Repository: novuhq/novu Branch: next Commit: 3a807619a630 Files: 6554 Total size: 20.9 MB Directory structure: gitextract_65y55uur/ ├── .agents/ │ └── skills/ │ ├── email-best-practices/ │ │ ├── SKILL.md │ │ └── resources/ │ │ ├── branding.md │ │ ├── compliance.md │ │ ├── deliverability.md │ │ ├── email-capture.md │ │ ├── email-types.md │ │ ├── list-management.md │ │ ├── marketing-emails.md │ │ ├── sending-reliability.md │ │ ├── transactional-email-catalog.md │ │ ├── transactional-emails.md │ │ └── webhooks-events.md │ └── react-email/ │ ├── SKILL.md │ ├── TESTS.md │ └── references/ │ ├── COMPONENTS.md │ ├── I18N.md │ ├── PATTERNS.md │ ├── SENDING.md │ └── STYLING.md ├── .claude/ │ └── skills/ │ └── better-auth-best-practices/ │ └── SKILL.md ├── .coderabbit.yaml ├── .copilotignore ├── .cursor/ │ ├── Dockerfile │ ├── agents/ │ │ ├── impact-checker.md │ │ └── verifier.md │ ├── commands/ │ │ ├── code-review-checklist.md │ │ └── create-pr.md │ ├── rules/ │ │ ├── api-property-optionality-hygiene.mdc │ │ ├── api.mdc │ │ ├── clickhouse.mdc │ │ ├── context-engineering.mdc │ │ ├── dal-repository.mdc │ │ ├── dashboard.mdc │ │ ├── dependency-graph.mdc │ │ ├── infrastructure.mdc │ │ ├── novu.mdc │ │ ├── packages.mdc │ │ ├── pullrequest.mdc │ │ ├── testing.mdc │ │ ├── worker.mdc │ │ └── ws.mdc │ ├── scripts/ │ │ └── dead-code/ │ │ ├── knip.config.jsonc │ │ └── scan.sh │ ├── settings.json │ └── skills/ │ ├── better-auth-best-practices/ │ │ └── SKILL.md │ ├── enterprise-submodule/ │ │ └── SKILL.md │ └── run-api-e2e-tests/ │ └── SKILL.md ├── .cursorignore ├── .deepsource.toml ├── .devcontainer/ │ ├── Dockerfile │ ├── devcontainer.json │ └── docker-compose.yml ├── .editorconfig ├── .github/ │ ├── CODEOWNERS │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ ├── docs_feedback.yml │ │ ├── feature_request.yml │ │ └── polishing.yml │ ├── ISSUE_TEMPLATE.md │ ├── PULL_REQUEST_TEMPLATE.md │ ├── actions/ │ │ ├── cache/ │ │ │ └── action.yml │ │ ├── checkout-submodules/ │ │ │ └── action.yml │ │ ├── free-space/ │ │ │ └── action.yml │ │ ├── run-api/ │ │ │ └── action.yml │ │ ├── run-backend/ │ │ │ └── action.yml │ │ ├── setup-project/ │ │ │ └── action.yml │ │ ├── setup-project-minimal/ │ │ │ └── action.yml │ │ ├── setup-redis-cluster/ │ │ │ └── action.yml │ │ ├── slack-notify-on-failure/ │ │ │ └── action.yml │ │ ├── start-localstack/ │ │ │ └── action.yml │ │ └── validate-openapi/ │ │ └── action.yml │ ├── labeler.yml │ └── workflows/ │ ├── check-only.yml │ ├── check-submodule-sync-merge.yaml │ ├── check-submodule-sync-pr.yaml │ ├── codeql-analysis.yml │ ├── community-label.yml │ ├── contributor-checks.yml │ ├── conventional-commit.yml │ ├── deploy.yml │ ├── deployment-summary.yml │ ├── dev-deploy-dashboard.yml │ ├── dev-deploy-inbound-mail.yml │ ├── issue-label.yml │ ├── jarvis.yml │ ├── on-pr-change.yml │ ├── on-pr.yml │ ├── on-push-trigger.yml │ ├── pr-labeler.yml │ ├── pr-manager.yml │ ├── prepare-cloud-release.yaml │ ├── prepare-enterprise-self-hosted-release.yml │ ├── prepare-self-hosted-release.yml │ ├── preview-packages.yml │ ├── prod-deploy-inbound-mail.yml │ ├── release-packages.yml │ ├── reusable-api-e2e.yml │ ├── reusable-dashboard-deploy.yml │ ├── reusable-dashboard-e2e.yml │ ├── reusable-inbound-mail-e2e.yml │ ├── reusable-webhook-e2e.yml │ ├── reusable-worker-e2e.yml │ ├── reusable-ws-e2e.yml │ ├── rollback.yml │ └── scripts/ │ ├── add-triage-label.js │ ├── community-contribution-label.js │ ├── is-community-contributor.js │ ├── stop-only.sh │ └── validate-submodule-sync.sh ├── .gitignore ├── .gitmodules ├── .husky/ │ └── pre-commit ├── .idea/ │ ├── .gitignore │ ├── aws.xml │ ├── codeStyles/ │ │ ├── Project.xml │ │ └── codeStyleConfig.xml │ ├── discord.xml │ ├── inspectionProfiles/ │ │ └── Project_Default.xml │ ├── jsLibraryMappings.xml │ ├── modules.xml │ ├── novu.iml │ ├── nx-angular-config.xml │ ├── runConfigurations/ │ │ ├── API.xml │ │ ├── API___TEST.xml │ │ ├── APPLICATION_GENERIC.xml │ │ ├── DAL.xml │ │ ├── DAL2.xml │ │ ├── DOCS.xml │ │ ├── EE_AUTH.xml │ │ ├── EMBED.xml │ │ ├── RUN_LOCAL_ENV.xml │ │ ├── RUN_TEST_ENV.xml │ │ ├── SHARED.xml │ │ ├── SHARED_WEB.xml │ │ ├── TESTING.xml │ │ ├── WEB.xml │ │ ├── WEBHOOK.xml │ │ ├── WEB___CYPRESS.xml │ │ ├── WIDGET.xml │ │ ├── WIDGET_CLI.xml │ │ ├── WIDGET___CYPRESS.xml │ │ ├── WIDGET___TEST.xml │ │ ├── WORKER.xml │ │ ├── WORKER___TEST.xml │ │ ├── WS.xml │ │ ├── WS___TEST.xml │ │ └── _template__of_Mocha.xml │ ├── swagger-settings.xml │ └── vcs.xml ├── .markdownlint.jsonc ├── .npmrc ├── .npmrc-cloud ├── .nvmrc ├── .nxignore ├── .vscode/ │ ├── extensions.json │ ├── launch.json │ ├── settings.json │ └── tasks.json ├── AGENTS.md ├── CITATION.cff ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── EE-PACKAGES-LICENSE ├── LICENSE-ENTERPRISE ├── LICENSE-MIT ├── README.md ├── SECURITY.md ├── _templates/ │ ├── module/ │ │ └── new/ │ │ ├── controller.ejs.t │ │ ├── module.ejs.t │ │ ├── prompt.ejs.t │ │ └── usecase-index.ejs.t │ └── usecase/ │ └── new/ │ ├── command.ejs.t │ ├── import-inject.ejs.t │ ├── import-row-inject.ejs.t │ ├── prompt.ejs.t │ └── usecase.ejs.t ├── apps/ │ ├── api/ │ │ ├── .gitignore │ │ ├── .mocharc.json │ │ ├── .spectral.yaml │ │ ├── .swcrc │ │ ├── .vscode/ │ │ │ └── settings.json │ │ ├── Dockerfile │ │ ├── README.md │ │ ├── admin/ │ │ │ ├── connect-to-dal.ts │ │ │ ├── make-json-backup.ts │ │ │ ├── remove-organization.ts │ │ │ └── remove-user-account.ts │ │ ├── e2e/ │ │ │ ├── compile-email-template.e2e.ts │ │ │ ├── enterprise/ │ │ │ │ └── inbound-webhook/ │ │ │ │ └── process-inbound-webhook.e2e.ts │ │ │ ├── mock-http-client.ts │ │ │ ├── retry.e2e.ts │ │ │ ├── setup.ts │ │ │ └── test-bridge-server.ts │ │ ├── exportOpenAPIJSON.ts │ │ ├── jarvis-api-intro.md │ │ ├── migrations/ │ │ │ ├── 001-add-default-identifier-to-topic-subscribers/ │ │ │ │ ├── add-default-identifier-to-topic-subscribers-migration.spec.ts │ │ │ │ └── add-default-identifier-to-topic-subscribers-migration.ts │ │ │ ├── 002-remove-duplicate-identifiers/ │ │ │ │ ├── remove-duplicate-identifiers.spec.ts │ │ │ │ └── remove-duplicate-identifiers.ts │ │ │ ├── add-layout-id-to-email-controls/ │ │ │ │ ├── add-layout-id-to-email-controls-migration.spec.ts │ │ │ │ └── add-layout-id-to-email-controls-migration.ts │ │ │ ├── changes-migration.ts │ │ │ ├── clickhouse-migrations/ │ │ │ │ ├── 1_initial_schema.sql │ │ │ │ ├── 2_add_workflow_id_to_schema.sql │ │ │ │ ├── 3_analytics_tables.sql │ │ │ │ ├── 4_refactor_traces_schema.sql │ │ │ │ ├── 5_finalize_table_exchange.sql │ │ │ │ └── README.md │ │ │ ├── deleteLogs/ │ │ │ │ └── dropLogsCollection.ts │ │ │ ├── email-step-ui-schema-html-editor/ │ │ │ │ ├── email-step-ui-schema-html-editor-migration.spec.ts │ │ │ │ └── email-step-ui-schema-html-editor-migration.ts │ │ │ ├── encrypt-api-keys/ │ │ │ │ ├── encrypt-api-keys-migration.spec.ts │ │ │ │ └── encrypt-api-keys-migration.ts │ │ │ ├── encrypt-credentials/ │ │ │ │ ├── encrypt-credentials-migration.spec.ts │ │ │ │ └── encrypt-credentials-migration.ts │ │ │ ├── expire-at/ │ │ │ │ ├── expire-at-delay.migration.spec.ts │ │ │ │ ├── expire-at.migration.spec.ts │ │ │ │ └── expire-at.migration.ts │ │ │ ├── fcm-credentials/ │ │ │ │ ├── fcm-credentials-migration.spec.ts │ │ │ │ └── fcm-credentials-migration.ts │ │ │ ├── in-app-integration/ │ │ │ │ └── in-app-integration.migration.ts │ │ │ ├── integration-scheme-update/ │ │ │ │ ├── add-integration-identifier-migration.spec.ts │ │ │ │ ├── add-integration-identifier-migration.ts │ │ │ │ ├── add-primary-priority-migration.ts │ │ │ │ └── update-primary-for-disabled-novu-integrations.ts │ │ │ ├── layout-identifier-update/ │ │ │ │ ├── add-layout-identifier-migration.spec.ts │ │ │ │ └── add-layout-identifier-migration.ts │ │ │ ├── normalize-message-template-cta-action/ │ │ │ │ ├── normalize-message-cta-action-migration.ts │ │ │ │ ├── normalize-message-template-cta-action-migration.spec.ts │ │ │ │ └── normalize-message-template-cta-action-migration.ts │ │ │ ├── normalize-users-email/ │ │ │ │ └── normalize-users-email.migration.ts │ │ │ ├── novu-integrations/ │ │ │ │ └── novu-integrations.migration.ts │ │ │ ├── preference-centralization/ │ │ │ │ ├── preference-centralization-migration.spec.ts │ │ │ │ └── preference-centralization-migration.ts │ │ │ ├── preferences-uniqueness/ │ │ │ │ ├── preferences-uniqueness-migration.spec.ts │ │ │ │ └── preferences-uniqueness-migration.ts │ │ │ ├── secure-to-boolean/ │ │ │ │ ├── secure-to-boolean-migration.spec.ts │ │ │ │ └── secure-to-boolean-migration.ts │ │ │ ├── seen-read-support/ │ │ │ │ ├── seen-read-support.migration.spec.ts │ │ │ │ └── seen-read-support.migration.ts │ │ │ ├── subscribers/ │ │ │ │ └── remove-duplicated-subscribers/ │ │ │ │ ├── remove-duplicated-subscribers.migration.spec.ts │ │ │ │ └── remove-duplicated-subscribers.migration.ts │ │ │ └── topic-subscriber-normalize/ │ │ │ ├── topic-subscriber-normalize.migration.spec.ts │ │ │ └── topic-subscriber-normalize.migration.ts │ │ ├── nest-cli.json │ │ ├── package.json │ │ ├── project.json │ │ ├── scripts/ │ │ │ ├── check-api-property-optionality.ts │ │ │ ├── clickhouse-seeder/ │ │ │ │ ├── README.md │ │ │ │ ├── config.ts │ │ │ │ ├── generators.ts │ │ │ │ ├── inserter.ts │ │ │ │ └── time-distribution.ts │ │ │ ├── generate-metadata.ts │ │ │ ├── run-novu-v2-e2e-shard.cjs │ │ │ ├── seed-clickhouse.ts │ │ │ └── seed-triggers.ts │ │ ├── src/ │ │ │ ├── .example.env │ │ │ ├── app/ │ │ │ │ ├── activity/ │ │ │ │ │ ├── activity.controller.ts │ │ │ │ │ ├── activity.module.ts │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── get-charts.request.dto.ts │ │ │ │ │ │ ├── get-charts.response.dto.ts │ │ │ │ │ │ ├── get-request.request.dto.ts │ │ │ │ │ │ ├── get-request.response.dto.ts │ │ │ │ │ │ ├── get-requests.dto.ts │ │ │ │ │ │ ├── get-requests.response.dto.ts │ │ │ │ │ │ ├── shared.dto.ts │ │ │ │ │ │ ├── workflow-run-response.dto.ts │ │ │ │ │ │ ├── workflow-runs-request.dto.ts │ │ │ │ │ │ └── workflow-runs-response.dto.ts │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ ├── get-requests.e2e.ts │ │ │ │ │ │ ├── get-workflow-run.e2e.ts │ │ │ │ │ │ └── get-workflow-runs.e2e.ts │ │ │ │ │ ├── shared/ │ │ │ │ │ │ ├── mappers.ts │ │ │ │ │ │ └── select.const.ts │ │ │ │ │ └── usecases/ │ │ │ │ │ ├── build-active-subscribers-chart/ │ │ │ │ │ │ ├── build-active-subscribers-chart.command.ts │ │ │ │ │ │ ├── build-active-subscribers-chart.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── build-active-subscribers-trend-chart/ │ │ │ │ │ │ ├── build-active-subscribers-trend-chart.command.ts │ │ │ │ │ │ ├── build-active-subscribers-trend-chart.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── build-avg-messages-per-subscriber-chart/ │ │ │ │ │ │ ├── build-avg-messages-per-subscriber-chart.command.ts │ │ │ │ │ │ ├── build-avg-messages-per-subscriber-chart.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── build-delivery-trend-chart/ │ │ │ │ │ │ ├── build-delivery-trend-chart.command.ts │ │ │ │ │ │ ├── build-delivery-trend-chart.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── build-interaction-trend-chart/ │ │ │ │ │ │ ├── build-interaction-trend-chart.command.ts │ │ │ │ │ │ ├── build-interaction-trend-chart.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── build-messages-delivered-chart/ │ │ │ │ │ │ ├── build-messages-delivered-chart.command.ts │ │ │ │ │ │ ├── build-messages-delivered-chart.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── build-provider-by-volume-chart/ │ │ │ │ │ │ ├── build-provider-by-volume-chart.command.ts │ │ │ │ │ │ ├── build-provider-by-volume-chart.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── build-total-interactions-chart/ │ │ │ │ │ │ ├── build-total-interactions-chart.command.ts │ │ │ │ │ │ ├── build-total-interactions-chart.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── build-workflow-by-volume-chart/ │ │ │ │ │ │ ├── build-workflow-by-volume-chart.command.ts │ │ │ │ │ │ ├── build-workflow-by-volume-chart.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── build-workflow-runs-count-chart/ │ │ │ │ │ │ ├── build-workflow-runs-count-chart.command.ts │ │ │ │ │ │ ├── build-workflow-runs-count-chart.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── build-workflow-runs-metric-chart/ │ │ │ │ │ │ ├── build-workflow-runs-metric-chart.command.ts │ │ │ │ │ │ ├── build-workflow-runs-metric-chart.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── build-workflow-runs-trend-chart/ │ │ │ │ │ │ ├── build-workflow-runs-trend-chart.command.ts │ │ │ │ │ │ ├── build-workflow-runs-trend-chart.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── get-charts/ │ │ │ │ │ │ ├── get-charts.command.ts │ │ │ │ │ │ └── get-charts.usecase.ts │ │ │ │ │ ├── get-request/ │ │ │ │ │ │ ├── get-request.command.ts │ │ │ │ │ │ └── get-request.usecase.ts │ │ │ │ │ ├── get-requests/ │ │ │ │ │ │ ├── get-requests.command.ts │ │ │ │ │ │ └── get-requests.usecase.ts │ │ │ │ │ ├── get-workflow-run/ │ │ │ │ │ │ ├── get-workflow-run.command.ts │ │ │ │ │ │ └── get-workflow-run.usecase.ts │ │ │ │ │ └── get-workflow-runs/ │ │ │ │ │ ├── get-workflow-runs.command.ts │ │ │ │ │ └── get-workflow-runs.usecase.ts │ │ │ │ ├── analytics/ │ │ │ │ │ ├── analytics.controller.ts │ │ │ │ │ ├── analytics.module.ts │ │ │ │ │ └── usecases/ │ │ │ │ │ └── hubspot-identify-form/ │ │ │ │ │ ├── hubspot-identify-form.command.ts │ │ │ │ │ └── hubspot-identify-form.usecase.ts │ │ │ │ ├── auth/ │ │ │ │ │ ├── auth.controller.ts │ │ │ │ │ ├── auth.module.ts │ │ │ │ │ ├── community.auth.module.config.ts │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── login.dto.ts │ │ │ │ │ │ ├── password-reset.dto.ts │ │ │ │ │ │ ├── update-password.dto.ts │ │ │ │ │ │ └── user-registration.dto.ts │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ ├── clerk.strategy.spec.ts │ │ │ │ │ │ ├── link-entities.service.spec.ts │ │ │ │ │ │ ├── login.e2e.ts │ │ │ │ │ │ ├── password-reset.e2e.ts │ │ │ │ │ │ ├── permissions.guard.e2e.ts │ │ │ │ │ │ ├── switch-organization.e2e.ts │ │ │ │ │ │ ├── update-password.e2e.ts │ │ │ │ │ │ ├── user-registration.e2e.ts │ │ │ │ │ │ └── user.auth.guard.e2e.ts │ │ │ │ │ ├── ee.auth.module.config.ts │ │ │ │ │ ├── framework/ │ │ │ │ │ │ ├── auth.decorator.ts │ │ │ │ │ │ ├── community.user.auth.guard.ts │ │ │ │ │ │ ├── external-api.decorator.ts │ │ │ │ │ │ └── root-environment-guard.service.ts │ │ │ │ │ ├── services/ │ │ │ │ │ │ ├── auth.service.ts │ │ │ │ │ │ ├── community.auth.service.ts │ │ │ │ │ │ └── passport/ │ │ │ │ │ │ ├── apikey.strategy.ts │ │ │ │ │ │ ├── github.strategy.ts │ │ │ │ │ │ ├── jwt.strategy.ts │ │ │ │ │ │ ├── newrelic.util.ts │ │ │ │ │ │ └── subscriber-jwt.strategy.ts │ │ │ │ │ └── usecases/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── login/ │ │ │ │ │ │ ├── login.command.ts │ │ │ │ │ │ └── login.usecase.ts │ │ │ │ │ ├── password-reset/ │ │ │ │ │ │ ├── password-reset.command.ts │ │ │ │ │ │ └── password-reset.usecase.ts │ │ │ │ │ ├── password-reset-request/ │ │ │ │ │ │ ├── password-reset-request.command.ts │ │ │ │ │ │ └── password-reset-request.usecase.ts │ │ │ │ │ ├── register/ │ │ │ │ │ │ ├── user-register.command.ts │ │ │ │ │ │ └── user-register.usecase.ts │ │ │ │ │ ├── switch-environment/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── switch-environment.command.ts │ │ │ │ │ │ └── switch-environment.usecase.ts │ │ │ │ │ ├── switch-organization/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── switch-organization.command.ts │ │ │ │ │ │ └── switch-organization.usecase.ts │ │ │ │ │ └── update-password/ │ │ │ │ │ ├── update-password.command.ts │ │ │ │ │ └── update-password.usecase.ts │ │ │ │ ├── billing/ │ │ │ │ │ └── e2e/ │ │ │ │ │ ├── checkout-session-completed.e2e-ee.ts │ │ │ │ │ ├── create-checkout-session.e2e-ee.ts │ │ │ │ │ ├── create-subscription.e2e-ee.ts │ │ │ │ │ ├── create-usage-records.e2e-ee.ts │ │ │ │ │ ├── customer-subscription-created.e2e-ee.ts │ │ │ │ │ ├── customer-subscription-deleted.e2e-ee.ts │ │ │ │ │ ├── get-event-resource-limit.e2e-ee.ts │ │ │ │ │ ├── get-platform-notification-usage.e2e-ee.ts │ │ │ │ │ ├── get-portal-link.e2e-ee.ts │ │ │ │ │ ├── get-prices.e2e-ee.ts │ │ │ │ │ ├── get-subscription.e2e-ee.ts │ │ │ │ │ ├── quota-throttler.guard.e2e-ee.ts │ │ │ │ │ └── verify-customer.e2e-ee.ts │ │ │ │ ├── blueprint/ │ │ │ │ │ ├── blueprint.controller.ts │ │ │ │ │ ├── blueprint.module.ts │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── get-blueprint.response.dto.ts │ │ │ │ │ │ └── grouped-blueprint.response.dto.ts │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ ├── get-blueprints-by-id.e2e.ts │ │ │ │ │ │ └── get-grouped-blueprints.e2e.ts │ │ │ │ │ └── usecases/ │ │ │ │ │ ├── get-blueprint/ │ │ │ │ │ │ ├── get-blueprint.command.ts │ │ │ │ │ │ ├── get-blueprint.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── get-grouped-blueprints/ │ │ │ │ │ │ ├── consts.ts │ │ │ │ │ │ ├── get-grouped-blueprints.command.ts │ │ │ │ │ │ ├── get-grouped-blueprints.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── bridge/ │ │ │ │ │ ├── bridge.controller.ts │ │ │ │ │ ├── bridge.module.ts │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── create-bridge-request.dto.ts │ │ │ │ │ │ ├── create-bridge-response.dto.ts │ │ │ │ │ │ ├── validate-bridge-url-request.dto.ts │ │ │ │ │ │ └── validate-bridge-url-response.dto.ts │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ ├── health-check.e2e.ts │ │ │ │ │ │ └── sync.e2e.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── usecases/ │ │ │ │ │ ├── get-bridge-status/ │ │ │ │ │ │ ├── get-bridge-status.command.ts │ │ │ │ │ │ ├── get-bridge-status.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── store-control-values/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── store-control-values.command.ts │ │ │ │ │ │ └── store-control-values.usecase.ts │ │ │ │ │ └── sync/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── sync.command.ts │ │ │ │ │ └── sync.usecase.ts │ │ │ │ ├── change/ │ │ │ │ │ ├── change.module.ts │ │ │ │ │ ├── changes.controller.ts │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── bulk-apply-change.dto.ts │ │ │ │ │ │ ├── change-request.dto.ts │ │ │ │ │ │ └── change-response.dto.ts │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ ├── get-changes.e2e.ts │ │ │ │ │ │ ├── promote-changes.e2e.ts │ │ │ │ │ │ └── promote-layout-changes.e2e.ts │ │ │ │ │ └── usecases/ │ │ │ │ │ ├── apply-change/ │ │ │ │ │ │ ├── apply-change.command.ts │ │ │ │ │ │ ├── apply-change.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── bulk-apply-change/ │ │ │ │ │ │ ├── bulk-apply-change.command.ts │ │ │ │ │ │ └── bulk-apply-change.usecase.ts │ │ │ │ │ ├── count-changes/ │ │ │ │ │ │ ├── count-changes.command.ts │ │ │ │ │ │ └── count-changes.usecase.ts │ │ │ │ │ ├── create-change/ │ │ │ │ │ │ └── create-change.spec.ts │ │ │ │ │ ├── get-changes/ │ │ │ │ │ │ ├── get-changes.command.ts │ │ │ │ │ │ └── get-changes.usecase.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── promote-change-to-environment/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── promote-change-to-environment.command.ts │ │ │ │ │ │ └── promote-change-to-environment.usecase.ts │ │ │ │ │ ├── promote-feed-change/ │ │ │ │ │ │ └── promote-feed-change.ts │ │ │ │ │ ├── promote-layout-change/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── promote-layout-change.use-case.ts │ │ │ │ │ ├── promote-message-template-change/ │ │ │ │ │ │ └── promote-message-template-change.ts │ │ │ │ │ ├── promote-notification-group-change/ │ │ │ │ │ │ └── promote-notification-group-change.ts │ │ │ │ │ ├── promote-notification-template-change/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── promote-notification-template-change.usecase.ts │ │ │ │ │ ├── promote-translation-change/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── promote-translation-change.usecase.ts │ │ │ │ │ ├── promote-translation-group-change/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── promote-translation-group-change.usecase.ts │ │ │ │ │ ├── promote-type-change.command.ts │ │ │ │ │ └── shared/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── notification-template-change.interface.ts │ │ │ │ ├── channel-connections/ │ │ │ │ │ ├── channel-connections.controller.ts │ │ │ │ │ ├── channel-connections.module.ts │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── create-channel-connection-request.dto.ts │ │ │ │ │ │ ├── cursor-pagination-query.dto.ts │ │ │ │ │ │ ├── dto.mapper.ts │ │ │ │ │ │ ├── get-channel-connection-response.dto.ts │ │ │ │ │ │ ├── list-channel-connections-query.dto.ts │ │ │ │ │ │ ├── list-channel-connections-response.dto.ts │ │ │ │ │ │ ├── shared.dto.ts │ │ │ │ │ │ └── update-channel-connection-request.dto.ts │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ ├── create-channel-connection.e2e.ts │ │ │ │ │ │ ├── delete-channel-connection.e2e.ts │ │ │ │ │ │ ├── get-channel-connection.e2e.ts │ │ │ │ │ │ ├── helpers/ │ │ │ │ │ │ │ └── channel-helpers.ts │ │ │ │ │ │ ├── list-channel-connections.e2e.ts │ │ │ │ │ │ └── update-channel-connection.e2e.ts │ │ │ │ │ └── usecases/ │ │ │ │ │ ├── create-channel-connection/ │ │ │ │ │ │ ├── create-channel-connection.command.ts │ │ │ │ │ │ └── create-channel-connection.usecase.ts │ │ │ │ │ ├── delete-channel-connection/ │ │ │ │ │ │ ├── delete-channel-connection.command.ts │ │ │ │ │ │ └── delete-channel-connection.usecase.ts │ │ │ │ │ ├── get-channel-connection/ │ │ │ │ │ │ ├── get-channel-connection.command.ts │ │ │ │ │ │ └── get-channel-connection.usecase.ts │ │ │ │ │ ├── list-channel-connections/ │ │ │ │ │ │ ├── list-channel-connections.command.ts │ │ │ │ │ │ └── list-channel-connections.usecase.ts │ │ │ │ │ └── update-channel-connection/ │ │ │ │ │ ├── update-channel-connection.command.ts │ │ │ │ │ └── update-channel-connection.usecase.ts │ │ │ │ ├── channel-endpoints/ │ │ │ │ │ ├── channel-endpoints.controller.ts │ │ │ │ │ ├── channel-endpoints.module.ts │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── create-channel-endpoint-request.dto.ts │ │ │ │ │ │ ├── create-channel-endpoint-variants.dto.ts │ │ │ │ │ │ ├── cursor-pagination-query.dto.ts │ │ │ │ │ │ ├── dto.mapper.ts │ │ │ │ │ │ ├── endpoint-types.dto.ts │ │ │ │ │ │ ├── get-channel-endpoint-response.dto.ts │ │ │ │ │ │ ├── list-channel-endpoints-query.dto.ts │ │ │ │ │ │ ├── list-channel-endpoints-response.dto.ts │ │ │ │ │ │ └── update-channel-endpoint-request.dto.ts │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ ├── create-channel-endpoint.e2e.ts │ │ │ │ │ │ ├── delete-channel-endpoint.e2e.ts │ │ │ │ │ │ ├── get-channel-endpoint.e2e.ts │ │ │ │ │ │ ├── list-channel-endpoints.e2e.ts │ │ │ │ │ │ └── update-channel-endpoint.e2e.ts │ │ │ │ │ ├── usecases/ │ │ │ │ │ │ ├── create-channel-endpoint/ │ │ │ │ │ │ │ ├── create-channel-endpoint.command.ts │ │ │ │ │ │ │ └── create-channel-endpoint.usecase.ts │ │ │ │ │ │ ├── delete-channel-endpoint/ │ │ │ │ │ │ │ ├── delete-channel-endpoint.command.ts │ │ │ │ │ │ │ └── delete-channel-endpoint.usecase.ts │ │ │ │ │ │ ├── get-channel-endpoint/ │ │ │ │ │ │ │ ├── get-channel-endpoint.command.ts │ │ │ │ │ │ │ └── get-channel-endpoint.usecase.ts │ │ │ │ │ │ ├── list-channel-endpoints/ │ │ │ │ │ │ │ ├── list-channel-endpoints.command.ts │ │ │ │ │ │ │ └── list-channel-endpoints.usecase.ts │ │ │ │ │ │ └── update-channel-endpoint/ │ │ │ │ │ │ ├── update-channel-endpoint.command.ts │ │ │ │ │ │ └── update-channel-endpoint.usecase.ts │ │ │ │ │ └── validators/ │ │ │ │ │ └── channel-endpoint.validator.ts │ │ │ │ ├── content-templates/ │ │ │ │ │ ├── content-templates.controller.ts │ │ │ │ │ ├── content-templates.module.ts │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ ├── preview-email.e2e.ts │ │ │ │ │ │ └── preview-step.e2e.ts │ │ │ │ │ └── usecases/ │ │ │ │ │ └── index.ts │ │ │ │ ├── contexts/ │ │ │ │ │ ├── contexts.controller.ts │ │ │ │ │ ├── contexts.module.ts │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── create-context-request.dto.ts │ │ │ │ │ │ ├── cursor-pagination-query.dto.ts │ │ │ │ │ │ ├── dto.mapper.ts │ │ │ │ │ │ ├── get-context-response.dto.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── list-contexts-query.dto.ts │ │ │ │ │ │ ├── list-contexts-response.dto.ts │ │ │ │ │ │ └── update-context-request.dto.ts │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ ├── create-context.e2e.ts │ │ │ │ │ │ ├── delete-context.e2e.ts │ │ │ │ │ │ ├── get-context.e2e.ts │ │ │ │ │ │ ├── list-contexts.e2e.ts │ │ │ │ │ │ └── update-context.e2e.ts │ │ │ │ │ └── usecases/ │ │ │ │ │ ├── create-context/ │ │ │ │ │ │ ├── create-context.command.ts │ │ │ │ │ │ ├── create-context.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── delete-context/ │ │ │ │ │ │ ├── delete-context.command.ts │ │ │ │ │ │ ├── delete-context.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── get-context/ │ │ │ │ │ │ ├── get-context.command.ts │ │ │ │ │ │ ├── get-context.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── list-contexts/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── list-contexts.command.ts │ │ │ │ │ │ └── list-contexts.usecase.ts │ │ │ │ │ └── update-context/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── update-context.command.ts │ │ │ │ │ └── update-context.usecase.ts │ │ │ │ ├── environment-variables/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── create-environment-variable-request.dto.ts │ │ │ │ │ │ ├── environment-variable-response.dto.ts │ │ │ │ │ │ ├── get-environment-variable-usage-response.dto.ts │ │ │ │ │ │ ├── get-environment-variables-request.dto.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── update-environment-variable-request.dto.ts │ │ │ │ │ ├── environment-variables.controller.ts │ │ │ │ │ ├── environment-variables.module.ts │ │ │ │ │ └── usecases/ │ │ │ │ │ ├── create-environment-variable/ │ │ │ │ │ │ ├── create-environment-variable.command.ts │ │ │ │ │ │ └── create-environment-variable.usecase.ts │ │ │ │ │ ├── delete-environment-variable/ │ │ │ │ │ │ ├── delete-environment-variable.command.ts │ │ │ │ │ │ └── delete-environment-variable.usecase.ts │ │ │ │ │ ├── get-environment-variable/ │ │ │ │ │ │ ├── get-environment-variable.command.ts │ │ │ │ │ │ └── get-environment-variable.usecase.ts │ │ │ │ │ ├── get-environment-variable-usage/ │ │ │ │ │ │ ├── get-environment-variable-usage.command.ts │ │ │ │ │ │ ├── get-environment-variable-usage.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── get-environment-variables/ │ │ │ │ │ │ ├── get-environment-variables.command.ts │ │ │ │ │ │ └── get-environment-variables.usecase.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── update-environment-variable/ │ │ │ │ │ ├── update-environment-variable.command.ts │ │ │ │ │ └── update-environment-variable.usecase.ts │ │ │ │ ├── environments-v1/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── api-key.dto.ts │ │ │ │ │ │ ├── create-environment-request.dto.ts │ │ │ │ │ │ ├── environment-response.dto.ts │ │ │ │ │ │ └── update-environment-request.dto.ts │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ ├── environments.controller.e2e.ts │ │ │ │ │ │ ├── get-api-keys.e2e.ts │ │ │ │ │ │ └── regenerate-api-keys.e2e.ts │ │ │ │ │ ├── environments-v1.controller.ts │ │ │ │ │ ├── environments-v1.module.ts │ │ │ │ │ ├── novu-bridge-client.ts │ │ │ │ │ ├── novu-bridge.controller.ts │ │ │ │ │ ├── novu-bridge.module.ts │ │ │ │ │ └── usecases/ │ │ │ │ │ ├── construct-framework-workflow/ │ │ │ │ │ │ ├── construct-framework-workflow.command.ts │ │ │ │ │ │ ├── construct-framework-workflow.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── create-environment/ │ │ │ │ │ │ ├── create-environment.command.ts │ │ │ │ │ │ ├── create-environment.e2e.ts │ │ │ │ │ │ └── create-environment.usecase.ts │ │ │ │ │ ├── delete-environment/ │ │ │ │ │ │ ├── delete-environment.command.ts │ │ │ │ │ │ ├── delete-environment.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── generate-unique-api-key/ │ │ │ │ │ │ ├── generate-unique-api-key.spec.ts │ │ │ │ │ │ └── generate-unique-api-key.usecase.ts │ │ │ │ │ ├── get-api-keys/ │ │ │ │ │ │ ├── get-api-keys.command.ts │ │ │ │ │ │ └── get-api-keys.usecase.ts │ │ │ │ │ ├── get-environment/ │ │ │ │ │ │ ├── get-environment.command.ts │ │ │ │ │ │ ├── get-environment.e2e.ts │ │ │ │ │ │ ├── get-environment.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── get-my-environments/ │ │ │ │ │ │ ├── get-my-environments.command.ts │ │ │ │ │ │ ├── get-my-environments.e2e.ts │ │ │ │ │ │ └── get-my-environments.usecase.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── output-renderers/ │ │ │ │ │ │ ├── base-translation-renderer.usecase.ts │ │ │ │ │ │ ├── chat-output-renderer.usecase.ts │ │ │ │ │ │ ├── delay-output-renderer.usecase.ts │ │ │ │ │ │ ├── digest-output-renderer.usecase.ts │ │ │ │ │ │ ├── email-output-renderer.spec.ts │ │ │ │ │ │ ├── email-output-renderer.usecase.ts │ │ │ │ │ │ ├── in-app-output-renderer.usecase.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── novu-branding-html.ts │ │ │ │ │ │ ├── push-output-renderer.usecase.ts │ │ │ │ │ │ ├── render-command.ts │ │ │ │ │ │ ├── sms-output-renderer.usecase.ts │ │ │ │ │ │ └── throttle-output-renderer.usecase.ts │ │ │ │ │ ├── regenerate-api-keys/ │ │ │ │ │ │ └── regenerate-api-keys.usecase.ts │ │ │ │ │ └── update-environment/ │ │ │ │ │ ├── update-environment.command.ts │ │ │ │ │ ├── update-environment.e2e-ee.ts │ │ │ │ │ ├── update-environment.e2e.ts │ │ │ │ │ └── update-environment.usecase.ts │ │ │ │ ├── environments-v2/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── diff-environment.dto.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── publish-environment.dto.ts │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ ├── environments-v2-diff.e2e.ts │ │ │ │ │ │ ├── environments-v2-publish.e2e.ts │ │ │ │ │ │ └── get-environment-tags.e2e.ts │ │ │ │ │ ├── environments.controller.ts │ │ │ │ │ ├── environments.module.ts │ │ │ │ │ ├── services/ │ │ │ │ │ │ ├── dependency-analyzer.service.ts │ │ │ │ │ │ ├── environment-validation.service.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── types/ │ │ │ │ │ │ └── sync.types.ts │ │ │ │ │ └── usecases/ │ │ │ │ │ ├── diff-environment/ │ │ │ │ │ │ ├── diff-environment.command.ts │ │ │ │ │ │ ├── diff-environment.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── publish-environment/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── publish-environment.command.ts │ │ │ │ │ │ └── publish-environment.usecase.ts │ │ │ │ │ └── sync-strategies/ │ │ │ │ │ ├── adapters/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── layout-comparator.adapter.ts │ │ │ │ │ │ ├── layout-delete.adapter.ts │ │ │ │ │ │ ├── layout-repository.adapter.ts │ │ │ │ │ │ ├── layout-sync.adapter.ts │ │ │ │ │ │ ├── workflow-comparator.adapter.ts │ │ │ │ │ │ ├── workflow-delete.adapter.ts │ │ │ │ │ │ ├── workflow-repository.adapter.ts │ │ │ │ │ │ └── workflow-sync.adapter.ts │ │ │ │ │ ├── base/ │ │ │ │ │ │ ├── base-sync.strategy.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── interfaces/ │ │ │ │ │ │ │ ├── base-comparator.interface.ts │ │ │ │ │ │ │ ├── base-delete.interface.ts │ │ │ │ │ │ │ ├── base-repository.interface.ts │ │ │ │ │ │ │ ├── base-sync.interface.ts │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ └── operations/ │ │ │ │ │ │ ├── base-diff.operation.ts │ │ │ │ │ │ ├── base-sync.operation.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── builders/ │ │ │ │ │ │ ├── diff-result.builder.ts │ │ │ │ │ │ └── sync-result.builder.ts │ │ │ │ │ ├── comparators/ │ │ │ │ │ │ ├── layout.comparator.ts │ │ │ │ │ │ └── workflow.comparator.ts │ │ │ │ │ ├── constants/ │ │ │ │ │ │ └── sync.constants.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── layout-sync.strategy.ts │ │ │ │ │ ├── normalizers/ │ │ │ │ │ │ ├── layout.normalizer.ts │ │ │ │ │ │ └── workflow.normalizer.ts │ │ │ │ │ ├── operations/ │ │ │ │ │ │ ├── layout-diff.operation.ts │ │ │ │ │ │ ├── layout-repository.service.ts │ │ │ │ │ │ ├── layout-sync.operation.ts │ │ │ │ │ │ ├── workflow-diff.operation.ts │ │ │ │ │ │ ├── workflow-repository.service.ts │ │ │ │ │ │ └── workflow-sync.operation.ts │ │ │ │ │ ├── sync.module.ts │ │ │ │ │ ├── types/ │ │ │ │ │ │ ├── layout-sync.types.ts │ │ │ │ │ │ └── workflow-sync.types.ts │ │ │ │ │ └── workflow-sync.strategy.ts │ │ │ │ ├── events/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── test-email-request.dto.ts │ │ │ │ │ │ ├── trigger-event-request.dto.ts │ │ │ │ │ │ ├── trigger-event-response.dto.ts │ │ │ │ │ │ └── trigger-event-to-all-request.dto.ts │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ ├── bridge-trigger.e2e.ts │ │ │ │ │ │ ├── bulk-trigger.e2e.ts │ │ │ │ │ │ ├── cancel-event.e2e.ts │ │ │ │ │ │ ├── context-events.e2e.ts │ │ │ │ │ │ ├── delay-events.e2e.ts │ │ │ │ │ │ ├── digest-events.e2e.ts │ │ │ │ │ │ ├── process-subscriber.e2e.ts │ │ │ │ │ │ ├── scheduled-digest.e2e.ts │ │ │ │ │ │ ├── send-message-email.e2e.ts │ │ │ │ │ │ ├── send-message-push.e2e.ts │ │ │ │ │ │ ├── test-email.e2e.ts │ │ │ │ │ │ ├── throttle-events.e2e.ts │ │ │ │ │ │ ├── trigger-event-preferences.e2e.ts │ │ │ │ │ │ ├── trigger-event-to-all.e2e.ts │ │ │ │ │ │ ├── trigger-event-topic.e2e.ts │ │ │ │ │ │ ├── trigger-event.e2e.ts │ │ │ │ │ │ └── utils/ │ │ │ │ │ │ ├── poll-for-job-status-change.util.ts │ │ │ │ │ │ └── sleep.util.ts │ │ │ │ │ ├── events.controller.ts │ │ │ │ │ ├── events.module.ts │ │ │ │ │ ├── exceptions/ │ │ │ │ │ │ └── payload-validation-exception.ts │ │ │ │ │ ├── usecases/ │ │ │ │ │ │ ├── cancel-delayed/ │ │ │ │ │ │ │ ├── cancel-delayed.command.ts │ │ │ │ │ │ │ ├── cancel-delayed.usecase.ts │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── parse-event-request/ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── parse-event-request.command.ts │ │ │ │ │ │ │ ├── parse-event-request.e2e.ts │ │ │ │ │ │ │ └── parse-event-request.usecase.ts │ │ │ │ │ │ ├── process-bulk-trigger/ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── process-bulk-trigger.command.ts │ │ │ │ │ │ │ └── process-bulk-trigger.usecase.ts │ │ │ │ │ │ ├── send-test-email/ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── send-test-email.command.ts │ │ │ │ │ │ │ └── send-test-email.usecase.ts │ │ │ │ │ │ └── trigger-event-to-all/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── trigger-event-to-all.command.ts │ │ │ │ │ │ ├── trigger-event-to-all.spec.ts │ │ │ │ │ │ └── trigger-event-to-all.usecase.ts │ │ │ │ │ └── utils/ │ │ │ │ │ └── trigger-recipient-validation.ts │ │ │ │ ├── execution-details/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ └── execution-details-request.dto.ts │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ └── get-execution-details.e2e.ts │ │ │ │ │ ├── execution-details.controller.ts │ │ │ │ │ ├── execution-details.module.ts │ │ │ │ │ └── usecases/ │ │ │ │ │ ├── get-execution-details/ │ │ │ │ │ │ ├── get-execution-details.command.ts │ │ │ │ │ │ ├── get-execution-details.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── feeds/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── create-feed-request.dto.ts │ │ │ │ │ │ └── feed-response.dto.ts │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ ├── create-feed.e2e.ts │ │ │ │ │ │ ├── delete-feed.e2e.ts │ │ │ │ │ │ └── get-feeds.e2e.ts │ │ │ │ │ ├── feeds.controller.ts │ │ │ │ │ ├── feeds.module.ts │ │ │ │ │ └── usecases/ │ │ │ │ │ ├── create-feed/ │ │ │ │ │ │ ├── create-feed.command.ts │ │ │ │ │ │ └── create-feed.usecase.ts │ │ │ │ │ ├── delete-feed/ │ │ │ │ │ │ ├── delete-feed.command.ts │ │ │ │ │ │ └── delete-feed.usecase.ts │ │ │ │ │ ├── get-feeds/ │ │ │ │ │ │ ├── get-feeds.command.ts │ │ │ │ │ │ └── get-feeds.usecase.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── health/ │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ └── health-check.e2e.ts │ │ │ │ │ ├── health.controller.ts │ │ │ │ │ └── health.module.ts │ │ │ │ ├── inbound-parse/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ └── get-mx-record.dto.ts │ │ │ │ │ ├── inbound-parse.controller.ts │ │ │ │ │ ├── inbound-parse.module.ts │ │ │ │ │ └── usecases/ │ │ │ │ │ ├── get-mx-record/ │ │ │ │ │ │ ├── get-mx-record.command.ts │ │ │ │ │ │ └── get-mx-record.usecase.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── inbox/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── action-type-request.dto.ts │ │ │ │ │ │ ├── bulk-update-preferences-request.dto.ts │ │ │ │ │ │ ├── create-topic-subscription-request.dto.ts │ │ │ │ │ │ ├── get-notifications-count-request.dto.ts │ │ │ │ │ │ ├── get-notifications-count-response.dto.ts │ │ │ │ │ │ ├── get-notifications-request.dto.ts │ │ │ │ │ │ ├── get-notifications-response.dto.ts │ │ │ │ │ │ ├── get-preferences-request.dto.ts │ │ │ │ │ │ ├── get-preferences-response.dto.ts │ │ │ │ │ │ ├── inbox-notification.dto.ts │ │ │ │ │ │ ├── mark-notifications-as-seen-request.dto.ts │ │ │ │ │ │ ├── snooze-notification-request.dto.ts │ │ │ │ │ │ ├── subscriber-session-request.dto.ts │ │ │ │ │ │ ├── subscriber-session-response.dto.ts │ │ │ │ │ │ ├── update-all-notifications-request.dto.ts │ │ │ │ │ │ ├── update-preferences-request.dto.ts │ │ │ │ │ │ └── workflow.dto.ts │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ ├── context-aware-topic-subscriptions.e2e.ts │ │ │ │ │ │ ├── create-topic-subscription.e2e.ts │ │ │ │ │ │ ├── delete-notifications.e2e.ts │ │ │ │ │ │ ├── get-notifications-count.e2e.ts │ │ │ │ │ │ ├── get-notifications.e2e.ts │ │ │ │ │ │ ├── get-preferences.e2e.ts │ │ │ │ │ │ ├── get-topic-subscription.e2e.ts │ │ │ │ │ │ ├── mark-notification-as.e2e.ts │ │ │ │ │ │ ├── mark-notifications-as-seen.e2e.ts │ │ │ │ │ │ ├── session.e2e.ts │ │ │ │ │ │ ├── snooze-unsnooze-notification.e2e.ts │ │ │ │ │ │ ├── update-all-notifications.e2e.ts │ │ │ │ │ │ ├── update-notification-action.e2e.ts │ │ │ │ │ │ ├── update-preferences.e2e.ts │ │ │ │ │ │ └── update-subscription-workflow-preferences.e2e.ts │ │ │ │ │ ├── inbox.controller.ts │ │ │ │ │ ├── inbox.module.ts │ │ │ │ │ ├── inbox.topic.controller.ts │ │ │ │ │ ├── interceptors/ │ │ │ │ │ │ └── context-compatibility.interceptor.ts │ │ │ │ │ ├── usecases/ │ │ │ │ │ │ ├── bulk-update-preferences/ │ │ │ │ │ │ │ ├── bulk-update-preferences.command.ts │ │ │ │ │ │ │ ├── bulk-update-preferences.spec.ts │ │ │ │ │ │ │ └── bulk-update-preferences.usecase.ts │ │ │ │ │ │ ├── delete-all-notifications/ │ │ │ │ │ │ │ ├── delete-all-notifications.command.ts │ │ │ │ │ │ │ └── delete-all-notifications.usecase.ts │ │ │ │ │ │ ├── delete-many-notifications/ │ │ │ │ │ │ │ ├── delete-many-notifications.command.ts │ │ │ │ │ │ │ └── delete-many-notifications.usecase.ts │ │ │ │ │ │ ├── delete-notification/ │ │ │ │ │ │ │ ├── delete-notification.command.ts │ │ │ │ │ │ │ └── delete-notification.usecase.ts │ │ │ │ │ │ ├── delete-subscription/ │ │ │ │ │ │ │ ├── delete-subscription.command.ts │ │ │ │ │ │ │ └── delete-subscription.usecase.ts │ │ │ │ │ │ ├── get-inbox-preferences/ │ │ │ │ │ │ │ ├── get-inbox-preferences.command.ts │ │ │ │ │ │ │ ├── get-inbox-preferences.spec.ts │ │ │ │ │ │ │ └── get-inbox-preferences.usecase.ts │ │ │ │ │ │ ├── get-notifications/ │ │ │ │ │ │ │ ├── get-notifications.command.ts │ │ │ │ │ │ │ ├── get-notifications.spec.ts │ │ │ │ │ │ │ └── get-notifications.usecase.ts │ │ │ │ │ │ ├── get-topic-subscriptions/ │ │ │ │ │ │ │ ├── get-topic-subscriptions.command.ts │ │ │ │ │ │ │ └── get-topic-subscriptions.usecase.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── mark-many-notifications-as/ │ │ │ │ │ │ │ ├── mark-many-notifications-as.command.ts │ │ │ │ │ │ │ ├── mark-many-notifications-as.spec.ts │ │ │ │ │ │ │ └── mark-many-notifications-as.usecase.ts │ │ │ │ │ │ ├── mark-notification-as/ │ │ │ │ │ │ │ ├── mark-notification-as.command.ts │ │ │ │ │ │ │ ├── mark-notification-as.spec.ts │ │ │ │ │ │ │ └── mark-notification-as.usecase.ts │ │ │ │ │ │ ├── mark-notifications-as-seen/ │ │ │ │ │ │ │ ├── mark-notifications-as-seen.command.ts │ │ │ │ │ │ │ └── mark-notifications-as-seen.usecase.ts │ │ │ │ │ │ ├── noop-send-webhook-message.usecase.ts │ │ │ │ │ │ ├── notifications-count/ │ │ │ │ │ │ │ ├── notifications-count.command.ts │ │ │ │ │ │ │ ├── notifications-count.spec.ts │ │ │ │ │ │ │ └── notifications-count.usecase.ts │ │ │ │ │ │ ├── session/ │ │ │ │ │ │ │ ├── session.command.ts │ │ │ │ │ │ │ ├── session.spec.ts │ │ │ │ │ │ │ └── session.usecase.ts │ │ │ │ │ │ ├── snooze-notification/ │ │ │ │ │ │ │ ├── snooze-notification.command.ts │ │ │ │ │ │ │ ├── snooze-notification.spec.ts │ │ │ │ │ │ │ └── snooze-notification.usecase.ts │ │ │ │ │ │ ├── unsnooze-notification/ │ │ │ │ │ │ │ ├── unsnooze-notification.command.ts │ │ │ │ │ │ │ ├── unsnooze-notification.spec.ts │ │ │ │ │ │ │ └── unsnooze-notification.usecase.ts │ │ │ │ │ │ ├── update-all-notifications/ │ │ │ │ │ │ │ ├── update-all-notifications.command.ts │ │ │ │ │ │ │ ├── update-all-notifications.spec.ts │ │ │ │ │ │ │ └── update-all-notifications.usecase.ts │ │ │ │ │ │ ├── update-notification-action/ │ │ │ │ │ │ │ ├── update-notification-action.command.ts │ │ │ │ │ │ │ ├── update-notification-action.spec.ts │ │ │ │ │ │ │ └── update-notification-action.usecase.ts │ │ │ │ │ │ └── update-preferences/ │ │ │ │ │ │ ├── update-preferences.command.ts │ │ │ │ │ │ ├── update-preferences.spec.ts │ │ │ │ │ │ └── update-preferences.usecase.ts │ │ │ │ │ └── utils/ │ │ │ │ │ ├── analytics.ts │ │ │ │ │ ├── encryption.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── notification-mapper.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── validate-data.ts │ │ │ │ ├── integrations/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── auto-configure-integration-request.dto.ts │ │ │ │ │ │ ├── auto-configure-integration-response.dto.ts │ │ │ │ │ │ ├── create-integration-request.dto.ts │ │ │ │ │ │ ├── generate-chat-oauth-url-response.dto.ts │ │ │ │ │ │ ├── generate-chat-oauth-url.dto.ts │ │ │ │ │ │ ├── get-channel-type-limit.sto.ts │ │ │ │ │ │ └── update-integration.dto.ts │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ ├── create-integration.e2e.ts │ │ │ │ │ │ ├── deactivate-integration.e2e.ts │ │ │ │ │ │ ├── get-active-integration.e2e.ts │ │ │ │ │ │ ├── get-decrypted-integrations.e2e.ts │ │ │ │ │ │ ├── get-in-app-activated.e2e.ts │ │ │ │ │ │ ├── get-integration.e2e.ts │ │ │ │ │ │ ├── get-webhook-support-status.e2e.ts │ │ │ │ │ │ ├── remove-integration.e2e.ts │ │ │ │ │ │ ├── set-itegration-as-primary.e2e.ts │ │ │ │ │ │ └── update-integration.e2e.ts │ │ │ │ │ ├── integrations.controller.ts │ │ │ │ │ ├── integrations.module.ts │ │ │ │ │ └── usecases/ │ │ │ │ │ ├── auto-configure-integration/ │ │ │ │ │ │ ├── auto-configure-integration.command.ts │ │ │ │ │ │ └── auto-configure-integration.usecase.ts │ │ │ │ │ ├── chat-oauth-callback/ │ │ │ │ │ │ ├── chat-oauth-callback.command.ts │ │ │ │ │ │ ├── chat-oauth-callback.response.ts │ │ │ │ │ │ ├── chat-oauth-callback.usecase.ts │ │ │ │ │ │ ├── msteams-oauth-callback/ │ │ │ │ │ │ │ ├── msteams-oauth-callback.command.ts │ │ │ │ │ │ │ └── msteams-oauth-callback.usecase.ts │ │ │ │ │ │ └── slack-oauth-callback/ │ │ │ │ │ │ ├── slack-oauth-callback.command.ts │ │ │ │ │ │ └── slack-oauth-callback.usecase.ts │ │ │ │ │ ├── check-integration/ │ │ │ │ │ │ ├── check-integration-email.usecase.ts │ │ │ │ │ │ ├── check-integration.command.ts │ │ │ │ │ │ └── check-integration.usecase.ts │ │ │ │ │ ├── create-integration/ │ │ │ │ │ │ ├── create-integration.command.ts │ │ │ │ │ │ └── create-integration.usecase.ts │ │ │ │ │ ├── create-novu-integrations/ │ │ │ │ │ │ ├── create-novu-integrations.command.ts │ │ │ │ │ │ └── create-novu-integrations.usecase.ts │ │ │ │ │ ├── generate-chat-oath-url/ │ │ │ │ │ │ ├── chat-oauth.constants.ts │ │ │ │ │ │ ├── generate-chat-oauth-url.command.ts │ │ │ │ │ │ ├── generate-chat-oauth-url.usecase.ts │ │ │ │ │ │ ├── generate-msteams-oath-url/ │ │ │ │ │ │ │ ├── generate-msteams-oauth-url.command.ts │ │ │ │ │ │ │ └── generate-msteams-oauth-url.usecase.ts │ │ │ │ │ │ └── generate-slack-oath-url/ │ │ │ │ │ │ ├── generate-slack-oauth-url.command.ts │ │ │ │ │ │ └── generate-slack-oauth-url.usecase.ts │ │ │ │ │ ├── get-in-app-activated/ │ │ │ │ │ │ ├── get-in-app-activated.command.ts │ │ │ │ │ │ └── get-in-app-activated.usecase.ts │ │ │ │ │ ├── get-integrations/ │ │ │ │ │ │ ├── get-integrations.command.ts │ │ │ │ │ │ └── get-integrations.usecase.ts │ │ │ │ │ ├── get-webhook-support-status/ │ │ │ │ │ │ ├── get-webhook-support-status.command.ts │ │ │ │ │ │ └── get-webhook-support-status.usecase.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── remove-integration/ │ │ │ │ │ │ ├── remove-integration.command.ts │ │ │ │ │ │ └── remove-integration.usecase.ts │ │ │ │ │ ├── set-integration-as-primary/ │ │ │ │ │ │ ├── set-integration-as-primary.command.ts │ │ │ │ │ │ └── set-integration-as-primary.usecase.ts │ │ │ │ │ └── update-integration/ │ │ │ │ │ ├── update-integration.command.ts │ │ │ │ │ └── update-integration.usecase.ts │ │ │ │ ├── internal/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── scheduler-callback.dto.ts │ │ │ │ │ │ └── subscriber-online-state.dto.ts │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ └── internal.e2e.ts │ │ │ │ │ ├── guards/ │ │ │ │ │ │ └── internal-callback.guard.ts │ │ │ │ │ ├── internal.controller.ts │ │ │ │ │ ├── internal.module.ts │ │ │ │ │ └── usecases/ │ │ │ │ │ ├── handle-scheduler-callback/ │ │ │ │ │ │ ├── handle-scheduler-callback.command.ts │ │ │ │ │ │ └── handle-scheduler-callback.usecase.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── update-subscriber-online-state/ │ │ │ │ │ ├── update-subscriber-online-state.command.ts │ │ │ │ │ └── update-subscriber-online-state.usecase.ts │ │ │ │ ├── invites/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── bulk-invite-members.dto.ts │ │ │ │ │ │ ├── invite-member.dto.ts │ │ │ │ │ │ └── resend-invite.dto.ts │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ ├── accept-invite.e2e.ts │ │ │ │ │ │ ├── bulk-invite.e2e.ts │ │ │ │ │ │ ├── get-invite.e2e.ts │ │ │ │ │ │ └── resend-invite.e2e.ts │ │ │ │ │ ├── invites.controller.ts │ │ │ │ │ ├── invites.module.ts │ │ │ │ │ └── usecases/ │ │ │ │ │ ├── accept-invite/ │ │ │ │ │ │ ├── accept-invite.command.ts │ │ │ │ │ │ └── accept-invite.usecase.ts │ │ │ │ │ ├── bulk-invite/ │ │ │ │ │ │ ├── bulk-invite.command.ts │ │ │ │ │ │ └── bulk-invite.usecase.ts │ │ │ │ │ ├── get-invite/ │ │ │ │ │ │ ├── get-invite.command.ts │ │ │ │ │ │ └── get-invite.usecase.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── invite-member/ │ │ │ │ │ │ ├── invite-member.command.ts │ │ │ │ │ │ └── invite-member.usecase.ts │ │ │ │ │ └── resend-invite/ │ │ │ │ │ ├── resend-invite.command.ts │ │ │ │ │ └── resend-invite.usecase.ts │ │ │ │ ├── layouts-v1/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── create-layout.dto.ts │ │ │ │ │ │ ├── filter-layouts.dto.ts │ │ │ │ │ │ ├── get-layout.dto.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── update-layout.dto.ts │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ ├── create-layout.e2e.ts │ │ │ │ │ │ ├── delete-layout.e2e.ts │ │ │ │ │ │ ├── filter-layouts.e2e.ts │ │ │ │ │ │ ├── get-layout.e2e.ts │ │ │ │ │ │ ├── helpers/ │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── set-default-layout.e2e.ts │ │ │ │ │ │ └── update-layout.e2e.ts │ │ │ │ │ ├── layouts-v1.controller.ts │ │ │ │ │ ├── layouts-v1.module.ts │ │ │ │ │ ├── types/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── usecases/ │ │ │ │ │ ├── check-layout-is-used/ │ │ │ │ │ │ ├── check-layout-is-used.command.ts │ │ │ │ │ │ ├── check-layout-is-used.use-case.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── create-default-layout/ │ │ │ │ │ │ ├── create-default-layout.command.ts │ │ │ │ │ │ ├── create-default-layout.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── create-default-layout-change/ │ │ │ │ │ │ ├── create-default-layout-change.command.ts │ │ │ │ │ │ └── create-default-layout-change.usecase.ts │ │ │ │ │ ├── create-layout/ │ │ │ │ │ │ ├── create-layout.command.ts │ │ │ │ │ │ ├── create-layout.use-case.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── create-layout-change/ │ │ │ │ │ │ ├── create-layout-change.command.ts │ │ │ │ │ │ ├── create-layout-change.use-case.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── delete-layout/ │ │ │ │ │ │ ├── delete-layout.command.ts │ │ │ │ │ │ ├── delete-layout.use-case.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── filter-layouts/ │ │ │ │ │ │ ├── filter-layouts.command.ts │ │ │ │ │ │ ├── filter-layouts.use-case.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── find-deleted-layout/ │ │ │ │ │ │ ├── find-deleted-layout.command.ts │ │ │ │ │ │ ├── find-deleted-layout.spec.ts │ │ │ │ │ │ ├── find-deleted-layout.use-case.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── set-default-layout/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── set-default-layout.command.ts │ │ │ │ │ │ └── set-default-layout.use-case.ts │ │ │ │ │ └── update-layout/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── update-layout.command.ts │ │ │ │ │ └── update-layout.use-case.ts │ │ │ │ ├── layouts-v2/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── duplicate-layout.dto.ts │ │ │ │ │ │ ├── generate-layout-preview-response.dto.ts │ │ │ │ │ │ ├── get-layout-list-query-params.dto.ts │ │ │ │ │ │ ├── get-layout-usage-response.dto.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── layout-preview-payload.dto.ts │ │ │ │ │ │ ├── layout-preview-request.dto.ts │ │ │ │ │ │ └── list-layout-response.dto.ts │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ ├── preview-layout.e2e.ts │ │ │ │ │ │ └── upsert-layout.e2e.ts │ │ │ │ │ ├── layouts.controller.ts │ │ │ │ │ ├── layouts.module.ts │ │ │ │ │ ├── usecases/ │ │ │ │ │ │ ├── build-layout-issues/ │ │ │ │ │ │ │ ├── build-layout-issues.command.ts │ │ │ │ │ │ │ └── build-layout-issues.usecase.ts │ │ │ │ │ │ ├── delete-layout/ │ │ │ │ │ │ │ ├── delete-layout.command.ts │ │ │ │ │ │ │ ├── delete-layout.use-case.spec.ts │ │ │ │ │ │ │ ├── delete-layout.use-case.ts │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── duplicate-layout/ │ │ │ │ │ │ │ ├── duplicate-layout.command.ts │ │ │ │ │ │ │ ├── duplicate-layout.use-case.spec.ts │ │ │ │ │ │ │ ├── duplicate-layout.use-case.ts │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── get-layout-usage/ │ │ │ │ │ │ │ ├── get-layout-usage.command.ts │ │ │ │ │ │ │ ├── get-layout-usage.usecase.ts │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── list-layouts/ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── list-layouts.command.ts │ │ │ │ │ │ │ ├── list-layouts.use-case.spec.ts │ │ │ │ │ │ │ └── list-layouts.use-case.ts │ │ │ │ │ │ ├── preview-layout/ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── preview-layout.command.ts │ │ │ │ │ │ │ ├── preview-layout.usecase.spec.ts │ │ │ │ │ │ │ ├── preview-layout.usecase.ts │ │ │ │ │ │ │ └── preview-utils.ts │ │ │ │ │ │ ├── sync-to-environment/ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── layout-sync-to-environment.command.ts │ │ │ │ │ │ │ └── layout-sync-to-environment.usecase.ts │ │ │ │ │ │ └── upsert-layout/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── upsert-layout.command.ts │ │ │ │ │ │ ├── upsert-layout.usecase.spec.ts │ │ │ │ │ │ └── upsert-layout.usecase.ts │ │ │ │ │ └── utils/ │ │ │ │ │ └── layout-templates.ts │ │ │ │ ├── message-template/ │ │ │ │ │ ├── message-template.controller.ts │ │ │ │ │ ├── message-template.module.ts │ │ │ │ │ └── usecases/ │ │ │ │ │ ├── find-message-templates-by-layout/ │ │ │ │ │ │ ├── find-message-templates-by-layout.command.ts │ │ │ │ │ │ ├── find-message-templates-by-layout.use-case.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── messages/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── delete-message-response.dto.ts │ │ │ │ │ │ ├── get-messages-requests.dto.ts │ │ │ │ │ │ └── remove-messages-by-transactionId-request.dto.ts │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ ├── get-messages.e2e.ts │ │ │ │ │ │ ├── remove-message.e2e.ts │ │ │ │ │ │ └── remove-messages-by-transactionId.e2e.ts │ │ │ │ │ ├── messages.controller.ts │ │ │ │ │ ├── messages.module.ts │ │ │ │ │ ├── params/ │ │ │ │ │ │ └── delete-message.param.ts │ │ │ │ │ └── usecases/ │ │ │ │ │ ├── get-messages/ │ │ │ │ │ │ ├── get-messages.command.ts │ │ │ │ │ │ ├── get-messages.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── remove-message/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── remove-message.command.ts │ │ │ │ │ │ └── remove-message.usecase.ts │ │ │ │ │ └── remove-messages-by-transactionId/ │ │ │ │ │ ├── remove-messages-by-transactionId.command.ts │ │ │ │ │ └── remove-messages-by-transactionId.usecase.ts │ │ │ │ ├── notification-groups/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── create-notification-group-request.dto.ts │ │ │ │ │ │ ├── delete-notification-group-response.dto.ts │ │ │ │ │ │ └── notification-group-response.dto.ts │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ ├── create-notification-group.e2e.ts │ │ │ │ │ │ ├── delete-notification-group.e2e.ts │ │ │ │ │ │ ├── get-notification-group.e2e.ts │ │ │ │ │ │ ├── get-notification-groups.e2e.ts │ │ │ │ │ │ └── update-notification-group.e2e.ts │ │ │ │ │ ├── notification-groups.controller.ts │ │ │ │ │ ├── notification-groups.module.ts │ │ │ │ │ └── usecases/ │ │ │ │ │ ├── create-notification-group/ │ │ │ │ │ │ ├── create-notification-group.command.ts │ │ │ │ │ │ └── create-notification-group.usecase.ts │ │ │ │ │ ├── delete-notification-group/ │ │ │ │ │ │ ├── delete-notification-group.command.ts │ │ │ │ │ │ └── delete-notification-group.usecase.ts │ │ │ │ │ ├── get-notification-group/ │ │ │ │ │ │ ├── get-notification-group.command.ts │ │ │ │ │ │ └── get-notification-group.usecase.ts │ │ │ │ │ ├── get-notification-groups/ │ │ │ │ │ │ ├── get-notification-groups.command.ts │ │ │ │ │ │ └── get-notification-groups.usecase.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── update-notification-group/ │ │ │ │ │ ├── update-notification-group.command.ts │ │ │ │ │ └── update-notification-group.usecase.ts │ │ │ │ ├── notifications/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── activities-request.dto.ts │ │ │ │ │ │ ├── activities-response.dto.ts │ │ │ │ │ │ ├── activity-graph-states-response.dto.ts │ │ │ │ │ │ └── activity-stats-response.dto.ts │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ ├── get-activity-feed.e2e.ts │ │ │ │ │ │ └── get-activity.e2e.ts │ │ │ │ │ ├── notification.controller.ts │ │ │ │ │ ├── notification.module.ts │ │ │ │ │ └── usecases/ │ │ │ │ │ ├── get-activity/ │ │ │ │ │ │ ├── get-activity.command.ts │ │ │ │ │ │ └── get-activity.usecase.ts │ │ │ │ │ ├── get-activity-feed/ │ │ │ │ │ │ ├── get-activity-feed.command.ts │ │ │ │ │ │ ├── get-activity-feed.usecase.spec.ts │ │ │ │ │ │ ├── get-activity-feed.usecase.ts │ │ │ │ │ │ └── map-feed-item-to.dto.ts │ │ │ │ │ ├── get-activity-graph-states/ │ │ │ │ │ │ ├── get-activity-graph-states.command.ts │ │ │ │ │ │ └── get-activity-graph-states.usecase.ts │ │ │ │ │ ├── get-activity-stats/ │ │ │ │ │ │ ├── get-activity-stats.command.ts │ │ │ │ │ │ ├── get-activity-stats.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── organization/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── create-organization.dto.ts │ │ │ │ │ │ ├── get-my-organization.dto.ts │ │ │ │ │ │ ├── get-organization-settings.dto.ts │ │ │ │ │ │ ├── get-organizations.dto.ts │ │ │ │ │ │ ├── member-response.dto.ts │ │ │ │ │ │ ├── organization-response.dto.ts │ │ │ │ │ │ ├── rename-organization.dto.ts │ │ │ │ │ │ ├── update-branding-details.dto.ts │ │ │ │ │ │ ├── update-member-roles.dto.ts │ │ │ │ │ │ └── update-organization-settings.dto.ts │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ ├── change-member-role.e2e.ts │ │ │ │ │ │ ├── create-organization.e2e.ts │ │ │ │ │ │ ├── get-members.e2e.ts │ │ │ │ │ │ ├── get-my-organization.e2e.ts │ │ │ │ │ │ ├── get-organizations.e2e.ts │ │ │ │ │ │ ├── remove-member.e2e.ts │ │ │ │ │ │ ├── rename-organization.e2e.ts │ │ │ │ │ │ ├── update-branding-details.e2e.ts │ │ │ │ │ │ └── update-organization-settings.e2e.ts │ │ │ │ │ ├── ee.organization.controller.ts │ │ │ │ │ ├── organization.controller.ts │ │ │ │ │ ├── organization.module.ts │ │ │ │ │ └── usecases/ │ │ │ │ │ ├── create-organization/ │ │ │ │ │ │ ├── create-organization.command.ts │ │ │ │ │ │ ├── create-organization.usecase.ts │ │ │ │ │ │ └── sync-external-organization/ │ │ │ │ │ │ ├── sync-external-organization.command.ts │ │ │ │ │ │ └── sync-external-organization.usecase.ts │ │ │ │ │ ├── get-my-organization/ │ │ │ │ │ │ ├── get-my-organization.command.ts │ │ │ │ │ │ └── get-my-organization.usecase.ts │ │ │ │ │ ├── get-organization/ │ │ │ │ │ │ ├── get-organization.command.ts │ │ │ │ │ │ └── get-organization.usecase.ts │ │ │ │ │ ├── get-organization-settings/ │ │ │ │ │ │ ├── get-organization-settings.command.ts │ │ │ │ │ │ └── get-organization-settings.usecase.ts │ │ │ │ │ ├── get-organizations/ │ │ │ │ │ │ ├── get-organizations.command.ts │ │ │ │ │ │ └── get-organizations.usecase.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── membership/ │ │ │ │ │ │ ├── add-member/ │ │ │ │ │ │ │ ├── add-member.command.ts │ │ │ │ │ │ │ └── add-member.usecase.ts │ │ │ │ │ │ ├── change-member-role/ │ │ │ │ │ │ │ ├── change-member-role.command.ts │ │ │ │ │ │ │ └── change-member-role.usecase.ts │ │ │ │ │ │ ├── get-members/ │ │ │ │ │ │ │ ├── get-members.command.ts │ │ │ │ │ │ │ └── get-members.usecase.ts │ │ │ │ │ │ └── remove-member/ │ │ │ │ │ │ ├── remove-member.command.ts │ │ │ │ │ │ └── remove-member.usecase.ts │ │ │ │ │ ├── rename-organization/ │ │ │ │ │ │ ├── rename-organization-command.ts │ │ │ │ │ │ └── rename-organization.usecase.ts │ │ │ │ │ ├── update-branding-details/ │ │ │ │ │ │ ├── update-branding-details.command.ts │ │ │ │ │ │ └── update-branding-details.usecase.ts │ │ │ │ │ └── update-organization-settings/ │ │ │ │ │ ├── update-organization-settings.command.ts │ │ │ │ │ └── update-organization-settings.usecase.ts │ │ │ │ ├── outbound-webhooks/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── create-webhook-portal-response.dto.ts │ │ │ │ │ │ └── get-webhook-portal-token-response.dto.ts │ │ │ │ │ ├── outbound-webhooks.controller.ts │ │ │ │ │ ├── outbound-webhooks.module.ts │ │ │ │ │ ├── usecases/ │ │ │ │ │ │ ├── create-webhook-portal-token/ │ │ │ │ │ │ │ ├── create-webhook-portal.command.ts │ │ │ │ │ │ │ └── create-webhook-portal.usecase.ts │ │ │ │ │ │ └── get-webhook-portal-token/ │ │ │ │ │ │ ├── get-webhook-portal-token.command.ts │ │ │ │ │ │ └── get-webhook-portal-token.usecase.ts │ │ │ │ │ └── webhooks.const.ts │ │ │ │ ├── partner-integrations/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── create-vercel-integration-request.dto.ts │ │ │ │ │ │ ├── create-vercel-integration-response.dto.ts │ │ │ │ │ │ └── update-vercel-integration-request.dto.ts │ │ │ │ │ ├── partner-integrations.controller.ts │ │ │ │ │ ├── partner-integrations.module.ts │ │ │ │ │ └── usecases/ │ │ │ │ │ ├── create-vercel-integration/ │ │ │ │ │ │ ├── create-vercel-integration.command.ts │ │ │ │ │ │ ├── create-vercel-integration.spec.ts │ │ │ │ │ │ └── create-vercel-integration.usecase.ts │ │ │ │ │ ├── get-vercel-integration/ │ │ │ │ │ │ ├── get-vercel-integration.command.ts │ │ │ │ │ │ ├── get-vercel-integration.spec.ts │ │ │ │ │ │ └── get-vercel-integration.usecase.ts │ │ │ │ │ ├── get-vercel-projects/ │ │ │ │ │ │ ├── get-vercel-integration-projects.command.ts │ │ │ │ │ │ ├── get-vercel-integration-projects.spec.ts │ │ │ │ │ │ └── get-vercel-integration-projects.usecase.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── process-vercel-webhook/ │ │ │ │ │ │ ├── process-vercel-webhook.command.ts │ │ │ │ │ │ ├── process-vercel-webhook.spec.ts │ │ │ │ │ │ └── process-vercel-webhook.usecase.ts │ │ │ │ │ └── update-vercel-integration/ │ │ │ │ │ ├── update-vercel-integration.command.ts │ │ │ │ │ ├── update-vercel-integration.spec.ts │ │ │ │ │ └── update-vercel-integration.usecase.ts │ │ │ │ ├── preferences/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── preferences.dto.ts │ │ │ │ │ │ └── upsert-preferences.dto.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── preferences.controller.ts │ │ │ │ │ ├── preferences.module.ts │ │ │ │ │ └── preferences.spec.ts │ │ │ │ ├── rate-limiting/ │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ └── throttler.guard.e2e.ts │ │ │ │ │ ├── guards/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── throttler.decorator.ts │ │ │ │ │ │ └── throttler.guard.ts │ │ │ │ │ ├── rate-limiting.module.ts │ │ │ │ │ └── usecases/ │ │ │ │ │ ├── evaluate-api-rate-limit/ │ │ │ │ │ │ ├── evaluate-api-rate-limit.command.ts │ │ │ │ │ │ ├── evaluate-api-rate-limit.spec.ts │ │ │ │ │ │ ├── evaluate-api-rate-limit.types.ts │ │ │ │ │ │ ├── evaluate-api-rate-limit.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── evaluate-token-bucket-rate-limit/ │ │ │ │ │ │ ├── evaluate-token-bucket-rate-limit.command.ts │ │ │ │ │ │ ├── evaluate-token-bucket-rate-limit.spec.ts │ │ │ │ │ │ ├── evaluate-token-bucket-rate-limit.types.ts │ │ │ │ │ │ ├── evaluate-token-bucket-rate-limit.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── get-api-rate-limit-algorithm-config/ │ │ │ │ │ │ ├── get-api-rate-limit-algorithm-config.spec.ts │ │ │ │ │ │ ├── get-api-rate-limit-algorithm-config.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── get-api-rate-limit-cost-config/ │ │ │ │ │ │ ├── get-api-rate-limit-cost-config.spec.ts │ │ │ │ │ │ ├── get-api-rate-limit-cost-config.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── get-api-rate-limit-maximum/ │ │ │ │ │ │ ├── get-api-rate-limit-maximum.command.ts │ │ │ │ │ │ ├── get-api-rate-limit-maximum.dto.ts │ │ │ │ │ │ ├── get-api-rate-limit-maximum.spec.ts │ │ │ │ │ │ ├── get-api-rate-limit-maximum.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── shared/ │ │ │ │ │ ├── commands/ │ │ │ │ │ │ ├── authenticated.command.ts │ │ │ │ │ │ ├── organization.command.ts │ │ │ │ │ │ └── project.command.ts │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── api-key.ts │ │ │ │ │ │ ├── base-responses.ts │ │ │ │ │ │ ├── base-subscriber-fields.dto.ts │ │ │ │ │ │ ├── channel-preference.ts │ │ │ │ │ │ ├── cursor-paginated-response.ts │ │ │ │ │ │ ├── cursor-pagination-request.ts │ │ │ │ │ │ ├── data-wrapper-dto.ts │ │ │ │ │ │ ├── limit-offset-pagination.dto.ts │ │ │ │ │ │ ├── message-template.ts │ │ │ │ │ │ ├── message.template.dto.ts │ │ │ │ │ │ ├── notification-step-dto.ts │ │ │ │ │ │ ├── pagination-request.ts │ │ │ │ │ │ ├── pagination-response.ts │ │ │ │ │ │ ├── pagination-with-filters-request.ts │ │ │ │ │ │ ├── preference-channels.ts │ │ │ │ │ │ ├── schedule.ts │ │ │ │ │ │ ├── subscription-details-response.dto.ts │ │ │ │ │ │ └── subscriptions/ │ │ │ │ │ │ ├── create-subscriptions-response.dto.ts │ │ │ │ │ │ ├── create-subscriptions.dto.ts │ │ │ │ │ │ └── update-subscription.dto.ts │ │ │ │ │ ├── framework/ │ │ │ │ │ │ ├── analytics-logs.guard.ts │ │ │ │ │ │ ├── analytics-logs.interceptor.ts │ │ │ │ │ │ ├── constants/ │ │ │ │ │ │ │ ├── headers.schema.ts │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ └── responses.schema.ts │ │ │ │ │ │ ├── exclude-from-idempotency.ts │ │ │ │ │ │ ├── idempotency.e2e.ts │ │ │ │ │ │ ├── idempotency.interceptor.ts │ │ │ │ │ │ ├── paginated-ok-response.decorator.ts │ │ │ │ │ │ ├── response.decorator.ts │ │ │ │ │ │ ├── response.interceptor.ts │ │ │ │ │ │ ├── swagger/ │ │ │ │ │ │ │ ├── headers.decorator.ts │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── injection.ts │ │ │ │ │ │ │ ├── keyless.security.ts │ │ │ │ │ │ │ ├── open.api.manipulation.component.ts │ │ │ │ │ │ │ ├── responses.decorator.ts │ │ │ │ │ │ │ ├── sdk.decorators.ts │ │ │ │ │ │ │ └── swagger.controller.ts │ │ │ │ │ │ └── user.decorator.ts │ │ │ │ │ ├── helpers/ │ │ │ │ │ │ ├── content.service.spec.ts │ │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ │ └── sdk/ │ │ │ │ │ │ │ └── e2e-sdk.helper.ts │ │ │ │ │ │ ├── generate-transaction-id.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── is-valid-hmac.ts │ │ │ │ │ │ └── utils/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── mapMarkMessageToWebSocketEvent.ts │ │ │ │ │ ├── interceptors/ │ │ │ │ │ │ └── product-feature.interceptor.ts │ │ │ │ │ ├── middleware/ │ │ │ │ │ │ └── request-id.middleware.ts │ │ │ │ │ ├── services/ │ │ │ │ │ │ └── encryption/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── shared.module.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── utils/ │ │ │ │ │ │ ├── auth.utils.ts │ │ │ │ │ │ ├── mappers.ts │ │ │ │ │ │ └── request-transaction.util.ts │ │ │ │ │ └── validators/ │ │ │ │ │ ├── image.validator.ts │ │ │ │ │ ├── is-enum-or-array.ts │ │ │ │ │ ├── is-mongo-id-or-array-of-ids.validator.ts │ │ │ │ │ ├── is-time-12-hour-format.validator.ts │ │ │ │ │ ├── json-schema.validator.ts │ │ │ │ │ └── weekly-schedule-disabled.validator.ts │ │ │ │ ├── step-resolvers/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── deploy-step-resolver-request.dto.ts │ │ │ │ │ │ ├── deploy-step-resolver-response.dto.ts │ │ │ │ │ │ ├── disconnect-step-resolver-request.dto.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── step-resolvers-count-response.dto.ts │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ └── step-resolvers.e2e.ts │ │ │ │ │ ├── services/ │ │ │ │ │ │ └── cloudflare-step-resolver-deploy.service.ts │ │ │ │ │ ├── step-resolvers.controller.ts │ │ │ │ │ ├── step-resolvers.module.ts │ │ │ │ │ ├── usecases/ │ │ │ │ │ │ ├── deploy-step-resolver/ │ │ │ │ │ │ │ ├── deploy-step-resolver.command.ts │ │ │ │ │ │ │ ├── deploy-step-resolver.usecase.ts │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── get-step-resolvers-count/ │ │ │ │ │ │ │ ├── get-step-resolvers-count.usecase.ts │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ └── sync-step-resolver-to-environment/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── sync-step-resolver-to-environment.command.ts │ │ │ │ │ │ └── sync-step-resolver-to-environment.usecase.ts │ │ │ │ │ └── utils/ │ │ │ │ │ └── generate-step-resolver-worker-id.ts │ │ │ │ ├── storage/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ └── upload-url-response.dto.ts │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ └── get-signed-url.e2e.ts │ │ │ │ │ ├── storage.controller.ts │ │ │ │ │ ├── storage.module.ts │ │ │ │ │ └── usecases/ │ │ │ │ │ ├── get-signed-url/ │ │ │ │ │ │ ├── get-signed-url.command.ts │ │ │ │ │ │ └── get-signed-url.usecase.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── subscribers/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── bulk-create-subscriber-response.dto.ts │ │ │ │ │ │ ├── chat-oauth-request.dto.ts │ │ │ │ │ │ ├── create-subscriber-request.dto.ts │ │ │ │ │ │ ├── delete-subscriber-response.dto.ts │ │ │ │ │ │ ├── get-in-app-notification-feed-for-subscriber.dto.ts │ │ │ │ │ │ ├── get-subscriber-preferences-response.dto.ts │ │ │ │ │ │ ├── get-subscribers.dto.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── mark-all-messages-as-request.dto.ts │ │ │ │ │ │ ├── subscriber-feed-response.dto.ts │ │ │ │ │ │ ├── subscriber-preference-override.dto.ts │ │ │ │ │ │ ├── subscriber-preference-template-response.dto.ts │ │ │ │ │ │ ├── subscriber-preference.dto.ts │ │ │ │ │ │ ├── subscribers-response.dto.ts │ │ │ │ │ │ ├── update-subscriber-global-preferences-request.dto.ts │ │ │ │ │ │ ├── update-subscriber-online-flag-request.dto.ts │ │ │ │ │ │ └── update-subscriber-request.dto.ts │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ ├── bulk-create-subscribers.e2e.ts │ │ │ │ │ │ ├── create-subscriber.e2e.ts │ │ │ │ │ │ ├── get-notifications-feed.e2e.ts │ │ │ │ │ │ ├── get-subscriber.e2e.ts │ │ │ │ │ │ ├── get-unseen-count.e2e.ts │ │ │ │ │ │ ├── helpers/ │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── mark-all-subscriber-messages.e2e.ts │ │ │ │ │ │ ├── mark-as-by-mark.e2e.ts │ │ │ │ │ │ ├── remove-subscriber.e2e.ts │ │ │ │ │ │ └── update-online-flag.e2e.ts │ │ │ │ │ ├── params/ │ │ │ │ │ │ ├── get-subscriber-preferences-by-level.params.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── query-objects/ │ │ │ │ │ │ └── unseen-count.query.ts │ │ │ │ │ ├── subscribersV1.controller.ts │ │ │ │ │ ├── subscribersV1.module.ts │ │ │ │ │ ├── unit/ │ │ │ │ │ │ └── update-subscriber-channel.spec.ts │ │ │ │ │ └── usecases/ │ │ │ │ │ ├── bulk-create-subscribers/ │ │ │ │ │ │ ├── bulk-create-subscribers.command.ts │ │ │ │ │ │ ├── bulk-create-subscribers.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── chat-oauth/ │ │ │ │ │ │ ├── chat-oauth.command.ts │ │ │ │ │ │ └── chat-oauth.usecase.ts │ │ │ │ │ ├── chat-oauth-callback/ │ │ │ │ │ │ ├── chat-oauth-callback.command.ts │ │ │ │ │ │ ├── chat-oauth-callback.result.ts │ │ │ │ │ │ ├── chat-oauth-callback.usecase.ts │ │ │ │ │ │ └── is-not-empty.spec.ts │ │ │ │ │ ├── delete-subscriber-credentials/ │ │ │ │ │ │ ├── delete-subscriber-credentials.command.ts │ │ │ │ │ │ ├── delete-subscriber-credentials.spec.ts │ │ │ │ │ │ ├── delete-subscriber-credentials.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── get-preferences-by-level/ │ │ │ │ │ │ ├── get-preferences-by-level.command.ts │ │ │ │ │ │ └── get-preferences-by-level.usecase.ts │ │ │ │ │ ├── get-subscriber/ │ │ │ │ │ │ ├── get-subscriber.command.ts │ │ │ │ │ │ ├── get-subscriber.spec.ts │ │ │ │ │ │ ├── get-subscriber.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── get-subscriber-global-preference/ │ │ │ │ │ │ ├── get-subscriber-global-preference.command.ts │ │ │ │ │ │ ├── get-subscriber-global-preference.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── get-subscriber-preference/ │ │ │ │ │ │ ├── get-subscriber-preference.command.ts │ │ │ │ │ │ ├── get-subscriber-preference.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── get-subscribers/ │ │ │ │ │ │ ├── get-subscribers.command.ts │ │ │ │ │ │ ├── get-subscribers.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── remove-subscriber/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── remove-subscriber.command.ts │ │ │ │ │ │ ├── remove-subscriber.spec.ts │ │ │ │ │ │ └── remove-subscriber.usecase.ts │ │ │ │ │ ├── search-by-external-subscriber-ids/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── search-by-external-subscriber-ids.command.ts │ │ │ │ │ │ ├── search-by-external-subscriber-ids.spec.ts │ │ │ │ │ │ └── search-by-external-subscriber-ids.use-case.ts │ │ │ │ │ └── update-subscriber-online-flag/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── update-subscriber-online-flag.command.ts │ │ │ │ │ └── update-subscriber-online-flag.usecase.ts │ │ │ │ ├── subscribers-v2/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── bulk-update-subscriber-preferences.dto.ts │ │ │ │ │ │ ├── context-keys-query.dto.ts │ │ │ │ │ │ ├── create-subscriber.dto.ts │ │ │ │ │ │ ├── cursor-pagination-query.dto.ts │ │ │ │ │ │ ├── get-subscriber-notifications-count-query.dto.ts │ │ │ │ │ │ ├── get-subscriber-notifications-count-response.dto.ts │ │ │ │ │ │ ├── get-subscriber-notifications-query.dto.ts │ │ │ │ │ │ ├── get-subscriber-notifications-response.dto.ts │ │ │ │ │ │ ├── get-subscriber-preferences-request.dto.ts │ │ │ │ │ │ ├── get-subscriber-preferences.dto.ts │ │ │ │ │ │ ├── inbox-notification.dto.ts │ │ │ │ │ │ ├── list-subscribers-query.dto.ts │ │ │ │ │ │ ├── list-subscribers-response.dto.ts │ │ │ │ │ │ ├── mark-subscriber-notifications-as-seen.dto.ts │ │ │ │ │ │ ├── patch-subscriber-preferences.dto.ts │ │ │ │ │ │ ├── patch-subscriber.dto.ts │ │ │ │ │ │ ├── remove-subscriber.dto.ts │ │ │ │ │ │ ├── snooze-subscriber-notification.dto.ts │ │ │ │ │ │ ├── subscriber-global-preference.dto.ts │ │ │ │ │ │ ├── subscriber-notification-action.dto.ts │ │ │ │ │ │ ├── subscriber-preferences-workflow-info.dto.ts │ │ │ │ │ │ ├── subscriber-workflow-preference.dto.ts │ │ │ │ │ │ └── update-all-subscriber-notifications.dto.ts │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ ├── create-subscriber.e2e.ts │ │ │ │ │ │ ├── delete-subscriber.e2e.ts │ │ │ │ │ │ ├── get-subscriber-preferences.e2e.ts │ │ │ │ │ │ ├── get-subscriber.e2e.ts │ │ │ │ │ │ ├── list-subscriber-subscriptions.e2e.ts │ │ │ │ │ │ ├── patch-subscriber-preferences.e2e.ts │ │ │ │ │ │ ├── patch-subscriber.e2e.ts │ │ │ │ │ │ └── subscriber-notifications.e2e.ts │ │ │ │ │ ├── subscribers.controller.e2e.ts │ │ │ │ │ ├── subscribers.controller.ts │ │ │ │ │ ├── subscribers.module.ts │ │ │ │ │ └── usecases/ │ │ │ │ │ ├── get-subscriber/ │ │ │ │ │ │ ├── get-subscriber.command.ts │ │ │ │ │ │ └── get-subscriber.usecase.ts │ │ │ │ │ ├── get-subscriber-preferences/ │ │ │ │ │ │ ├── get-subscriber-preferences.command.ts │ │ │ │ │ │ └── get-subscriber-preferences.usecase.ts │ │ │ │ │ ├── list-subscribers/ │ │ │ │ │ │ ├── list-subscribers.command.ts │ │ │ │ │ │ ├── list-subscribers.usecase.ts │ │ │ │ │ │ └── map-subscriber-entity-to.dto.ts │ │ │ │ │ ├── patch-subscriber/ │ │ │ │ │ │ ├── patch-subscriber.command.ts │ │ │ │ │ │ └── patch-subscriber.usecase.ts │ │ │ │ │ ├── remove-subscriber/ │ │ │ │ │ │ ├── remove-subscriber.command.ts │ │ │ │ │ │ └── remove-subscriber.usecase.ts │ │ │ │ │ └── update-subscriber-preferences/ │ │ │ │ │ ├── update-subscriber-preferences.command.ts │ │ │ │ │ └── update-subscriber-preferences.usecase.ts │ │ │ │ ├── subscriptions/ │ │ │ │ │ ├── subscriptions.module.ts │ │ │ │ │ ├── usecases/ │ │ │ │ │ │ ├── create-subscription-preferences/ │ │ │ │ │ │ │ ├── create-subscription-preferences.command.ts │ │ │ │ │ │ │ ├── create-subscription-preferences.usecase.ts │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── create-subscriptions/ │ │ │ │ │ │ │ ├── create-subscriptions.command.ts │ │ │ │ │ │ │ ├── create-subscriptions.usecase.ts │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── get-subscription/ │ │ │ │ │ │ │ ├── get-subscription.command.ts │ │ │ │ │ │ │ └── get-subscription.usecase.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── update-subscription/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── update-subscription.command.ts │ │ │ │ │ │ └── update-subscription.usecase.ts │ │ │ │ │ └── utils/ │ │ │ │ │ └── subscriptions.ts │ │ │ │ ├── support/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── create-thread.dto.ts │ │ │ │ │ │ └── plain-card.dto.ts │ │ │ │ │ ├── guards/ │ │ │ │ │ │ └── plain-cards.guard.ts │ │ │ │ │ ├── support.controller.ts │ │ │ │ │ ├── support.module.ts │ │ │ │ │ └── usecases/ │ │ │ │ │ ├── create-thread.command.ts │ │ │ │ │ ├── create-thread.usecase.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── plain-cards.command.ts │ │ │ │ │ └── plain-cards.usecase.ts │ │ │ │ ├── tenant/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── create-tenant-request.dto.ts │ │ │ │ │ │ ├── create-tenant-response.dto.ts │ │ │ │ │ │ ├── get-tenant-response.dto.ts │ │ │ │ │ │ ├── get-tenants-request.dto.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── update-tenant-request.dto.ts │ │ │ │ │ │ └── update-tenant-response.dto.ts │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ ├── create-tenant.e2e.ts │ │ │ │ │ │ ├── delete-tenant.e2e.ts │ │ │ │ │ │ ├── get-tenant.e2e.ts │ │ │ │ │ │ ├── get-tenants.e2e.ts │ │ │ │ │ │ └── update-tenant.e2e.ts │ │ │ │ │ ├── tenant.controller.ts │ │ │ │ │ ├── tenant.module.ts │ │ │ │ │ └── usecases/ │ │ │ │ │ ├── delete-tenant/ │ │ │ │ │ │ ├── delete-tenant.command.ts │ │ │ │ │ │ └── delete-tenant.usecase.ts │ │ │ │ │ ├── get-tenants/ │ │ │ │ │ │ ├── get-tenants.command.ts │ │ │ │ │ │ └── get-tenants.usecase.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── testing/ │ │ │ │ │ ├── auth.controller.ts │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ └── idempotency.dto.ts │ │ │ │ │ ├── product-feature.e2e.ts │ │ │ │ │ ├── rate-limiting.controller.ts │ │ │ │ │ ├── testing.controller.ts │ │ │ │ │ └── testing.module.ts │ │ │ │ ├── topics-v1/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── add-subscribers.dto.ts │ │ │ │ │ │ ├── assignSubscriberToTopicDto.ts │ │ │ │ │ │ ├── create-topic.dto.ts │ │ │ │ │ │ ├── filter-topics.dto.ts │ │ │ │ │ │ ├── get-topic.dto.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── remove-subscribers.dto.ts │ │ │ │ │ │ ├── rename-topic.dto.ts │ │ │ │ │ │ ├── topic-subscriber.dto.ts │ │ │ │ │ │ └── topic.dto.ts │ │ │ │ │ ├── topics-v1.controller.ts │ │ │ │ │ ├── topics-v1.module.ts │ │ │ │ │ ├── types/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── use-cases/ │ │ │ │ │ ├── add-subscribers/ │ │ │ │ │ │ ├── add-subscribers.command.ts │ │ │ │ │ │ ├── add-subscribers.use-case.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── create-topic/ │ │ │ │ │ │ ├── create-topic.command.ts │ │ │ │ │ │ ├── create-topic.use-case.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── delete-topic/ │ │ │ │ │ │ ├── delete-topic.command.ts │ │ │ │ │ │ ├── delete-topic.use-case.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── filter-topics/ │ │ │ │ │ │ ├── filter-topics.command.ts │ │ │ │ │ │ ├── filter-topics.use-case.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── get-topic/ │ │ │ │ │ │ ├── get-topic.command.ts │ │ │ │ │ │ ├── get-topic.use-case.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── get-topic-subscriber/ │ │ │ │ │ │ ├── get-topic-subscriber.command.ts │ │ │ │ │ │ ├── get-topic-subscriber.use-case.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── remove-subscribers/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── remove-subscribers.command.ts │ │ │ │ │ │ └── remove-subscribers.use-case.ts │ │ │ │ │ └── rename-topic/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── rename-topic.command.ts │ │ │ │ │ └── rename-topic.use-case.ts │ │ │ │ ├── topics-v2/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── create-topic-subscriptions.dto.ts │ │ │ │ │ │ ├── create-update-topic.dto.ts │ │ │ │ │ │ ├── cursor-pagination-query.dto.ts │ │ │ │ │ │ ├── delete-topic-response.dto.ts │ │ │ │ │ │ ├── delete-topic-subscriptions-response.dto.ts │ │ │ │ │ │ ├── delete-topic-subscriptions.dto.ts │ │ │ │ │ │ ├── list-subscriber-subscriptions-query.dto.ts │ │ │ │ │ │ ├── list-topic-subscriptions-query.dto.ts │ │ │ │ │ │ ├── list-topic-subscriptions-response.dto.ts │ │ │ │ │ │ ├── list-topics-query.dto.ts │ │ │ │ │ │ ├── list-topics-response.dto.ts │ │ │ │ │ │ ├── topic-response.dto.ts │ │ │ │ │ │ ├── topic-subscription-response.dto.ts │ │ │ │ │ │ ├── update-topic-subscription.dto.ts │ │ │ │ │ │ └── update-topic.dto.ts │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ ├── create-topic-subscriptions.e2e.ts │ │ │ │ │ │ ├── delete-topic-subscriptions.e2e.ts │ │ │ │ │ │ ├── delete-topic.e2e.ts │ │ │ │ │ │ ├── get-topic.e2e.ts │ │ │ │ │ │ ├── list-topic-subscriptions.e2e.ts │ │ │ │ │ │ ├── list-topics.e2e.ts │ │ │ │ │ │ ├── update-topic-subscription.e2e.ts │ │ │ │ │ │ ├── update-topic.e2e.ts │ │ │ │ │ │ └── upsert-topic.e2e.ts │ │ │ │ │ ├── topics-v2.module.ts │ │ │ │ │ ├── topics.controller.ts │ │ │ │ │ └── usecases/ │ │ │ │ │ ├── delete-topic/ │ │ │ │ │ │ ├── delete-topic.command.ts │ │ │ │ │ │ └── delete-topic.usecase.ts │ │ │ │ │ ├── delete-topic-subscriptions/ │ │ │ │ │ │ ├── delete-topic-subscriptions.command.ts │ │ │ │ │ │ ├── delete-topic-subscriptions.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── get-topic/ │ │ │ │ │ │ ├── get-topic.command.ts │ │ │ │ │ │ └── get-topic.usecase.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── list-subscriber-subscriptions/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── list-subscriber-subscriptions.command.ts │ │ │ │ │ │ └── list-subscriber-subscriptions.usecase.ts │ │ │ │ │ ├── list-topic-subscriptions/ │ │ │ │ │ │ ├── list-topic-subscriptions.command.ts │ │ │ │ │ │ └── list-topic-subscriptions.usecase.ts │ │ │ │ │ ├── list-topics/ │ │ │ │ │ │ ├── list-topics.command.ts │ │ │ │ │ │ ├── list-topics.usecase.ts │ │ │ │ │ │ └── map-topic-entity-to.dto.ts │ │ │ │ │ ├── update-topic/ │ │ │ │ │ │ ├── update-topic.command.ts │ │ │ │ │ │ └── update-topic.usecase.ts │ │ │ │ │ └── upsert-topic/ │ │ │ │ │ ├── upsert-topic.command.ts │ │ │ │ │ └── upsert-topic.usecase.ts │ │ │ │ ├── translations/ │ │ │ │ │ └── e2e/ │ │ │ │ │ ├── v1/ │ │ │ │ │ │ ├── create-translation.e2e-ee.ts │ │ │ │ │ │ ├── delete-translation-group.e2e-ee.ts │ │ │ │ │ │ ├── delete-translation.e2e-ee.ts │ │ │ │ │ │ ├── edit-translation.e2e-ee.ts │ │ │ │ │ │ ├── get-locales-from-content.e2e-ee.ts │ │ │ │ │ │ ├── get-locales.e2e-ee.ts │ │ │ │ │ │ ├── get-translation-group.e2e-ee.ts │ │ │ │ │ │ ├── get-translation-groups.e2e-ee.ts │ │ │ │ │ │ ├── get-translation.e2e-ee.ts │ │ │ │ │ │ ├── update-default-locale.e2e-ee.ts │ │ │ │ │ │ └── update-translation.e2e-ee.ts │ │ │ │ │ └── v2/ │ │ │ │ │ ├── create-translation.e2e-ee.ts │ │ │ │ │ ├── delete-translation-group.e2e-ee.ts │ │ │ │ │ ├── delete-translation.e2e-ee.ts │ │ │ │ │ ├── export-master-json.e2e-ee.ts │ │ │ │ │ ├── get-translation-group.e2e-ee.ts │ │ │ │ │ ├── get-translation.e2e-ee.ts │ │ │ │ │ ├── get-translations-list.e2e-ee.ts │ │ │ │ │ ├── import-master-json.e2e-ee.ts │ │ │ │ │ ├── translation-replacement.e2e-ee.ts │ │ │ │ │ ├── upload-master-json.e2e-ee.ts │ │ │ │ │ └── upload-translations.e2e-ee.ts │ │ │ │ ├── user/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── change-profile-email.dto.ts │ │ │ │ │ │ ├── update-profile-request.dto.ts │ │ │ │ │ │ ├── user-onboarding-request.dto.ts │ │ │ │ │ │ ├── user-onboarding-tour-request.dto.ts │ │ │ │ │ │ └── user-response.dto.ts │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ ├── email-change.e2e.ts │ │ │ │ │ │ ├── get-me.e2e.ts │ │ │ │ │ │ └── update-name-and-profile-picture.e2e.ts │ │ │ │ │ ├── usecases/ │ │ │ │ │ │ ├── base-user-profile.usecase.ts │ │ │ │ │ │ ├── create-user/ │ │ │ │ │ │ │ ├── create-user.command.ts │ │ │ │ │ │ │ ├── create-user.usecase.ts │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── get-my-profile/ │ │ │ │ │ │ │ ├── get-my-profile.dto.ts │ │ │ │ │ │ │ └── get-my-profile.usecase.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── update-name-and-profile-picture/ │ │ │ │ │ │ │ ├── update-name-and-profile-picture.command.ts │ │ │ │ │ │ │ └── update-name-and-profile-picture.usecase.ts │ │ │ │ │ │ ├── update-on-boarding/ │ │ │ │ │ │ │ ├── update-on-boarding.command.ts │ │ │ │ │ │ │ └── update-on-boarding.usecase.ts │ │ │ │ │ │ ├── update-on-boarding-tour/ │ │ │ │ │ │ │ ├── update-on-boarding-tour.command.ts │ │ │ │ │ │ │ └── update-on-boarding-tour.usecase.ts │ │ │ │ │ │ └── update-profile-email/ │ │ │ │ │ │ ├── update-profile-email.command.ts │ │ │ │ │ │ └── update-profile-email.usecase.ts │ │ │ │ │ ├── user.controller.ts │ │ │ │ │ └── user.module.ts │ │ │ │ ├── widgets/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── feeds-response.dto.ts │ │ │ │ │ │ ├── get-notifications-feed-request.dto.ts │ │ │ │ │ │ ├── log-usage-request.dto.ts │ │ │ │ │ │ ├── log-usage-response.dto.ts │ │ │ │ │ │ ├── mark-as-request.dto.ts │ │ │ │ │ │ ├── mark-message-action-as-seen.dto.ts │ │ │ │ │ │ ├── mark-message-as-request.dto.ts │ │ │ │ │ │ ├── message-response.dto.ts │ │ │ │ │ │ ├── organization-response.dto.ts │ │ │ │ │ │ ├── remove-all-messages.dto.ts │ │ │ │ │ │ ├── remove-messages-bulk-request.dto.ts │ │ │ │ │ │ ├── session-initialize-request.dto.ts │ │ │ │ │ │ ├── session-initialize-response.dto.ts │ │ │ │ │ │ ├── unseen-count-response.dto.ts │ │ │ │ │ │ ├── update-subscriber-preference-request.dto.ts │ │ │ │ │ │ └── update-subscriber-preference-response.dto.ts │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ ├── get-count.e2e.ts │ │ │ │ │ │ ├── get-notification-feed.e2e.ts │ │ │ │ │ │ ├── get-subscriber-preference.e2e.ts │ │ │ │ │ │ ├── get-unread-count.e2e.ts │ │ │ │ │ │ ├── get-unseen-count.e2e.ts │ │ │ │ │ │ ├── initialize-widget-session.e2e.ts │ │ │ │ │ │ ├── mark-all-as-read.e2e.ts │ │ │ │ │ │ ├── mark-as-by-mark.e2e.ts │ │ │ │ │ │ ├── mark-as.e2e.ts │ │ │ │ │ │ ├── remove-all-messages.e2e.ts │ │ │ │ │ │ ├── remove-messages-bulk.e2e.ts │ │ │ │ │ │ └── update-subscriber-preference.e2e.ts │ │ │ │ │ ├── pipes/ │ │ │ │ │ │ └── limit-pipe/ │ │ │ │ │ │ ├── limit-pipe.spec.ts │ │ │ │ │ │ └── limit-pipe.ts │ │ │ │ │ ├── queries/ │ │ │ │ │ │ ├── get-count.query.ts │ │ │ │ │ │ └── store.query.ts │ │ │ │ │ ├── usecases/ │ │ │ │ │ │ ├── get-feed-count/ │ │ │ │ │ │ │ ├── get-feed-count.command.ts │ │ │ │ │ │ │ └── get-feed-count.usecase.ts │ │ │ │ │ │ ├── get-notifications-feed/ │ │ │ │ │ │ │ ├── get-notifications-feed.command.ts │ │ │ │ │ │ │ └── get-notifications-feed.usecase.ts │ │ │ │ │ │ ├── get-organization-data/ │ │ │ │ │ │ │ ├── get-organization-data.command.ts │ │ │ │ │ │ │ └── get-organization-data.usecase.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── initialize-session/ │ │ │ │ │ │ │ ├── initialize-session.command.ts │ │ │ │ │ │ │ └── initialize-session.usecase.ts │ │ │ │ │ │ ├── mark-action-as-done/ │ │ │ │ │ │ │ ├── update-message-actions.command.ts │ │ │ │ │ │ │ └── update-message-actions.usecase.ts │ │ │ │ │ │ ├── mark-all-messages-as/ │ │ │ │ │ │ │ ├── mark-all-messages-as.command.ts │ │ │ │ │ │ │ └── mark-all-messages-as.usecase.ts │ │ │ │ │ │ ├── mark-message-as/ │ │ │ │ │ │ │ ├── mark-message-as.command.ts │ │ │ │ │ │ │ └── mark-message-as.usecase.ts │ │ │ │ │ │ ├── mark-message-as-by-mark/ │ │ │ │ │ │ │ ├── mark-message-as-by-mark.command.ts │ │ │ │ │ │ │ └── mark-message-as-by-mark.usecase.ts │ │ │ │ │ │ ├── remove-message/ │ │ │ │ │ │ │ ├── remove-message.command.ts │ │ │ │ │ │ │ └── remove-message.usecase.ts │ │ │ │ │ │ ├── remove-messages/ │ │ │ │ │ │ │ ├── remove-all-messages.command.ts │ │ │ │ │ │ │ └── remove-all-messages.usecase.ts │ │ │ │ │ │ └── remove-messages-bulk/ │ │ │ │ │ │ ├── remove-messages-bulk.command.ts │ │ │ │ │ │ └── remove-messages-bulk.usecase.ts │ │ │ │ │ ├── widgets.controller.ts │ │ │ │ │ └── widgets.module.ts │ │ │ │ ├── workflow-overrides/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── create-workflow-override-request.dto.ts │ │ │ │ │ │ ├── create-workflow-override-response.dto.ts │ │ │ │ │ │ ├── get-workflow-override-response.dto.ts │ │ │ │ │ │ ├── get-workflow-overrides-request.dto.ts │ │ │ │ │ │ ├── get-workflow-overrides-response.dto.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── shared.ts │ │ │ │ │ │ ├── update-workflow-override-request.dto.ts │ │ │ │ │ │ └── update-workflow-override-response.dto.ts │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ ├── create-workflow-override.e2e.ts │ │ │ │ │ │ ├── delete-workflow-override.e2e.ts │ │ │ │ │ │ ├── get-workflow-override-by-id.e2e.ts │ │ │ │ │ │ ├── get-workflow-override.e2e.ts │ │ │ │ │ │ ├── get-workflow-overrides.e2e.ts │ │ │ │ │ │ ├── update-workflow-override-by-id.e2e.ts │ │ │ │ │ │ └── update-workflow-override.e2e.ts │ │ │ │ │ ├── usecases/ │ │ │ │ │ │ ├── create-workflow-override/ │ │ │ │ │ │ │ ├── create-workflow-override.command.ts │ │ │ │ │ │ │ └── create-workflow-override.usecase.ts │ │ │ │ │ │ ├── delete-workflow-override/ │ │ │ │ │ │ │ ├── delete-workflow-override.command.ts │ │ │ │ │ │ │ └── delete-workflow-override.usecase.ts │ │ │ │ │ │ ├── get-workflow-override/ │ │ │ │ │ │ │ ├── get-workflow-override.command.ts │ │ │ │ │ │ │ └── get-workflow-override.usecase.ts │ │ │ │ │ │ ├── get-workflow-override-by-id/ │ │ │ │ │ │ │ ├── get-workflow-override-by-id.command.ts │ │ │ │ │ │ │ └── get-workflow-override-by-id.usecase.ts │ │ │ │ │ │ ├── get-workflow-overrides/ │ │ │ │ │ │ │ ├── get-workflow-overrides.command.ts │ │ │ │ │ │ │ └── get-workflow-overrides.usecase.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── update-workflow-override/ │ │ │ │ │ │ │ ├── update-workflow-override.command.ts │ │ │ │ │ │ │ └── update-workflow-override.usecase.ts │ │ │ │ │ │ └── update-workflow-override-by-id/ │ │ │ │ │ │ ├── update-workflow-override-by-id.command.ts │ │ │ │ │ │ └── update-workflow-override-by-id.usecase.ts │ │ │ │ │ ├── workflow-overrides.controller.ts │ │ │ │ │ └── workflow-overrides.module.ts │ │ │ │ ├── workflows-v1/ │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── change-workflow-status-request.dto.ts │ │ │ │ │ │ ├── create-workflow.request.dto.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── update-workflow-request.dto.ts │ │ │ │ │ │ ├── variables.response.dto.ts │ │ │ │ │ │ ├── workflow-response.dto.ts │ │ │ │ │ │ ├── workflows-request.dto.ts │ │ │ │ │ │ └── workflows.response.dto.ts │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ ├── change-template-status.e2e.ts │ │ │ │ │ │ ├── create-notification-templates.e2e.ts │ │ │ │ │ │ ├── delete-notification-template.e2e.ts │ │ │ │ │ │ ├── get-notification-template.e2e.ts │ │ │ │ │ │ ├── get-notification-templates.e2e.ts │ │ │ │ │ │ └── update-notification-template.e2e.ts │ │ │ │ │ ├── notification-template.controller.ts │ │ │ │ │ ├── queries/ │ │ │ │ │ │ ├── CreateWorkflowQuery.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── usecases/ │ │ │ │ │ │ ├── change-template-active-status/ │ │ │ │ │ │ │ ├── change-template-active-status.command.ts │ │ │ │ │ │ │ └── change-template-active-status.usecase.ts │ │ │ │ │ │ ├── delete-notification-template/ │ │ │ │ │ │ │ ├── delete-notification-template.command.ts │ │ │ │ │ │ │ └── delete-notification-template.usecase.ts │ │ │ │ │ │ ├── delete-workflow/ │ │ │ │ │ │ │ ├── delete-workflow.command.ts │ │ │ │ │ │ │ └── delete-workflow.usecase.ts │ │ │ │ │ │ ├── get-active-integrations-status/ │ │ │ │ │ │ │ ├── get-active-integrations-status.command.ts │ │ │ │ │ │ │ ├── get-active-integrations-status.spec.ts │ │ │ │ │ │ │ └── get-active-integrations-status.usecase.ts │ │ │ │ │ │ ├── get-notification-template/ │ │ │ │ │ │ │ ├── get-notification-template.command.ts │ │ │ │ │ │ │ └── get-notification-template.usecase.ts │ │ │ │ │ │ ├── get-notification-templates/ │ │ │ │ │ │ │ ├── get-notification-templates.command.ts │ │ │ │ │ │ │ └── get-notification-templates.usecase.ts │ │ │ │ │ │ ├── get-workflow-variables/ │ │ │ │ │ │ │ ├── get-workflow-variables.command.ts │ │ │ │ │ │ │ └── get-workflow-variables.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── workflow-v1.controller.ts │ │ │ │ │ └── workflow-v1.module.ts │ │ │ │ └── workflows-v2/ │ │ │ │ ├── dtos/ │ │ │ │ │ ├── base-step-issue.dto.ts │ │ │ │ │ ├── control-schemas.dto.ts │ │ │ │ │ ├── create-step.dto.ts │ │ │ │ │ ├── create-workflow.dto.ts │ │ │ │ │ ├── duplicate-workflow.dto.ts │ │ │ │ │ ├── get-list-query-params.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── list-workflow.dto.ts │ │ │ │ │ ├── patch-step-data.dto.ts │ │ │ │ │ ├── patch-workflow.dto.ts │ │ │ │ │ ├── preferences.request.dto.ts │ │ │ │ │ ├── sync-workflow.dto.ts │ │ │ │ │ ├── test-http-endpoint.dto.ts │ │ │ │ │ ├── update-workflow.dto.ts │ │ │ │ │ └── workflow-test-data.dto.ts │ │ │ │ ├── e2e/ │ │ │ │ │ ├── generate-preview.e2e.ts │ │ │ │ │ ├── list-workflows.e2e.ts │ │ │ │ │ └── upsert-workflow.e2e.ts │ │ │ │ ├── exceptions/ │ │ │ │ │ ├── workflow-not-duplicable-exception.ts │ │ │ │ │ └── workflow-not-syncable-exception.ts │ │ │ │ ├── maily-test-data.ts │ │ │ │ ├── usecases/ │ │ │ │ │ ├── build-test-data/ │ │ │ │ │ │ ├── build-workflow-test-data.command.ts │ │ │ │ │ │ ├── build-workflow-test-data.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── duplicate-workflow/ │ │ │ │ │ │ ├── duplicate-workflow.command.ts │ │ │ │ │ │ ├── duplicate-workflow.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── list-workflows/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── list-workflow.usecase.ts │ │ │ │ │ │ └── list-workflows.command.ts │ │ │ │ │ ├── patch-workflow/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── patch-workflow.command.ts │ │ │ │ │ │ └── patch-workflow.usecase.ts │ │ │ │ │ ├── sync-to-environment/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── sync-to-environment.command.ts │ │ │ │ │ │ └── sync-to-environment.usecase.ts │ │ │ │ │ └── test-http-endpoint/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── test-http-endpoint.command.ts │ │ │ │ │ └── test-http-endpoint.usecase.ts │ │ │ │ ├── workflow.controller.e2e.ts │ │ │ │ ├── workflow.controller.ts │ │ │ │ └── workflow.module.ts │ │ │ ├── app.module.ts │ │ │ ├── bootstrap.ts │ │ │ ├── config/ │ │ │ │ ├── cors.config.spec.ts │ │ │ │ ├── cors.config.ts │ │ │ │ ├── env.config.ts │ │ │ │ ├── env.validators.ts │ │ │ │ └── index.ts │ │ │ ├── error-dto.ts │ │ │ ├── exception-filter.ts │ │ │ ├── instrument.ts │ │ │ ├── main.ts │ │ │ ├── newrelic.ts │ │ │ ├── types/ │ │ │ │ └── env.d.ts │ │ │ └── utils/ │ │ │ └── payload-sanitizer.ts │ │ ├── swagger-spec.json │ │ ├── swc-register.js │ │ ├── tsconfig.build.json │ │ ├── tsconfig.json │ │ ├── tsconfig.spec.json │ │ └── webpack.config.js │ ├── dashboard/ │ │ ├── .example.env │ │ ├── .gitignore │ │ ├── .vscode/ │ │ │ └── settings.json │ │ ├── README.md │ │ ├── components.json │ │ ├── docker-entrypoint.sh │ │ ├── dockerfile │ │ ├── index.html │ │ ├── netlify.toml │ │ ├── package.json │ │ ├── playwright.config.ts │ │ ├── postcss.config.js │ │ ├── public/ │ │ │ └── manifest.json │ │ ├── src/ │ │ │ ├── api/ │ │ │ │ ├── activity.ts │ │ │ │ ├── ai.ts │ │ │ │ ├── api.client.ts │ │ │ │ ├── billing.ts │ │ │ │ ├── bridge.ts │ │ │ │ ├── contexts.ts │ │ │ │ ├── environment-variables.ts │ │ │ │ ├── environments.ts │ │ │ │ ├── integrations.ts │ │ │ │ ├── layouts.ts │ │ │ │ ├── logs.ts │ │ │ │ ├── organization.ts │ │ │ │ ├── partner-integrations.ts │ │ │ │ ├── step-resolvers.ts │ │ │ │ ├── steps.ts │ │ │ │ ├── subscribers.ts │ │ │ │ ├── telemetry.ts │ │ │ │ ├── topics.ts │ │ │ │ ├── translations.ts │ │ │ │ ├── webhooks.ts │ │ │ │ └── workflows.ts │ │ │ ├── components/ │ │ │ │ ├── activity/ │ │ │ │ │ ├── activity-detail-card.tsx │ │ │ │ │ ├── activity-empty-state.tsx │ │ │ │ │ ├── activity-error.tsx │ │ │ │ │ ├── activity-feed-content.tsx │ │ │ │ │ ├── activity-filters.tsx │ │ │ │ │ ├── activity-header.tsx │ │ │ │ │ ├── activity-job-item.tsx │ │ │ │ │ ├── activity-logs.tsx │ │ │ │ │ ├── activity-panel.tsx │ │ │ │ │ ├── activity-skeleton.tsx │ │ │ │ │ ├── activity-table.tsx │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── activity-overview.tsx │ │ │ │ │ │ ├── activity-table-row.tsx │ │ │ │ │ │ ├── overview-item.tsx │ │ │ │ │ │ ├── status-badge.tsx │ │ │ │ │ │ ├── status-preview-card.tsx │ │ │ │ │ │ └── step-indicators.tsx │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── execution-detail-item.tsx │ │ │ │ │ └── helpers.ts │ │ │ │ ├── ai-drawer/ │ │ │ │ │ ├── ai-drawer-provider.tsx │ │ │ │ │ ├── ai-drawer.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ └── use-ai-drawer.ts │ │ │ │ ├── ai-elements/ │ │ │ │ │ ├── chain-of-thought.tsx │ │ │ │ │ ├── conversation.tsx │ │ │ │ │ ├── message.tsx │ │ │ │ │ ├── prompt-input.tsx │ │ │ │ │ └── shimmer.tsx │ │ │ │ ├── ai-sidekick/ │ │ │ │ │ ├── ai-chat-context.tsx │ │ │ │ │ ├── assistant-message.tsx │ │ │ │ │ ├── chat-body.tsx │ │ │ │ │ ├── chat-chain-of-thought.tsx │ │ │ │ │ ├── chat-message-actions.tsx │ │ │ │ │ ├── chat-message-response.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── message-utils.ts │ │ │ │ │ ├── novu-copilot-panel.tsx │ │ │ │ │ ├── sidekick-toast.tsx │ │ │ │ │ └── user-message.tsx │ │ │ │ ├── amount-input.tsx │ │ │ │ ├── analytics/ │ │ │ │ │ ├── charts/ │ │ │ │ │ │ ├── active-subscribers-tooltip.tsx │ │ │ │ │ │ ├── active-subscribers-trend-chart.tsx │ │ │ │ │ │ ├── chart-dummy-data.tsx │ │ │ │ │ │ ├── chart-empty-state.tsx │ │ │ │ │ │ ├── chart-types.ts │ │ │ │ │ │ ├── chart-wrapper.tsx │ │ │ │ │ │ ├── delivery-trends-chart.tsx │ │ │ │ │ │ ├── flickering-grid.tsx │ │ │ │ │ │ ├── interaction-trend-chart.tsx │ │ │ │ │ │ ├── providers-by-volume.tsx │ │ │ │ │ │ ├── workflow-runs-trend-chart.tsx │ │ │ │ │ │ └── workflows-by-volume.tsx │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── analytics-page-skeleton.tsx │ │ │ │ │ │ ├── analytics-section.tsx │ │ │ │ │ │ ├── analytics-upgrade-cta-icon.tsx │ │ │ │ │ │ ├── charts-section.tsx │ │ │ │ │ │ └── flickering-grid-placeholder.tsx │ │ │ │ │ ├── constants/ │ │ │ │ │ │ ├── analytics-page.consts.ts │ │ │ │ │ │ └── analytics-tooltips.ts │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ └── use-analytics-page-date-filter.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── utils/ │ │ │ │ │ └── chart-validation.ts │ │ │ │ ├── animated-outlet.tsx │ │ │ │ ├── auth/ │ │ │ │ │ ├── auth-card.tsx │ │ │ │ │ ├── auth-feature-row.tsx │ │ │ │ │ ├── auth-side-banner.tsx │ │ │ │ │ ├── create-organization.tsx │ │ │ │ │ ├── inbox-playground.tsx │ │ │ │ │ ├── inbox-preview-content.tsx │ │ │ │ │ ├── mobile-message.tsx │ │ │ │ │ ├── questionnaire-form.tsx │ │ │ │ │ ├── region-picker.tsx │ │ │ │ │ ├── shared.tsx │ │ │ │ │ ├── trusted-companies.tsx │ │ │ │ │ ├── usecase-selector.tsx │ │ │ │ │ └── usecases-list.utils.tsx │ │ │ │ ├── auth-layout.tsx │ │ │ │ ├── billing/ │ │ │ │ │ ├── active-plan-banner.tsx │ │ │ │ │ ├── contact-sales-button.tsx │ │ │ │ │ ├── features-config.ts │ │ │ │ │ ├── features.tsx │ │ │ │ │ ├── plan-action-button.tsx │ │ │ │ │ ├── plan-switcher.tsx │ │ │ │ │ ├── plan.tsx │ │ │ │ │ ├── plans-row.tsx │ │ │ │ │ └── utils/ │ │ │ │ │ └── action.button.constants.ts │ │ │ │ ├── command-palette/ │ │ │ │ │ ├── command-menu.tsx │ │ │ │ │ ├── command-palette-provider.tsx │ │ │ │ │ ├── command-palette.tsx │ │ │ │ │ ├── command-types.ts │ │ │ │ │ ├── commands/ │ │ │ │ │ │ ├── action-commands.tsx │ │ │ │ │ │ ├── environment-commands.tsx │ │ │ │ │ │ ├── help-commands.tsx │ │ │ │ │ │ ├── navigation-commands.tsx │ │ │ │ │ │ ├── settings-commands.tsx │ │ │ │ │ │ ├── subscriber-commands.tsx │ │ │ │ │ │ ├── workflow-commands.tsx │ │ │ │ │ │ └── workflow-editor-commands.tsx │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ ├── use-command-palette.ts │ │ │ │ │ │ ├── use-command-registry.ts │ │ │ │ │ │ └── use-workflow-editor-context.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── conditions-editor/ │ │ │ │ │ ├── add-condition-action.tsx │ │ │ │ │ ├── add-group-action.tsx │ │ │ │ │ ├── combinator-selector.tsx │ │ │ │ │ ├── conditions-editor-context.tsx │ │ │ │ │ ├── conditions-editor.tsx │ │ │ │ │ ├── field-selector.tsx │ │ │ │ │ ├── field-type-editors.ts │ │ │ │ │ ├── field-type-operators.ts │ │ │ │ │ ├── help-icon.tsx │ │ │ │ │ ├── operator-selector.tsx │ │ │ │ │ ├── rule-actions.tsx │ │ │ │ │ ├── select-option-utils.tsx │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── value-editor.tsx │ │ │ │ │ └── variable-select.tsx │ │ │ │ ├── confirmation-modal.tsx │ │ │ │ ├── context-search-editor.tsx │ │ │ │ ├── contexts/ │ │ │ │ │ ├── context-activity.tsx │ │ │ │ │ ├── context-drawer.tsx │ │ │ │ │ ├── context-filter.tsx │ │ │ │ │ ├── context-list-blank.tsx │ │ │ │ │ ├── context-list.tsx │ │ │ │ │ ├── context-overview.tsx │ │ │ │ │ ├── context-row.tsx │ │ │ │ │ ├── contexts-filters.tsx │ │ │ │ │ ├── create-context-drawer.tsx │ │ │ │ │ ├── create-context-form.tsx │ │ │ │ │ ├── empty-contexts-illustration.tsx │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ ├── use-contexts-navigate.ts │ │ │ │ │ │ └── use-contexts-url-state.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── schema.ts │ │ │ │ ├── create-workflow-modal.tsx │ │ │ │ ├── dashboard-layout.tsx │ │ │ │ ├── default-pagination.tsx │ │ │ │ ├── delete-resource-confirmation-dialog.tsx │ │ │ │ ├── delete-workflow-dialog.tsx │ │ │ │ ├── editor-overlays.tsx │ │ │ │ ├── email-editor-select.tsx │ │ │ │ ├── environments/ │ │ │ │ │ ├── create-environment-button.tsx │ │ │ │ │ ├── delete-environment-dialog.tsx │ │ │ │ │ ├── edit-environment-sheet.tsx │ │ │ │ │ ├── environment-form.tsx │ │ │ │ │ ├── environments-free-state.tsx │ │ │ │ │ └── environments-list.tsx │ │ │ │ ├── flag-circle.tsx │ │ │ │ ├── full-page-layout.tsx │ │ │ │ ├── header-navigation/ │ │ │ │ │ ├── customer-support-button.tsx │ │ │ │ │ ├── edit-bridge-url-button.tsx │ │ │ │ │ ├── header-button.tsx │ │ │ │ │ ├── header-navigation.tsx │ │ │ │ │ ├── layout-usage-indicator.tsx │ │ │ │ │ ├── no-changes-modal.tsx │ │ │ │ │ ├── publish-button.tsx │ │ │ │ │ ├── publish-modal.tsx │ │ │ │ │ ├── publish-success-modal.tsx │ │ │ │ │ ├── support-drawer-components.tsx │ │ │ │ │ ├── support-drawer-constants.ts │ │ │ │ │ ├── support-drawer.tsx │ │ │ │ │ └── workflow-hover-card.tsx │ │ │ │ ├── html-editor.tsx │ │ │ │ ├── http-logs/ │ │ │ │ │ ├── api-traces-content.tsx │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ └── use-workflow-runs-url-state.ts │ │ │ │ │ ├── http-status-badge.tsx │ │ │ │ │ ├── logs-detail-content.tsx │ │ │ │ │ ├── logs-detail-empty.tsx │ │ │ │ │ ├── logs-detail-error.tsx │ │ │ │ │ ├── logs-detail-header.tsx │ │ │ │ │ ├── logs-detail-panel.tsx │ │ │ │ │ ├── logs-detail-skeleton.tsx │ │ │ │ │ ├── logs-empty-state.tsx │ │ │ │ │ ├── logs-filters.tsx │ │ │ │ │ ├── logs-table-row.tsx │ │ │ │ │ ├── logs-table-skeleton-row.tsx │ │ │ │ │ ├── logs-table.tsx │ │ │ │ │ ├── method-badge.tsx │ │ │ │ │ ├── transaction-id-display.tsx │ │ │ │ │ ├── workflow-run-activity-drawer.tsx │ │ │ │ │ ├── workflow-runs-content.tsx │ │ │ │ │ └── workflow-runs-filters.tsx │ │ │ │ ├── icons/ │ │ │ │ │ ├── add-subscriber-illustration.tsx │ │ │ │ │ ├── api.tsx │ │ │ │ │ ├── arrow-right.tsx │ │ │ │ │ ├── bell.tsx │ │ │ │ │ ├── broom-sparkle.tsx │ │ │ │ │ ├── broom.tsx │ │ │ │ │ ├── cards-blocks.tsx │ │ │ │ │ ├── circle-check.tsx │ │ │ │ │ ├── code-2.tsx │ │ │ │ │ ├── delete.tsx │ │ │ │ │ ├── digest-variable-icon.tsx │ │ │ │ │ ├── email-footer-logo-with-text-stacked.tsx │ │ │ │ │ ├── email-footer-plain-text.tsx │ │ │ │ │ ├── email-footer.tsx │ │ │ │ │ ├── email-header-centered-logo-with-border.tsx │ │ │ │ │ ├── email-header-logo-with-cover-image.tsx │ │ │ │ │ ├── email-header-logo-with-text.tsx │ │ │ │ │ ├── email-header.tsx │ │ │ │ │ ├── enter-line.tsx │ │ │ │ │ ├── flags/ │ │ │ │ │ │ ├── eu.tsx │ │ │ │ │ │ └── us.tsx │ │ │ │ │ ├── horizontal-card-with-image.tsx │ │ │ │ │ ├── inbox-arrow-down.tsx │ │ │ │ │ ├── inbox-bell-filled-dev.tsx │ │ │ │ │ ├── inbox-bell-filled.tsx │ │ │ │ │ ├── inbox-bell.tsx │ │ │ │ │ ├── inbox-ellipsis.tsx │ │ │ │ │ ├── inbox-settings.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── information-card-with-logo.tsx │ │ │ │ │ ├── logo-circle.tsx │ │ │ │ │ ├── mail-3-fill.tsx │ │ │ │ │ ├── notification-5-fill.tsx │ │ │ │ │ ├── novu-icon.tsx │ │ │ │ │ ├── onboarding-arrow-left.tsx │ │ │ │ │ ├── paragraph-with-image.tsx │ │ │ │ │ ├── plug.tsx │ │ │ │ │ ├── preferences-blank-illustration.tsx │ │ │ │ │ ├── refresh.tsx │ │ │ │ │ ├── repeat-play.tsx │ │ │ │ │ ├── repeat-variable.tsx │ │ │ │ │ ├── route-fill.tsx │ │ │ │ │ ├── shield-zap.tsx │ │ │ │ │ ├── sms.tsx │ │ │ │ │ ├── sparkling.tsx │ │ │ │ │ ├── square-two-stack.tsx │ │ │ │ │ ├── stacked-dots.tsx │ │ │ │ │ ├── stacked-plus-line.tsx │ │ │ │ │ ├── target-arrow.tsx │ │ │ │ │ ├── translate-variable.tsx │ │ │ │ │ ├── translated-layout-icon.tsx │ │ │ │ │ ├── translated-workflow.tsx │ │ │ │ │ ├── trend-line-down.tsx │ │ │ │ │ ├── trend-line-up.tsx │ │ │ │ │ ├── utils.ts │ │ │ │ │ ├── version-control-dev.tsx │ │ │ │ │ ├── version-control-prod.tsx │ │ │ │ │ └── workflow-trigger-inbox.tsx │ │ │ │ ├── in-app-action-dropdown.tsx │ │ │ │ ├── inbox-button.tsx │ │ │ │ ├── integrations/ │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── channel-tabs.tsx │ │ │ │ │ │ ├── configuration-group.tsx │ │ │ │ │ │ ├── create-integration-sidebar.tsx │ │ │ │ │ │ ├── credential-section.tsx │ │ │ │ │ │ ├── cross-channel-configs-group.tsx │ │ │ │ │ │ ├── description-with-links.tsx │ │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ │ ├── use-integration-list.ts │ │ │ │ │ │ │ ├── use-integration-primary-modal.tsx │ │ │ │ │ │ │ └── use-sidebar-navigation-manager.ts │ │ │ │ │ │ ├── inbound-webhook-group.tsx │ │ │ │ │ │ ├── inbound-webhook-url.tsx │ │ │ │ │ │ ├── integration-card.tsx │ │ │ │ │ │ ├── integration-channel-group.tsx │ │ │ │ │ │ ├── integration-general-settings.tsx │ │ │ │ │ │ ├── integration-list-item.tsx │ │ │ │ │ │ ├── integration-settings.tsx │ │ │ │ │ │ ├── integration-sheet-header.tsx │ │ │ │ │ │ ├── integration-sheet.tsx │ │ │ │ │ │ ├── integrations-list.tsx │ │ │ │ │ │ ├── modals/ │ │ │ │ │ │ │ ├── delete-integration-modal.tsx │ │ │ │ │ │ │ └── select-primary-integration-modal.tsx │ │ │ │ │ │ ├── provider-icon.tsx │ │ │ │ │ │ ├── update-integration-sidebar.tsx │ │ │ │ │ │ └── utils/ │ │ │ │ │ │ ├── handle-integration-error.ts │ │ │ │ │ │ └── helpers.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── utils/ │ │ │ │ │ └── channels.ts │ │ │ │ ├── issues-panel.tsx │ │ │ │ ├── layouts/ │ │ │ │ │ ├── component-utils.tsx │ │ │ │ │ ├── create-layout-btn.tsx │ │ │ │ │ ├── create-layout-form.tsx │ │ │ │ │ ├── delete-layout-dialog.tsx │ │ │ │ │ ├── empty-layouts-illustration.tsx │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ └── use-layouts-url-state.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── layout-breadcrumbs.tsx │ │ │ │ │ ├── layout-control-input.tsx │ │ │ │ │ ├── layout-editor-factory.tsx │ │ │ │ │ ├── layout-editor-provider.tsx │ │ │ │ │ ├── layout-editor-settings-drawer.tsx │ │ │ │ │ ├── layout-editor-skeleton.tsx │ │ │ │ │ ├── layout-editor.tsx │ │ │ │ │ ├── layout-email-body.tsx │ │ │ │ │ ├── layout-email-editor.tsx │ │ │ │ │ ├── layout-list-blank.tsx │ │ │ │ │ ├── layout-list.tsx │ │ │ │ │ ├── layout-preview-context-panel.tsx │ │ │ │ │ ├── layout-preview-factory.tsx │ │ │ │ │ ├── layout-row.tsx │ │ │ │ │ ├── layouts-filters.tsx │ │ │ │ │ ├── layouts-list-upgrade-cta.tsx │ │ │ │ │ └── schema.ts │ │ │ │ ├── list-no-results.tsx │ │ │ │ ├── maily/ │ │ │ │ │ ├── blocks/ │ │ │ │ │ │ ├── block-custom-preview.tsx │ │ │ │ │ │ ├── cards.tsx │ │ │ │ │ │ ├── digest.tsx │ │ │ │ │ │ ├── footers.tsx │ │ │ │ │ │ ├── headers.tsx │ │ │ │ │ │ └── html.tsx │ │ │ │ │ ├── maily-config.tsx │ │ │ │ │ ├── maily-utils.ts │ │ │ │ │ ├── maily.tsx │ │ │ │ │ ├── repeat-block-aliases.ts │ │ │ │ │ ├── repeat-menu-description.tsx │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── variables.ts │ │ │ │ │ └── views/ │ │ │ │ │ ├── for-view.tsx │ │ │ │ │ ├── html-view.tsx │ │ │ │ │ ├── maily-variables-list-view.tsx │ │ │ │ │ └── variable-view.tsx │ │ │ │ ├── mobile-desktop-prompt.tsx │ │ │ │ ├── onboarding/ │ │ │ │ │ ├── animated-page.tsx │ │ │ │ │ └── stepper.tsx │ │ │ │ ├── page-meta.tsx │ │ │ │ ├── pause-workflow-dialog.tsx │ │ │ │ ├── preview-context-section.tsx │ │ │ │ ├── preview-env-section.tsx │ │ │ │ ├── preview-subscriber-section.tsx │ │ │ │ ├── primitives/ │ │ │ │ │ ├── accordion.tsx │ │ │ │ │ ├── analytics-card.tsx │ │ │ │ │ ├── animated-number.tsx │ │ │ │ │ ├── autocomplete.tsx │ │ │ │ │ ├── avatar.tsx │ │ │ │ │ ├── badge.tsx │ │ │ │ │ ├── breadcrumb.tsx │ │ │ │ │ ├── button-compact.tsx │ │ │ │ │ ├── button-group.tsx │ │ │ │ │ ├── button-link.tsx │ │ │ │ │ ├── button.tsx │ │ │ │ │ ├── card.tsx │ │ │ │ │ ├── chart.tsx │ │ │ │ │ ├── checkbox.tsx │ │ │ │ │ ├── code-block.tsx │ │ │ │ │ ├── collapsible.tsx │ │ │ │ │ ├── color-picker.tsx │ │ │ │ │ ├── command.tsx │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── container.tsx │ │ │ │ │ ├── copy-button.tsx │ │ │ │ │ ├── copy-to-clipboard.tsx │ │ │ │ │ ├── dialog.tsx │ │ │ │ │ ├── dropdown-menu.tsx │ │ │ │ │ ├── editor.tsx │ │ │ │ │ ├── environment-branch-icon.tsx │ │ │ │ │ ├── form/ │ │ │ │ │ │ ├── avatar-picker.tsx │ │ │ │ │ │ ├── faceted-filter/ │ │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ │ ├── base-filter-content.tsx │ │ │ │ │ │ │ │ ├── clear-button.tsx │ │ │ │ │ │ │ │ ├── filter-badge.tsx │ │ │ │ │ │ │ │ ├── filter-input.tsx │ │ │ │ │ │ │ │ ├── multi-filter-content.tsx │ │ │ │ │ │ │ │ ├── single-filter-content.tsx │ │ │ │ │ │ │ │ └── text-filter-content.tsx │ │ │ │ │ │ │ ├── facated-form-filter.tsx │ │ │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ │ │ └── use-keyboard-navigation.ts │ │ │ │ │ │ │ ├── styles.ts │ │ │ │ │ │ │ └── types.ts │ │ │ │ │ │ ├── form-context.ts │ │ │ │ │ │ └── form.tsx │ │ │ │ │ ├── help-tooltip-indicator.tsx │ │ │ │ │ ├── hint.tsx │ │ │ │ │ ├── hover-card.tsx │ │ │ │ │ ├── inline-toast.tsx │ │ │ │ │ ├── input-group.tsx │ │ │ │ │ ├── input.tsx │ │ │ │ │ ├── kbd.tsx │ │ │ │ │ ├── label.tsx │ │ │ │ │ ├── loading-indicator.tsx │ │ │ │ │ ├── locale-select.tsx │ │ │ │ │ ├── multi-select.tsx │ │ │ │ │ ├── pagination.tsx │ │ │ │ │ ├── panel.tsx │ │ │ │ │ ├── permission-button.tsx │ │ │ │ │ ├── permission-switch.tsx │ │ │ │ │ ├── phone-input.tsx │ │ │ │ │ ├── popover.tsx │ │ │ │ │ ├── progress.tsx │ │ │ │ │ ├── radio-group.tsx │ │ │ │ │ ├── resizable.tsx │ │ │ │ │ ├── scroll-area.tsx │ │ │ │ │ ├── secret-input.tsx │ │ │ │ │ ├── segmented-control.tsx │ │ │ │ │ ├── select.tsx │ │ │ │ │ ├── separator.tsx │ │ │ │ │ ├── sheet.tsx │ │ │ │ │ ├── skeleton.tsx │ │ │ │ │ ├── sonner-helpers.tsx │ │ │ │ │ ├── sonner.tsx │ │ │ │ │ ├── status-badge.tsx │ │ │ │ │ ├── step.tsx │ │ │ │ │ ├── switch.tsx │ │ │ │ │ ├── table-pagination-footer.tsx │ │ │ │ │ ├── table.tsx │ │ │ │ │ ├── tabs.tsx │ │ │ │ │ ├── tag-input.tsx │ │ │ │ │ ├── tag.tsx │ │ │ │ │ ├── text-separator.tsx │ │ │ │ │ ├── textarea.tsx │ │ │ │ │ ├── timeline.tsx │ │ │ │ │ ├── toggle-group.tsx │ │ │ │ │ ├── toggle.tsx │ │ │ │ │ ├── tooltip.tsx │ │ │ │ │ ├── translation-plugin/ │ │ │ │ │ │ ├── autocomplete.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── pill-widget.ts │ │ │ │ │ │ ├── plugin-view.ts │ │ │ │ │ │ └── utils.ts │ │ │ │ │ ├── variable-editor.tsx │ │ │ │ │ ├── variable-plugin/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── plugin-view.ts │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ ├── utils.ts │ │ │ │ │ │ ├── variable-pill-widget.ts │ │ │ │ │ │ └── variable-theme.ts │ │ │ │ │ └── visually-hidden.tsx │ │ │ │ ├── promotional/ │ │ │ │ │ └── coming-soon-banner.tsx │ │ │ │ ├── protected-drawer.tsx │ │ │ │ ├── regenerate-api-keys-dialog.tsx │ │ │ │ ├── schema-editor/ │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── array-section.tsx │ │ │ │ │ │ ├── enum-section.tsx │ │ │ │ │ │ ├── object-section.tsx │ │ │ │ │ │ ├── property-actions.tsx │ │ │ │ │ │ ├── property-name-input.tsx │ │ │ │ │ │ └── property-type-selector.tsx │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ ├── use-property-paths.ts │ │ │ │ │ │ └── use-schema-property-type.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── json-schema.ts │ │ │ │ │ ├── schema-editor.tsx │ │ │ │ │ ├── schema-property-row.tsx │ │ │ │ │ ├── schema-property-settings-popover.tsx │ │ │ │ │ ├── types/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── schema-form.types.ts │ │ │ │ │ ├── use-schema-form.ts │ │ │ │ │ └── utils/ │ │ │ │ │ ├── check-variable-usage.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── json-helpers.ts │ │ │ │ │ ├── property-manager.ts │ │ │ │ │ ├── schema-change-detection.ts │ │ │ │ │ ├── schema-converter.ts │ │ │ │ │ ├── ui-helpers.ts │ │ │ │ │ └── validation-schema.ts │ │ │ │ ├── settings/ │ │ │ │ │ ├── novu-branding-switch.tsx │ │ │ │ │ └── organization-settings.tsx │ │ │ │ ├── shared/ │ │ │ │ │ └── external-link.tsx │ │ │ │ ├── side-navigation/ │ │ │ │ │ ├── changelog-cards.tsx │ │ │ │ │ ├── environment-dropdown.tsx │ │ │ │ │ ├── free-trial-card.tsx │ │ │ │ │ ├── getting-started-menu-item.tsx │ │ │ │ │ ├── mobile-side-navigation.tsx │ │ │ │ │ ├── navigation-link.tsx │ │ │ │ │ ├── organization-dropdown-clerk.tsx │ │ │ │ │ ├── organization-dropdown.tsx │ │ │ │ │ ├── side-navigation.tsx │ │ │ │ │ ├── sidebar.tsx │ │ │ │ │ └── usage-card.tsx │ │ │ │ ├── step-preview-hover-card.tsx │ │ │ │ ├── subscribers/ │ │ │ │ │ ├── create-subscriber-form.tsx │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ ├── use-delete-subscription.ts │ │ │ │ │ │ ├── use-get-subscription.ts │ │ │ │ │ │ ├── use-subscriber-search.ts │ │ │ │ │ │ ├── use-subscribers-navigate.ts │ │ │ │ │ │ └── use-subscribers-url-state.ts │ │ │ │ │ ├── preferences/ │ │ │ │ │ │ ├── day-schedule-copy.tsx │ │ │ │ │ │ ├── preferences-blank.tsx │ │ │ │ │ │ ├── preferences-item.tsx │ │ │ │ │ │ ├── preferences-skeleton.tsx │ │ │ │ │ │ ├── preferences.tsx │ │ │ │ │ │ ├── schedule-table.tsx │ │ │ │ │ │ ├── subscribers-schedule.tsx │ │ │ │ │ │ ├── utils.ts │ │ │ │ │ │ └── workflow-preferences.tsx │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── subscriber-activity-drawer.tsx │ │ │ │ │ ├── subscriber-activity-list.tsx │ │ │ │ │ ├── subscriber-activity.tsx │ │ │ │ │ ├── subscriber-autocomplete.tsx │ │ │ │ │ ├── subscriber-drawer.tsx │ │ │ │ │ ├── subscriber-list-blank.tsx │ │ │ │ │ ├── subscriber-list.tsx │ │ │ │ │ ├── subscriber-overview-form.tsx │ │ │ │ │ ├── subscriber-overview-skeleton.tsx │ │ │ │ │ ├── subscriber-row.tsx │ │ │ │ │ ├── subscriber-tabs.tsx │ │ │ │ │ ├── subscribers-filters.tsx │ │ │ │ │ ├── subscriptions/ │ │ │ │ │ │ ├── subscriber-subscriptions.tsx │ │ │ │ │ │ ├── subscription-item.tsx │ │ │ │ │ │ ├── subscription-preference-rule.tsx │ │ │ │ │ │ ├── subscription-preferences-drawer.tsx │ │ │ │ │ │ ├── subscription-preferences.tsx │ │ │ │ │ │ └── subscriptions-empty-state.tsx │ │ │ │ │ ├── timezone-select.tsx │ │ │ │ │ └── utils.ts │ │ │ │ ├── success-button-toast.tsx │ │ │ │ ├── template-store/ │ │ │ │ │ ├── components/ │ │ │ │ │ │ └── workflow-results.tsx │ │ │ │ │ ├── featured.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── workflow-card.tsx │ │ │ │ │ ├── workflow-sidebar.tsx │ │ │ │ │ └── workflow-template-modal.tsx │ │ │ │ ├── time-display-hover-card.tsx │ │ │ │ ├── topics/ │ │ │ │ │ ├── add-subscriber-form.tsx │ │ │ │ │ ├── create-topic-drawer.tsx │ │ │ │ │ ├── create-topic-form.tsx │ │ │ │ │ ├── empty-topics-illustration.tsx │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ ├── use-delete-topic.ts │ │ │ │ │ │ ├── use-topic-subscribers.ts │ │ │ │ │ │ ├── use-topic.ts │ │ │ │ │ │ ├── use-topics-navigate.ts │ │ │ │ │ │ └── use-topics-url-state.ts │ │ │ │ │ ├── subscription-count-badge.tsx │ │ │ │ │ ├── topic-activity.tsx │ │ │ │ │ ├── topic-drawer.tsx │ │ │ │ │ ├── topic-list-blank.tsx │ │ │ │ │ ├── topic-list.tsx │ │ │ │ │ ├── topic-overview-form.tsx │ │ │ │ │ ├── topic-row.tsx │ │ │ │ │ ├── topic-subscriber-filter.tsx │ │ │ │ │ ├── topic-subscriber-item.tsx │ │ │ │ │ ├── topics-filters.tsx │ │ │ │ │ └── types.ts │ │ │ │ ├── translations/ │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── delete-translation-modal.tsx │ │ │ │ │ ├── empty-translations-illustration.tsx │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ ├── use-delete-translation-modal.ts │ │ │ │ │ │ ├── use-translation-list-logic.ts │ │ │ │ │ │ └── use-translations-url-state.tsx │ │ │ │ │ ├── translation-drawer/ │ │ │ │ │ │ ├── editor-actions.tsx │ │ │ │ │ │ ├── editor-panel.tsx │ │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── use-translation-editor.ts │ │ │ │ │ │ │ └── use-translation-file-operations.ts │ │ │ │ │ │ ├── locale-list.tsx │ │ │ │ │ │ ├── translation-drawer-content.tsx │ │ │ │ │ │ ├── translation-drawer.tsx │ │ │ │ │ │ ├── translation-header.tsx │ │ │ │ │ │ └── use-translation-drawer-logic.tsx │ │ │ │ │ ├── translation-import-trigger.tsx │ │ │ │ │ ├── translation-list-upgrade-cta.tsx │ │ │ │ │ ├── translation-list.tsx │ │ │ │ │ ├── translation-onboarding-page.tsx │ │ │ │ │ ├── translation-row.tsx │ │ │ │ │ ├── translation-settings-drawer.tsx │ │ │ │ │ ├── translation-status.tsx │ │ │ │ │ ├── translation-switch.tsx │ │ │ │ │ ├── translations-filters.tsx │ │ │ │ │ └── utils.ts │ │ │ │ ├── truncated-text.tsx │ │ │ │ ├── unsaved-changes-alert-dialog.tsx │ │ │ │ ├── updated-ago.tsx │ │ │ │ ├── upgrade-cta-tooltip.tsx │ │ │ │ ├── usecase-playground-header.tsx │ │ │ │ ├── user-profile.tsx │ │ │ │ ├── variable/ │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── digest-count-summary-preview.tsx │ │ │ │ │ │ ├── digest-sentence-summary-preview.tsx │ │ │ │ │ │ ├── filter-item.tsx │ │ │ │ │ │ ├── new-variable-preview.tsx │ │ │ │ │ │ ├── reorder-filter-item.tsx │ │ │ │ │ │ ├── reorder-filters-group.tsx │ │ │ │ │ │ ├── variable-icon.tsx │ │ │ │ │ │ └── variable-preview.tsx │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── edit-variable-popover.tsx │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ ├── use-create-variable.tsx │ │ │ │ │ │ ├── use-filter-manager.ts │ │ │ │ │ │ ├── use-suggested-filters.ts │ │ │ │ │ │ ├── use-variable-parser.ts │ │ │ │ │ │ └── use-variable-validation.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── utils/ │ │ │ │ │ │ ├── digest-variables.tsx │ │ │ │ │ │ └── get-variable-error-message.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ ├── variable-list.tsx │ │ │ │ │ ├── variable-pill.tsx │ │ │ │ │ └── variable-tooltip.tsx │ │ │ │ ├── variables/ │ │ │ │ │ ├── delete-variable-dialog.tsx │ │ │ │ │ ├── system-variable-definitions.ts │ │ │ │ │ ├── system-variable-row.tsx │ │ │ │ │ ├── upsert-variable-drawer.tsx │ │ │ │ │ ├── upsert-variable-form.tsx │ │ │ │ │ ├── variable-list-upgrade-cta.tsx │ │ │ │ │ ├── variable-list.tsx │ │ │ │ │ └── variable-row.tsx │ │ │ │ ├── vercel-integration-form.tsx │ │ │ │ ├── webhooks/ │ │ │ │ │ ├── webhooks-empty-state-svg.tsx │ │ │ │ │ └── webhooks-paywall-state.tsx │ │ │ │ ├── welcome/ │ │ │ │ │ ├── ai-prompts/ │ │ │ │ │ │ ├── framework-prompts/ │ │ │ │ │ │ │ ├── angular-prompt.ts │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── javascript-prompt.ts │ │ │ │ │ │ │ ├── nextjs-prompt.ts │ │ │ │ │ │ │ ├── react-native-prompt.ts │ │ │ │ │ │ │ ├── react-prompt.ts │ │ │ │ │ │ │ ├── remix-prompt.ts │ │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ │ └── vue-prompt.ts │ │ │ │ │ │ └── simple-prompt-getter.ts │ │ │ │ │ ├── framework-guides.instructions.tsx │ │ │ │ │ ├── framework-guides.tsx │ │ │ │ │ ├── icons.tsx │ │ │ │ │ ├── inbox-connected-guide.tsx │ │ │ │ │ ├── inbox-embed.tsx │ │ │ │ │ ├── inbox-framework-guide/ │ │ │ │ │ │ ├── framework-card.tsx │ │ │ │ │ │ ├── framework-grid.tsx │ │ │ │ │ │ ├── header-section.tsx │ │ │ │ │ │ ├── helpers.ts │ │ │ │ │ │ ├── instructions-panel.tsx │ │ │ │ │ │ └── types.ts │ │ │ │ │ ├── inbox-framework-guide.tsx │ │ │ │ │ ├── progress-section.animations.ts │ │ │ │ │ ├── progress-section.tsx │ │ │ │ │ └── resources-list.tsx │ │ │ │ ├── workflow-editor/ │ │ │ │ │ ├── add-step-menu.tsx │ │ │ │ │ ├── animation-step-wrapper.tsx │ │ │ │ │ ├── base-node.tsx │ │ │ │ │ ├── channel-preferences-form.tsx │ │ │ │ │ ├── channel-preferences.tsx │ │ │ │ │ ├── condition-badge.tsx │ │ │ │ │ ├── configure-workflow-form.tsx │ │ │ │ │ ├── configure-workflow.tsx │ │ │ │ │ ├── control-input/ │ │ │ │ │ │ ├── control-input.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── create-workflow-form.tsx │ │ │ │ │ ├── drag-context.tsx │ │ │ │ │ ├── edges.tsx │ │ │ │ │ ├── editor-breadcrumbs.tsx │ │ │ │ │ ├── in-app-preview.tsx │ │ │ │ │ ├── node-utils.ts │ │ │ │ │ ├── nodes.tsx │ │ │ │ │ ├── payload-schema/ │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── payload-import-editor.tsx │ │ │ │ │ │ │ └── payload-schema-empty-state.tsx │ │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ └── use-import-schema.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── utils/ │ │ │ │ │ │ ├── generate-schema.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── payload-schema-drawer.tsx │ │ │ │ │ ├── saving-status-indicator.tsx │ │ │ │ │ ├── schema-change-confirmation-modal.tsx │ │ │ │ │ ├── schema.ts │ │ │ │ │ ├── severity-select-item.tsx │ │ │ │ │ ├── step-utils.ts │ │ │ │ │ ├── steps/ │ │ │ │ │ │ ├── base/ │ │ │ │ │ │ │ ├── base-body.tsx │ │ │ │ │ │ │ ├── base-subject.tsx │ │ │ │ │ │ │ └── data-object.tsx │ │ │ │ │ │ ├── chat/ │ │ │ │ │ │ │ ├── chat-editor.tsx │ │ │ │ │ │ │ ├── chat-preview.tsx │ │ │ │ │ │ │ └── configure-chat-step-preview.tsx │ │ │ │ │ │ ├── component-utils.tsx │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── preview-payload-section.tsx │ │ │ │ │ │ │ └── preview-step-results-section.tsx │ │ │ │ │ │ ├── conditions/ │ │ │ │ │ │ │ ├── edit-step-conditions-form.tsx │ │ │ │ │ │ │ ├── edit-step-conditions-layout.tsx │ │ │ │ │ │ │ ├── edit-step-conditions-skeleton.tsx │ │ │ │ │ │ │ └── edit-step-conditions.tsx │ │ │ │ │ │ ├── configure-step-form.tsx │ │ │ │ │ │ ├── configure-step-template-issue-cta.tsx │ │ │ │ │ │ ├── configure-step.tsx │ │ │ │ │ │ ├── constants/ │ │ │ │ │ │ │ └── preview-context.constants.ts │ │ │ │ │ │ ├── context/ │ │ │ │ │ │ │ ├── preview-context-container.tsx │ │ │ │ │ │ │ └── step-editor-context.tsx │ │ │ │ │ │ ├── controls/ │ │ │ │ │ │ │ ├── array-field-item-template.tsx │ │ │ │ │ │ │ ├── array-field-template.tsx │ │ │ │ │ │ │ ├── array-field-title-template.tsx │ │ │ │ │ │ │ ├── button-templates.tsx │ │ │ │ │ │ │ ├── custom-step-controls.tsx │ │ │ │ │ │ │ ├── json-form.tsx │ │ │ │ │ │ │ ├── object-field-template.tsx │ │ │ │ │ │ │ ├── select-widget.tsx │ │ │ │ │ │ │ ├── switch-widget.tsx │ │ │ │ │ │ │ ├── template-utils.tsx │ │ │ │ │ │ │ └── text-widget.tsx │ │ │ │ │ │ ├── delay/ │ │ │ │ │ │ │ ├── delay-control-values.tsx │ │ │ │ │ │ │ ├── delay-window.tsx │ │ │ │ │ │ │ ├── dynamic-delay.tsx │ │ │ │ │ │ │ └── fixed-delay.tsx │ │ │ │ │ │ ├── digest-delay-tabs/ │ │ │ │ │ │ │ ├── days-of-week.tsx │ │ │ │ │ │ │ ├── digest-control-values.tsx │ │ │ │ │ │ │ ├── digest-delay-tabs.tsx │ │ │ │ │ │ │ ├── digest-key.tsx │ │ │ │ │ │ │ ├── keys.ts │ │ │ │ │ │ │ ├── lookback-window.tsx │ │ │ │ │ │ │ ├── numbers-picker.tsx │ │ │ │ │ │ │ ├── period.tsx │ │ │ │ │ │ │ ├── regular-type.tsx │ │ │ │ │ │ │ ├── scheduled-type.tsx │ │ │ │ │ │ │ └── utils.ts │ │ │ │ │ │ ├── editor/ │ │ │ │ │ │ │ └── step-editor-factory.tsx │ │ │ │ │ │ ├── email/ │ │ │ │ │ │ │ ├── configure-email-step-preview.tsx │ │ │ │ │ │ │ ├── email-body-html.tsx │ │ │ │ │ │ │ ├── email-body-maily.tsx │ │ │ │ │ │ │ ├── email-body.tsx │ │ │ │ │ │ │ ├── email-editor.tsx │ │ │ │ │ │ │ ├── email-preview.tsx │ │ │ │ │ │ │ ├── email-subject.tsx │ │ │ │ │ │ │ ├── email-tabs-section.tsx │ │ │ │ │ │ │ ├── layout-select.tsx │ │ │ │ │ │ │ ├── novu-branding.tsx │ │ │ │ │ │ │ ├── sender-config-drawer.tsx │ │ │ │ │ │ │ └── translations/ │ │ │ │ │ │ │ ├── edit-translation-popover/ │ │ │ │ │ │ │ │ ├── edit-translation-popover.tsx │ │ │ │ │ │ │ │ ├── use-translation-editor.ts │ │ │ │ │ │ │ │ ├── use-translation-form.ts │ │ │ │ │ │ │ │ └── use-virtual-anchor.ts │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── new-translation-key-preview.tsx │ │ │ │ │ │ │ ├── translation-decorator.tsx │ │ │ │ │ │ │ ├── translation-pill.tsx │ │ │ │ │ │ │ ├── translation-suggestions-list-view.tsx │ │ │ │ │ │ │ └── translation-tooltip.tsx │ │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ │ ├── use-persisted-preview-context.ts │ │ │ │ │ │ │ └── use-preview-data-initialization.ts │ │ │ │ │ │ ├── http-request/ │ │ │ │ │ │ │ ├── configure-http-request-step-preview.tsx │ │ │ │ │ │ │ ├── continue-on-failure.tsx │ │ │ │ │ │ │ ├── curl-display.tsx │ │ │ │ │ │ │ ├── curl-utils.ts │ │ │ │ │ │ │ ├── enforce-schema-validation.tsx │ │ │ │ │ │ │ ├── http-request-console-preview.tsx │ │ │ │ │ │ │ ├── http-request-editor.tsx │ │ │ │ │ │ │ ├── http-request-test-context.ts │ │ │ │ │ │ │ ├── http-request-test-provider.tsx │ │ │ │ │ │ │ ├── key-value-pair-list.tsx │ │ │ │ │ │ │ ├── novu-signature-header.tsx │ │ │ │ │ │ │ ├── request-endpoint.tsx │ │ │ │ │ │ │ ├── response-body-schema.tsx │ │ │ │ │ │ │ ├── section-header.tsx │ │ │ │ │ │ │ ├── use-copy-prompt.tsx │ │ │ │ │ │ │ └── use-http-request-test.ts │ │ │ │ │ │ ├── in-app/ │ │ │ │ │ │ │ ├── configure-in-app-step-preview.tsx │ │ │ │ │ │ │ ├── in-app-action.tsx │ │ │ │ │ │ │ ├── in-app-avatar.tsx │ │ │ │ │ │ │ ├── in-app-body.tsx │ │ │ │ │ │ │ ├── in-app-editor.tsx │ │ │ │ │ │ │ ├── in-app-redirect.tsx │ │ │ │ │ │ │ ├── in-app-subject.tsx │ │ │ │ │ │ │ ├── in-app-tabs-section.tsx │ │ │ │ │ │ │ └── inbox-preview.tsx │ │ │ │ │ │ ├── layout/ │ │ │ │ │ │ │ ├── copilot-sidebar.tsx │ │ │ │ │ │ │ ├── panel-header.tsx │ │ │ │ │ │ │ └── resizable-layout.tsx │ │ │ │ │ │ ├── preview/ │ │ │ │ │ │ │ ├── previews/ │ │ │ │ │ │ │ │ └── email-preview-wrapper.tsx │ │ │ │ │ │ │ ├── step-preview-factory.tsx │ │ │ │ │ │ │ └── step-resolver-preview-error.tsx │ │ │ │ │ │ ├── preview-context-panel.tsx │ │ │ │ │ │ ├── push/ │ │ │ │ │ │ │ ├── configure-push-step-preview.tsx │ │ │ │ │ │ │ ├── push-editor.tsx │ │ │ │ │ │ │ └── push-preview.tsx │ │ │ │ │ │ ├── save-form-context.ts │ │ │ │ │ │ ├── sdk-banner.tsx │ │ │ │ │ │ ├── shared/ │ │ │ │ │ │ │ ├── bypass-sanitization-switch.tsx │ │ │ │ │ │ │ ├── editable-json-viewer/ │ │ │ │ │ │ │ │ ├── constants.ts │ │ │ │ │ │ │ │ ├── custom-text-editor.tsx │ │ │ │ │ │ │ │ ├── editable-json-viewer.tsx │ │ │ │ │ │ │ │ ├── icons.tsx │ │ │ │ │ │ │ │ ├── single-click-editable-value.tsx │ │ │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ │ │ └── use-hide-root-node.ts │ │ │ │ │ │ │ ├── extend-to-schedule.tsx │ │ │ │ │ │ │ ├── step-editor-mode-toggle.tsx │ │ │ │ │ │ │ ├── step-resolver-active-panel.tsx │ │ │ │ │ │ │ ├── step-resolver-empty-preview.tsx │ │ │ │ │ │ │ ├── step-resolver-not-published.tsx │ │ │ │ │ │ │ └── use-step-resolver-hint.tsx │ │ │ │ │ │ ├── skip-conditions-button.tsx │ │ │ │ │ │ ├── sms/ │ │ │ │ │ │ │ ├── configure-sms-step-preview.tsx │ │ │ │ │ │ │ ├── sms-editor.tsx │ │ │ │ │ │ │ ├── sms-phone.tsx │ │ │ │ │ │ │ └── sms-preview.tsx │ │ │ │ │ │ ├── step-drawer.tsx │ │ │ │ │ │ ├── step-editor-layout.tsx │ │ │ │ │ │ ├── step-editor-unavailable.tsx │ │ │ │ │ │ ├── tabs-section.tsx │ │ │ │ │ │ ├── throttle/ │ │ │ │ │ │ │ ├── dynamic-throttle.tsx │ │ │ │ │ │ │ ├── fixed-throttle.tsx │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ ├── throttle-control-values.tsx │ │ │ │ │ │ │ ├── throttle-editor.tsx │ │ │ │ │ │ │ ├── throttle-key.tsx │ │ │ │ │ │ │ ├── throttle-threshold.tsx │ │ │ │ │ │ │ └── throttle-window.tsx │ │ │ │ │ │ ├── time-units.ts │ │ │ │ │ │ ├── types/ │ │ │ │ │ │ │ └── preview-context.types.ts │ │ │ │ │ │ ├── use-editor-preview.tsx │ │ │ │ │ │ └── utils/ │ │ │ │ │ │ ├── digest-sync.utils.ts │ │ │ │ │ │ ├── preview-context-storage.utils.ts │ │ │ │ │ │ ├── preview-context.utils.ts │ │ │ │ │ │ └── step-utils.tsx │ │ │ │ │ ├── test-workflow/ │ │ │ │ │ │ ├── snippet-editor.tsx │ │ │ │ │ │ ├── test-workflow-activity-drawer.tsx │ │ │ │ │ │ ├── test-workflow-content.tsx │ │ │ │ │ │ ├── test-workflow-drawer.tsx │ │ │ │ │ │ ├── test-workflow-form.tsx │ │ │ │ │ │ ├── test-workflow-instructions.tsx │ │ │ │ │ │ ├── test-workflow-logs-sidebar.tsx │ │ │ │ │ │ ├── test-workflow-tabs.tsx │ │ │ │ │ │ └── types.ts │ │ │ │ │ ├── toasts.tsx │ │ │ │ │ ├── translation-status.tsx │ │ │ │ │ ├── translation-toggle-section.tsx │ │ │ │ │ ├── url-input.tsx │ │ │ │ │ ├── use-animated-nodes.ts │ │ │ │ │ ├── use-canvas-nodes-edges.ts │ │ │ │ │ ├── use-workflow-schema-manager.ts │ │ │ │ │ ├── workflow-activity.tsx │ │ │ │ │ ├── workflow-canvas.tsx │ │ │ │ │ ├── workflow-checklist.tsx │ │ │ │ │ ├── workflow-node-action-bar.tsx │ │ │ │ │ ├── workflow-provider.tsx │ │ │ │ │ ├── workflow-schema-provider.tsx │ │ │ │ │ └── workflow-tabs.tsx │ │ │ │ ├── workflow-issues-popover.tsx │ │ │ │ ├── workflow-list-empty.tsx │ │ │ │ ├── workflow-list.tsx │ │ │ │ ├── workflow-row.tsx │ │ │ │ ├── workflow-status.tsx │ │ │ │ ├── workflow-step.tsx │ │ │ │ ├── workflow-steps.tsx │ │ │ │ └── workflow-tags.tsx │ │ │ ├── config/ │ │ │ │ └── index.ts │ │ │ ├── context/ │ │ │ │ ├── auth/ │ │ │ │ │ ├── auth-context.tsx │ │ │ │ │ ├── auth-provider.tsx │ │ │ │ │ ├── hooks.ts │ │ │ │ │ ├── mappers.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── customer-io/ │ │ │ │ │ ├── customer-io-provider.tsx │ │ │ │ │ ├── hooks.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── ee-auth-provider.tsx │ │ │ │ ├── environment/ │ │ │ │ │ ├── environment-context.tsx │ │ │ │ │ ├── environment-provider.tsx │ │ │ │ │ └── hooks.ts │ │ │ │ ├── escape-key-manager/ │ │ │ │ │ ├── escape-key-context.tsx │ │ │ │ │ ├── escape-key-manager.tsx │ │ │ │ │ ├── hooks.ts │ │ │ │ │ └── priority.ts │ │ │ │ ├── feature-flags-provider.tsx │ │ │ │ ├── identity-provider.tsx │ │ │ │ ├── opt-in-provider.tsx │ │ │ │ ├── region/ │ │ │ │ │ ├── index.self-hosted.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── region-config.ts │ │ │ │ │ ├── region-context.self-hosted.tsx │ │ │ │ │ ├── region-context.tsx │ │ │ │ │ ├── region-modals.tsx │ │ │ │ │ ├── region-selector.tsx │ │ │ │ │ ├── region-types.ts │ │ │ │ │ └── region-utils.ts │ │ │ │ └── segment/ │ │ │ │ ├── hooks.ts │ │ │ │ ├── index.ts │ │ │ │ └── segment-provider.tsx │ │ │ ├── hooks/ │ │ │ │ ├── use-activity-url-state.ts │ │ │ │ ├── use-ai-chat-stream.ts │ │ │ │ ├── use-auto-configure-integration.ts │ │ │ │ ├── use-before-unload.ts │ │ │ │ ├── use-billing-portal.ts │ │ │ │ ├── use-checkout-session.ts │ │ │ │ ├── use-combined-refs.ts │ │ │ │ ├── use-conditions-count.ts │ │ │ │ ├── use-create-ai-chat.ts │ │ │ │ ├── use-create-context.ts │ │ │ │ ├── use-create-environment-variable.ts │ │ │ │ ├── use-create-integration.ts │ │ │ │ ├── use-create-layout.ts │ │ │ │ ├── use-create-subscriber.ts │ │ │ │ ├── use-create-topic.ts │ │ │ │ ├── use-create-translation-key.ts │ │ │ │ ├── use-create-vercel-integration.ts │ │ │ │ ├── use-create-workflow.ts │ │ │ │ ├── use-data-ref.ts │ │ │ │ ├── use-debounce.ts │ │ │ │ ├── use-debounced-form.ts │ │ │ │ ├── use-debounced-value.ts │ │ │ │ ├── use-default-subscriber-data.ts │ │ │ │ ├── use-delayed-loading.ts │ │ │ │ ├── use-delete-context.ts │ │ │ │ ├── use-delete-environment-variable.ts │ │ │ │ ├── use-delete-integration.ts │ │ │ │ ├── use-delete-layout.ts │ │ │ │ ├── use-delete-subscriber.ts │ │ │ │ ├── use-delete-translation-group.ts │ │ │ │ ├── use-delete-workflow.ts │ │ │ │ ├── use-disconnect-step-resolver.ts │ │ │ │ ├── use-duplicate-layout.ts │ │ │ │ ├── use-duplicate-workflow.ts │ │ │ │ ├── use-dynamic-preview-schema.ts │ │ │ │ ├── use-editor-translation-overlay.ts │ │ │ │ ├── use-enhanced-variable-validation.ts │ │ │ │ ├── use-environments.ts │ │ │ │ ├── use-export-master-json.ts │ │ │ │ ├── use-feature-flag.tsx │ │ │ │ ├── use-fetch-activities.ts │ │ │ │ ├── use-fetch-activity.ts │ │ │ │ ├── use-fetch-api-keys.ts │ │ │ │ ├── use-fetch-bridge-health-check.ts │ │ │ │ ├── use-fetch-charts.ts │ │ │ │ ├── use-fetch-context.ts │ │ │ │ ├── use-fetch-contexts.ts │ │ │ │ ├── use-fetch-environment-variable-usage.ts │ │ │ │ ├── use-fetch-environment-variables.ts │ │ │ │ ├── use-fetch-integrations.ts │ │ │ │ ├── use-fetch-latest-ai-chat.ts │ │ │ │ ├── use-fetch-layout-usage.ts │ │ │ │ ├── use-fetch-layout.ts │ │ │ │ ├── use-fetch-layouts.tsx │ │ │ │ ├── use-fetch-organization-settings.ts │ │ │ │ ├── use-fetch-request-logs.ts │ │ │ │ ├── use-fetch-request-traces.ts │ │ │ │ ├── use-fetch-subscriber-preferences.ts │ │ │ │ ├── use-fetch-subscriber-subscriptions.ts │ │ │ │ ├── use-fetch-subscriber.ts │ │ │ │ ├── use-fetch-subscribers.ts │ │ │ │ ├── use-fetch-subscription.ts │ │ │ │ ├── use-fetch-topics.ts │ │ │ │ ├── use-fetch-translation-group.ts │ │ │ │ ├── use-fetch-translation-keys.ts │ │ │ │ ├── use-fetch-translation-list.ts │ │ │ │ ├── use-fetch-translation.ts │ │ │ │ ├── use-fetch-vercel-integration-projects.tsx │ │ │ │ ├── use-fetch-vercel-integration.ts │ │ │ │ ├── use-fetch-workflow-runs-count.ts │ │ │ │ ├── use-fetch-workflow-test-data.ts │ │ │ │ ├── use-fetch-workflow.ts │ │ │ │ ├── use-fetch-workflows.ts │ │ │ │ ├── use-find-dirty-form.ts │ │ │ │ ├── use-first-trigger-detection.ts │ │ │ │ ├── use-form-autosave.ts │ │ │ │ ├── use-form-protection.tsx │ │ │ │ ├── use-has-permission.tsx │ │ │ │ ├── use-init-demo-workflow.ts │ │ │ │ ├── use-invocation-queue.ts │ │ │ │ ├── use-is-mobile.ts │ │ │ │ ├── use-is-payload-schema-enabled.ts │ │ │ │ ├── use-is-translation-enabled.ts │ │ │ │ ├── use-keep-ai-changes.ts │ │ │ │ ├── use-layout-preview.ts │ │ │ │ ├── use-logs-url-state.ts │ │ │ │ ├── use-metric-data.ts │ │ │ │ ├── use-mutation-observer.ts │ │ │ │ ├── use-new-dashboard-opt-in.ts │ │ │ │ ├── use-on-element-unmount.ts │ │ │ │ ├── use-onboarding-steps.ts │ │ │ │ ├── use-optimistic-channel-preferences.ts │ │ │ │ ├── use-optimistic-schedule-update.ts │ │ │ │ ├── use-page-visit-timestamp.ts │ │ │ │ ├── use-parse-variables.ts │ │ │ │ ├── use-patch-subscriber.ts │ │ │ │ ├── use-patch-workflow.ts │ │ │ │ ├── use-persisted-page-size.ts │ │ │ │ ├── use-plain-chat.ts │ │ │ │ ├── use-preview-context.ts │ │ │ │ ├── use-preview-step.ts │ │ │ │ ├── use-primary-email-integration.ts │ │ │ │ ├── use-pull-activity.ts │ │ │ │ ├── use-remove-grammarly.ts │ │ │ │ ├── use-resource-dependencies.ts │ │ │ │ ├── use-revert-message.ts │ │ │ │ ├── use-save-translation.ts │ │ │ │ ├── use-set-primary-integration.ts │ │ │ │ ├── use-step-resolver-polling.ts │ │ │ │ ├── use-step-resolvers-count.ts │ │ │ │ ├── use-sync-workflow.tsx │ │ │ │ ├── use-tab-observer.ts │ │ │ │ ├── use-tags.ts │ │ │ │ ├── use-telemetry.ts │ │ │ │ ├── use-template-store.ts │ │ │ │ ├── use-test-http-endpoint.ts │ │ │ │ ├── use-translation-completion-source.ts │ │ │ │ ├── use-translation-plugin-extension.ts │ │ │ │ ├── use-translation-validation.ts │ │ │ │ ├── use-translations.ts │ │ │ │ ├── use-trigger-workflow.ts │ │ │ │ ├── use-update-bridge-url.ts │ │ │ │ ├── use-update-context.ts │ │ │ │ ├── use-update-environment-variable.ts │ │ │ │ ├── use-update-integration.ts │ │ │ │ ├── use-update-layout.ts │ │ │ │ ├── use-update-organization-settings.ts │ │ │ │ ├── use-update-translation-value.ts │ │ │ │ ├── use-update-vercel-integration.ts │ │ │ │ ├── use-update-workflow.ts │ │ │ │ ├── use-upload-master-json.ts │ │ │ │ ├── use-upload-translations.ts │ │ │ │ ├── use-validate-bridge-url.ts │ │ │ │ ├── use-variables.ts │ │ │ │ ├── use-vercel-params.ts │ │ │ │ └── use-workflow-editor-page.ts │ │ │ ├── index.css │ │ │ ├── main.tsx │ │ │ ├── pages/ │ │ │ │ ├── access-denied-page.tsx │ │ │ │ ├── activity-feed.tsx │ │ │ │ ├── analytics.tsx │ │ │ │ ├── api-keys.tsx │ │ │ │ ├── contexts.tsx │ │ │ │ ├── create-context.tsx │ │ │ │ ├── create-layout.tsx │ │ │ │ ├── create-subscriber.tsx │ │ │ │ ├── create-topic.tsx │ │ │ │ ├── create-workflow.tsx │ │ │ │ ├── duplicate-layout-page.tsx │ │ │ │ ├── duplicate-workflow.tsx │ │ │ │ ├── edit-context.tsx │ │ │ │ ├── edit-layout.tsx │ │ │ │ ├── edit-step-template-v2.tsx │ │ │ │ ├── edit-subscriber-page.tsx │ │ │ │ ├── edit-topic.tsx │ │ │ │ ├── edit-translation.tsx │ │ │ │ ├── edit-workflow.tsx │ │ │ │ ├── environments.tsx │ │ │ │ ├── error-page.tsx │ │ │ │ ├── forgot-password.tsx │ │ │ │ ├── inbox-embed-page.tsx │ │ │ │ ├── inbox-embed-success-page.tsx │ │ │ │ ├── inbox-usecase-page.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── integrations-list-page.tsx │ │ │ │ ├── invitation-accept.tsx │ │ │ │ ├── landing-1-signup.tsx │ │ │ │ ├── layouts.tsx │ │ │ │ ├── new-layout-drawer.tsx │ │ │ │ ├── new-workflow-drawer.tsx │ │ │ │ ├── organization-list.tsx │ │ │ │ ├── questionnaire-page.tsx │ │ │ │ ├── redirect-to-legacy-studio-auth.tsx │ │ │ │ ├── reset-password.tsx │ │ │ │ ├── server-error-page.tsx │ │ │ │ ├── settings.tsx │ │ │ │ ├── sign-in.tsx │ │ │ │ ├── sign-up.tsx │ │ │ │ ├── sso-sign-in.tsx │ │ │ │ ├── subscribers.tsx │ │ │ │ ├── test-workflow-drawer-page.tsx │ │ │ │ ├── test-workflow-route-handler.tsx │ │ │ │ ├── test-workflow.tsx │ │ │ │ ├── topics.tsx │ │ │ │ ├── translation-settings-page.tsx │ │ │ │ ├── translations.tsx │ │ │ │ ├── upsert-variable.tsx │ │ │ │ ├── usecase-select-page.tsx │ │ │ │ ├── variables.tsx │ │ │ │ ├── vercel-integration-page.tsx │ │ │ │ ├── verify-email.tsx │ │ │ │ ├── webhooks-page.tsx │ │ │ │ ├── welcome-page.tsx │ │ │ │ └── workflows.tsx │ │ │ ├── routes/ │ │ │ │ ├── auth.tsx │ │ │ │ ├── catch-all.tsx │ │ │ │ ├── dashboard.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── onboarding.tsx │ │ │ │ ├── permission-protected-route.tsx │ │ │ │ ├── protected-route.tsx │ │ │ │ └── root.tsx │ │ │ ├── types/ │ │ │ │ ├── activity.ts │ │ │ │ ├── global.ts │ │ │ │ ├── logs.ts │ │ │ │ └── translations.ts │ │ │ ├── utils/ │ │ │ │ ├── activityFilters.ts │ │ │ │ ├── analytics-mock-data.ts │ │ │ │ ├── animation.ts │ │ │ │ ├── api-hostname-manager.ts │ │ │ │ ├── api-response-normalizer.ts │ │ │ │ ├── arrays.ts │ │ │ │ ├── auth.ts │ │ │ │ ├── avatars.ts │ │ │ │ ├── better-auth/ │ │ │ │ │ ├── client.ts │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── forgot-password.tsx │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── invitation-accept.tsx │ │ │ │ │ │ ├── organization-create.tsx │ │ │ │ │ │ ├── organization-dropdown.tsx │ │ │ │ │ │ ├── organization-list.tsx │ │ │ │ │ │ ├── organization-settings.tsx │ │ │ │ │ │ ├── organization-switcher.tsx │ │ │ │ │ │ ├── reset-password.tsx │ │ │ │ │ │ ├── sign-in.tsx │ │ │ │ │ │ ├── sign-up.tsx │ │ │ │ │ │ ├── sso-sign-in.tsx │ │ │ │ │ │ ├── team-members.tsx │ │ │ │ │ │ ├── user-button.tsx │ │ │ │ │ │ ├── user-profile.tsx │ │ │ │ │ │ └── verify-email.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── role-permissions.ts │ │ │ │ ├── channels.ts │ │ │ │ ├── clerk-appearance.ts │ │ │ │ ├── code-snippets.ts │ │ │ │ ├── color.ts │ │ │ │ ├── conditions.ts │ │ │ │ ├── constants.ts │ │ │ │ ├── context-variable-utils.ts │ │ │ │ ├── context.ts │ │ │ │ ├── customer-io.ts │ │ │ │ ├── default-values.ts │ │ │ │ ├── enums.ts │ │ │ │ ├── format-count.ts │ │ │ │ ├── format-date.ts │ │ │ │ ├── formatter.ts │ │ │ │ ├── id-utils.ts │ │ │ │ ├── inbox.ts │ │ │ │ ├── json.ts │ │ │ │ ├── liquid-autocomplete.tsx │ │ │ │ ├── liquid-scope-analyzer.ts │ │ │ │ ├── liquid.ts │ │ │ │ ├── local-storage.ts │ │ │ │ ├── logs-filters.utils.ts │ │ │ │ ├── number-formatting.ts │ │ │ │ ├── parse-page-param.ts │ │ │ │ ├── parseStepVariables.ts │ │ │ │ ├── polymorphic.ts │ │ │ │ ├── protect.tsx │ │ │ │ ├── query-keys.ts │ │ │ │ ├── recursive-clone-children.tsx │ │ │ │ ├── routes.ts │ │ │ │ ├── schema.ts │ │ │ │ ├── segment.ts │ │ │ │ ├── self-hosted/ │ │ │ │ │ ├── api-interceptor.tsx │ │ │ │ │ ├── auth.resource.tsx │ │ │ │ │ ├── components.tsx │ │ │ │ │ ├── icons.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── jwt-manager.tsx │ │ │ │ │ ├── organization-switcher.tsx │ │ │ │ │ ├── organization.resource.tsx │ │ │ │ │ ├── user-button.tsx │ │ │ │ │ ├── user.resource.tsx │ │ │ │ │ └── user.types.ts │ │ │ │ ├── sentry.ts │ │ │ │ ├── string.ts │ │ │ │ ├── telemetry.ts │ │ │ │ ├── titleize.ts │ │ │ │ ├── tracking.ts │ │ │ │ ├── tv.ts │ │ │ │ ├── types.ts │ │ │ │ ├── ui.ts │ │ │ │ ├── url.ts │ │ │ │ ├── uuid/ │ │ │ │ │ └── index.ts │ │ │ │ ├── validation.ts │ │ │ │ └── workflow-trigger-ai-prompt.ts │ │ │ └── vite-env.d.ts │ │ ├── tailwind.config.ts │ │ ├── tests/ │ │ │ ├── manage-workflows.e2e.ts │ │ │ ├── package.json │ │ │ ├── page-object-models/ │ │ │ │ ├── create-workflow-sidebar.ts │ │ │ │ ├── in-app-step-editor.ts │ │ │ │ ├── step-config-sidebar.ts │ │ │ │ ├── trigger-workflow-page.ts │ │ │ │ ├── workflow-editor-page.ts │ │ │ │ └── workflows-page.ts │ │ │ ├── sync-workflow.e2e.ts │ │ │ ├── tsconfig.json │ │ │ └── utils/ │ │ │ ├── api.ts │ │ │ ├── environment-service.ts │ │ │ ├── fixtures.ts │ │ │ ├── integration-service.ts │ │ │ ├── organization-service.ts │ │ │ ├── session.ts │ │ │ ├── test-bridge-server.ts │ │ │ └── user-service.ts │ │ ├── tests-examples/ │ │ │ └── demo-todo-app.spec.ts │ │ ├── tsconfig.app.json │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ └── vite.config.ts │ ├── inbound-mail/ │ │ ├── .example.env │ │ ├── .gitignore │ │ ├── Dockerfile │ │ ├── e2e/ │ │ │ └── setup.ts │ │ ├── nodemon.json │ │ ├── package.json │ │ ├── src/ │ │ │ ├── config/ │ │ │ │ ├── env.config.ts │ │ │ │ ├── env.validators.ts │ │ │ │ └── index.ts │ │ │ ├── instrument.ts │ │ │ ├── main.ts │ │ │ ├── python/ │ │ │ │ ├── DNS/ │ │ │ │ │ ├── Base.py │ │ │ │ │ ├── Class.py │ │ │ │ │ ├── Lib.py │ │ │ │ │ ├── Opcode.py │ │ │ │ │ ├── Status.py │ │ │ │ │ ├── Type.py │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── lazy.py │ │ │ │ │ └── win32dns.py │ │ │ │ ├── dkim/ │ │ │ │ │ ├── .__init__.py.swo │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── __main__.py │ │ │ │ │ ├── asn1.py │ │ │ │ │ ├── canonicalization.py │ │ │ │ │ ├── crypto.py │ │ │ │ │ ├── dnsplug.py │ │ │ │ │ └── util.py │ │ │ │ ├── ipaddr.py │ │ │ │ ├── spf.py │ │ │ │ ├── verifydkim.py │ │ │ │ └── verifyspf.py │ │ │ ├── server/ │ │ │ │ ├── inbound-mail.service.spec.ts │ │ │ │ ├── inbound-mail.service.ts │ │ │ │ ├── index.ts │ │ │ │ ├── logger.ts │ │ │ │ └── mailUtilities.ts │ │ │ └── types/ │ │ │ └── env.d.ts │ │ └── tsconfig.json │ ├── webhook/ │ │ ├── .dockerignore │ │ ├── .gitignore │ │ ├── Dockerfile │ │ ├── e2e/ │ │ │ ├── mocha.e2e.opts │ │ │ └── setup.ts │ │ ├── nest-cli.json │ │ ├── package.json │ │ ├── src/ │ │ │ ├── .example.env │ │ │ ├── app.controller.ts │ │ │ ├── app.module.ts │ │ │ ├── app.service.ts │ │ │ ├── bootstrap.ts │ │ │ ├── config/ │ │ │ │ ├── env.config.ts │ │ │ │ └── env.validators.ts │ │ │ ├── health/ │ │ │ │ ├── health.controller.ts │ │ │ │ └── health.module.ts │ │ │ ├── instrument.ts │ │ │ ├── main.ts │ │ │ ├── shared/ │ │ │ │ ├── constants.ts │ │ │ │ ├── framework/ │ │ │ │ │ ├── response.interceptor.ts │ │ │ │ │ └── user.decorator.ts │ │ │ │ ├── helpers/ │ │ │ │ │ └── regex.service.ts │ │ │ │ └── shared.module.ts │ │ │ ├── types/ │ │ │ │ └── env.d.ts │ │ │ └── webhooks/ │ │ │ ├── dtos/ │ │ │ │ └── webhooks-response.dto.ts │ │ │ ├── e2e/ │ │ │ │ └── email-webhook.e2e.ts │ │ │ ├── interfaces/ │ │ │ │ └── webhook.interface.ts │ │ │ ├── usecases/ │ │ │ │ ├── execution-details/ │ │ │ │ │ ├── create-execution-details.command.ts │ │ │ │ │ └── create-execution-details.usecase.ts │ │ │ │ ├── index.ts │ │ │ │ └── webhook/ │ │ │ │ ├── webhook.command.ts │ │ │ │ └── webhook.usecase.ts │ │ │ ├── webhooks.controller.ts │ │ │ └── webhooks.module.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── worker/ │ │ ├── .gitignore │ │ ├── .mocharc.json │ │ ├── .vscode/ │ │ │ └── settings.json │ │ ├── Dockerfile │ │ ├── README.md │ │ ├── e2e/ │ │ │ └── setup.ts │ │ ├── nest-cli.json │ │ ├── package.json │ │ ├── project.json │ │ ├── src/ │ │ │ ├── .example.env │ │ │ ├── app/ │ │ │ │ ├── health/ │ │ │ │ │ ├── e2e/ │ │ │ │ │ │ └── health-check.e2e.ts │ │ │ │ │ ├── health.controller.ts │ │ │ │ │ └── health.module.ts │ │ │ │ ├── shared/ │ │ │ │ │ ├── response.interceptor.ts │ │ │ │ │ ├── shared.module.ts │ │ │ │ │ └── utils/ │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── exceptions.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── should-halt-on-step-failure.ts │ │ │ │ ├── telemetry/ │ │ │ │ │ ├── telemetry.module.ts │ │ │ │ │ ├── usecases/ │ │ │ │ │ │ ├── machineInfoService.usecase.ts │ │ │ │ │ │ └── userInfoService.usecase.ts │ │ │ │ │ └── utils/ │ │ │ │ │ ├── machine.utils.ts │ │ │ │ │ └── sendDataToNovuTrace.utils.ts │ │ │ │ └── workflow/ │ │ │ │ ├── services/ │ │ │ │ │ ├── active-jobs-metric.service.ts │ │ │ │ │ ├── cold-start.service.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── standard.worker.spec.ts │ │ │ │ │ ├── standard.worker.ts │ │ │ │ │ ├── subscriber-process.worker.ts │ │ │ │ │ ├── workflow.worker.spec.ts │ │ │ │ │ └── workflow.worker.ts │ │ │ │ ├── specs/ │ │ │ │ │ ├── conditions-filter.usecase.spec.ts │ │ │ │ │ └── inbound-email-parse.spec.ts │ │ │ │ ├── usecases/ │ │ │ │ │ ├── add-job/ │ │ │ │ │ │ ├── add-job.command.ts │ │ │ │ │ │ ├── add-job.usecase.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── merge-or-create-digest.command.ts │ │ │ │ │ │ ├── merge-or-create-digest.usecase.ts │ │ │ │ │ │ └── validation.ts │ │ │ │ │ ├── execute-bridge-job/ │ │ │ │ │ │ ├── execute-bridge-job.command.ts │ │ │ │ │ │ ├── execute-bridge-job.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── handle-last-failed-job/ │ │ │ │ │ │ ├── handle-last-failed-job.command.ts │ │ │ │ │ │ ├── handle-last-failed-job.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── inbound-email-parse/ │ │ │ │ │ │ ├── inbound-email-parse.command.ts │ │ │ │ │ │ └── inbound-email-parse.usecase.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── noop-send-webhook-message.usecase.ts │ │ │ │ │ ├── process-unsnooze-job/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── process-unsnooze-job.command.ts │ │ │ │ │ │ └── process-unsnooze-job.usecase.ts │ │ │ │ │ ├── queue-next-job/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── queue-next-job.command.ts │ │ │ │ │ │ └── queue-next-job.usecase.ts │ │ │ │ │ ├── run-job/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── run-job.command.ts │ │ │ │ │ │ ├── run-job.usecase.ts │ │ │ │ │ │ ├── schedule-validator.spec.ts │ │ │ │ │ │ └── schedule-validator.ts │ │ │ │ │ ├── send-message/ │ │ │ │ │ │ ├── channel-endpoint-resolution/ │ │ │ │ │ │ │ ├── resolve-channel-endpoints.command.ts │ │ │ │ │ │ │ └── resolve-channel-endpoints.usecase.ts │ │ │ │ │ │ ├── digest/ │ │ │ │ │ │ │ ├── digest-events.command.ts │ │ │ │ │ │ │ ├── digest.usecase.ts │ │ │ │ │ │ │ ├── get-digest-events-backoff.usecase.ts │ │ │ │ │ │ │ ├── get-digest-events-regular.usecase.ts │ │ │ │ │ │ │ ├── get-digest-events.usecase.ts │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── execute-code-first-custom-step.usecase.ts │ │ │ │ │ │ ├── execute-http-request-step.usecase.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── send-message-channel.command.ts │ │ │ │ │ │ ├── send-message-chat.usecase.ts │ │ │ │ │ │ ├── send-message-delay.usecase.ts │ │ │ │ │ │ ├── send-message-email.usecase.ts │ │ │ │ │ │ ├── send-message-in-app.usecase.ts │ │ │ │ │ │ ├── send-message-push.usecase.spec.ts │ │ │ │ │ │ ├── send-message-push.usecase.ts │ │ │ │ │ │ ├── send-message-sms.usecase.ts │ │ │ │ │ │ ├── send-message-type.usecase.ts │ │ │ │ │ │ ├── send-message.base.ts │ │ │ │ │ │ ├── send-message.command.ts │ │ │ │ │ │ ├── send-message.usecase.ts │ │ │ │ │ │ └── throttle/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── throttle.usecase.ts │ │ │ │ │ ├── store-subscriber-jobs/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── store-subscriber-jobs.command.ts │ │ │ │ │ │ └── store-subscriber-jobs.usecase.ts │ │ │ │ │ ├── subscriber-job-bound/ │ │ │ │ │ │ ├── subscriber-job-bound.command.ts │ │ │ │ │ │ └── subscriber-job-bound.usecase.ts │ │ │ │ │ ├── update-job-status/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── set-job-as-completed.usecase.ts │ │ │ │ │ │ ├── set-job-as-failed.usecase.ts │ │ │ │ │ │ ├── set-job-as.command.ts │ │ │ │ │ │ ├── update-job-status.command.ts │ │ │ │ │ │ └── update-job-status.usecase.ts │ │ │ │ │ └── webhook-filter-backoff-strategy/ │ │ │ │ │ ├── event-job.dto.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── webhook-filter-backoff-strategy.command.ts │ │ │ │ │ └── webhook-filter-backoff-strategy.usecase.ts │ │ │ │ ├── workers/ │ │ │ │ │ └── inbound-parse.worker.service.ts │ │ │ │ └── workflow.module.ts │ │ │ ├── app.module.ts │ │ │ ├── bootstrap.ts │ │ │ ├── config/ │ │ │ │ ├── env.config.ts │ │ │ │ ├── env.validators.ts │ │ │ │ ├── index.ts │ │ │ │ └── worker-init.config.ts │ │ │ ├── instrument.ts │ │ │ ├── main.ts │ │ │ ├── newrelic.ts │ │ │ └── types/ │ │ │ └── env.d.ts │ │ ├── tsconfig.build.json │ │ ├── tsconfig.json │ │ ├── tsconfig.spec.json │ │ └── webpack.config.js │ └── ws/ │ ├── .gitignore │ ├── Dockerfile │ ├── e2e/ │ │ └── setup.ts │ ├── nest-cli.json │ ├── package.json │ ├── src/ │ │ ├── .example.env │ │ ├── app.controller.ts │ │ ├── app.module.ts │ │ ├── app.service.ts │ │ ├── bootstrap.ts │ │ ├── config/ │ │ │ ├── env.config.ts │ │ │ ├── env.validators.ts │ │ │ └── index.ts │ │ ├── health/ │ │ │ ├── health.controller.ts │ │ │ └── health.module.ts │ │ ├── instrument.ts │ │ ├── main.ts │ │ ├── shared/ │ │ │ ├── framework/ │ │ │ │ └── in-memory-io.adapter.ts │ │ │ ├── shared.module.ts │ │ │ └── subscriber-online/ │ │ │ ├── index.ts │ │ │ └── subscriber-online.service.ts │ │ ├── socket/ │ │ │ ├── services/ │ │ │ │ ├── cold-start.service.ts │ │ │ │ ├── index.ts │ │ │ │ ├── web-socket.worker.spec.ts │ │ │ │ ├── web-socket.worker.ts │ │ │ │ └── ws-server-health-indicator.service.ts │ │ │ ├── socket.module.ts │ │ │ ├── usecases/ │ │ │ │ └── external-services-route/ │ │ │ │ ├── external-services-route.command.ts │ │ │ │ ├── external-services-route.spec.ts │ │ │ │ ├── external-services-route.usecase.ts │ │ │ │ ├── index.ts │ │ │ │ └── types.ts │ │ │ └── ws.gateway.ts │ │ └── types/ │ │ └── env.d.ts │ ├── tsconfig.build.json │ └── tsconfig.json ├── biome-plugins/ │ ├── api-property-optionality-required-prop.grit │ ├── api-property-optionality.grit │ ├── api-property-record-type.grit │ ├── command-session-exclusion.grit │ └── pino-logger-arg-order.grit ├── biome.json ├── docker/ │ ├── Readme.md │ ├── community/ │ │ └── docker-compose.yml │ └── local/ │ ├── docker-compose.agent.yml │ ├── docker-compose.e2e.yml │ ├── docker-compose.local.yml │ └── docker-compose.yml ├── enterprise/ │ ├── packages/ │ │ ├── ai/ │ │ │ ├── .gitignore │ │ │ ├── check-ee.mjs │ │ │ ├── package.json │ │ │ ├── project.json │ │ │ ├── tsconfig.build.json │ │ │ └── tsconfig.json │ │ ├── api/ │ │ │ ├── .gitignore │ │ │ ├── check-ee.mjs │ │ │ ├── package.json │ │ │ ├── project.json │ │ │ ├── tsconfig.build.json │ │ │ └── tsconfig.json │ │ ├── auth/ │ │ │ ├── .gitignore │ │ │ ├── check-ee.mjs │ │ │ ├── package.json │ │ │ ├── project.json │ │ │ ├── tsconfig.json │ │ │ └── tsconfig.spec.json │ │ ├── billing/ │ │ │ ├── .gitignore │ │ │ ├── check-ee.mjs │ │ │ ├── package.json │ │ │ ├── project.json │ │ │ ├── tsconfig.json │ │ │ └── tsconfig.spec.json │ │ ├── shared-services/ │ │ │ ├── .czrc │ │ │ ├── .gitignore │ │ │ ├── README.md │ │ │ ├── check-ee.mjs │ │ │ ├── package.json │ │ │ ├── project.json │ │ │ ├── tsconfig.json │ │ │ └── tsconfig.module.json │ │ └── translation/ │ │ ├── .gitignore │ │ ├── check-ee.mjs │ │ ├── package.json │ │ ├── project.json │ │ ├── tsconfig.json │ │ └── tsconfig.spec.json │ └── workers/ │ ├── scheduler/ │ │ ├── .editorconfig │ │ ├── .gitignore │ │ ├── .prettierrc │ │ ├── package.json │ │ ├── src/ │ │ │ ├── auth.ts │ │ │ ├── env.d.ts │ │ │ ├── index.ts │ │ │ ├── scheduler.ts │ │ │ └── types.ts │ │ ├── test/ │ │ │ ├── env.d.ts │ │ │ ├── index.spec.ts │ │ │ ├── scheduler.spec.ts │ │ │ └── tsconfig.json │ │ ├── tsconfig.json │ │ ├── vitest.config.mts │ │ ├── worker-configuration.d.ts │ │ ├── wrangler.jsonc │ │ └── wrangler.local.jsonc │ ├── socket/ │ │ ├── .editorconfig │ │ ├── .gitignore │ │ ├── .vscode/ │ │ │ └── settings.json │ │ ├── package.json │ │ ├── src/ │ │ │ ├── durable-objects/ │ │ │ │ └── websocket-room.ts │ │ │ ├── handlers/ │ │ │ │ └── websocket.ts │ │ │ ├── index.ts │ │ │ ├── middleware/ │ │ │ │ ├── auth.ts │ │ │ │ └── internal-auth.ts │ │ │ └── types/ │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ ├── worker-configuration.d.ts │ │ └── wrangler.jsonc │ └── step-resolver/ │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── src/ │ │ ├── auth/ │ │ │ └── hmac.ts │ │ ├── index.ts │ │ ├── types.ts │ │ └── utils/ │ │ └── worker-id.ts │ ├── tsconfig.json │ ├── worker-configuration.d.ts │ └── wrangler.jsonc ├── jest.config.js ├── libs/ │ ├── application-generic/ │ │ ├── .czrc │ │ ├── .gitignore │ │ ├── README.md │ │ ├── jest.config.js │ │ ├── jest.setup.js │ │ ├── package.json │ │ ├── src/ │ │ │ ├── commands/ │ │ │ │ ├── authenticated.command.ts │ │ │ │ ├── base.command.spec.ts │ │ │ │ ├── base.command.ts │ │ │ │ ├── index.ts │ │ │ │ ├── organization.command.ts │ │ │ │ └── project.command.ts │ │ │ ├── config/ │ │ │ │ ├── index.ts │ │ │ │ ├── workers.config.spec.ts │ │ │ │ └── workers.ts │ │ │ ├── custom-providers/ │ │ │ │ └── index.ts │ │ │ ├── decorators/ │ │ │ │ ├── context-payload.decorator.ts │ │ │ │ ├── external-api.decorator.ts │ │ │ │ ├── index.ts │ │ │ │ ├── is-valid-context-payload.decorator.ts │ │ │ │ ├── is-valid-locale.decorator.ts │ │ │ │ ├── json-schema.validator.ts │ │ │ │ ├── permissions.decorator.ts │ │ │ │ ├── product-feature.decorator.ts │ │ │ │ ├── resource-category.decorator.ts │ │ │ │ ├── retry-on-error-decorator.spec.ts │ │ │ │ ├── retry-on-error-decorator.ts │ │ │ │ ├── to-boolean.spec.ts │ │ │ │ ├── to-boolean.ts │ │ │ │ └── user-session.decorator.ts │ │ │ ├── dtos/ │ │ │ │ ├── base-issue.dto.ts │ │ │ │ ├── configurations.dto.ts │ │ │ │ ├── controls-metadata.dto.ts │ │ │ │ ├── credentials.dto.ts │ │ │ │ ├── get-environment-tags.dto.ts │ │ │ │ ├── get-workflow-with-preferences.dto.ts │ │ │ │ ├── inbound-parse-job.dto.ts │ │ │ │ ├── index.ts │ │ │ │ ├── integration-issue.dto.ts │ │ │ │ ├── integration-response.dto.ts │ │ │ │ ├── json-schema.dto.ts │ │ │ │ ├── layout/ │ │ │ │ │ ├── create-layout.dto.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── layout-controls.dto.ts │ │ │ │ │ ├── layout-response.dto.ts │ │ │ │ │ ├── update-layout.dto.ts │ │ │ │ │ └── v0/ │ │ │ │ │ └── layout.dto.ts │ │ │ │ ├── process-subscriber-job.dto.ts │ │ │ │ ├── standard-job.dto.ts │ │ │ │ ├── step-content-issue.dto.ts │ │ │ │ ├── step-filter-dto.ts │ │ │ │ ├── step-issues.dto.ts │ │ │ │ ├── subscriber-topic-preference.dto.ts │ │ │ │ ├── subscribers/ │ │ │ │ │ ├── channelSettingsDto.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── subscriber-channel.ts │ │ │ │ │ ├── subscriber-response.dto.ts │ │ │ │ │ └── update-subscriber-channel-request.dto.ts │ │ │ │ ├── ui-schema-property.dto.ts │ │ │ │ ├── ui-schema.dto.ts │ │ │ │ ├── user-response.dto.ts │ │ │ │ ├── web-sockets-job.dto.ts │ │ │ │ ├── workflow/ │ │ │ │ │ ├── channel-preference.dto.ts │ │ │ │ │ ├── chat-control.dto.ts │ │ │ │ │ ├── controls/ │ │ │ │ │ │ ├── custom-control.dto.ts │ │ │ │ │ │ ├── delay-control.dto.ts │ │ │ │ │ │ ├── digest-control.dto.ts │ │ │ │ │ │ ├── email-control.dto.ts │ │ │ │ │ │ ├── http-request-control.dto.ts │ │ │ │ │ │ ├── in-app-control.dto.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── look-back-window.dto.ts │ │ │ │ │ │ ├── push-control.dto.ts │ │ │ │ │ │ ├── sms-control.dto.ts │ │ │ │ │ │ └── throttle-control.dto.ts │ │ │ │ │ ├── generate-preview-request.dto.ts │ │ │ │ │ ├── generate-preview-response.dto.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── preferences.response.dto.ts │ │ │ │ │ ├── preview-payload.dto.ts │ │ │ │ │ ├── runtime-issue.dto.ts │ │ │ │ │ ├── skip.dto.ts │ │ │ │ │ ├── step-list-response.dto.ts │ │ │ │ │ ├── step-responses/ │ │ │ │ │ │ ├── chat-step.response.dto.ts │ │ │ │ │ │ ├── custom-step.response.dto.ts │ │ │ │ │ │ ├── delay-step.response.dto.ts │ │ │ │ │ │ ├── digest-step.response.dto.ts │ │ │ │ │ │ ├── email-step.response.dto.ts │ │ │ │ │ │ ├── http-request-step.response.dto.ts │ │ │ │ │ │ ├── in-app-step.response.dto.ts │ │ │ │ │ │ ├── push-step.response.dto.ts │ │ │ │ │ │ ├── sms-step.response.dto.ts │ │ │ │ │ │ └── throttle-step.response.dto.ts │ │ │ │ │ ├── step.response.dto.ts │ │ │ │ │ ├── workflow-commons.dto.ts │ │ │ │ │ ├── workflow-list-response.dto.ts │ │ │ │ │ ├── workflow-preference.dto.ts │ │ │ │ │ ├── workflow-preferences.dto.ts │ │ │ │ │ └── workflow-response.dto.ts │ │ │ │ └── workflow-job.dto.ts │ │ │ ├── encryption/ │ │ │ │ ├── cipher.spec.ts │ │ │ │ ├── cipher.ts │ │ │ │ ├── encrypt-environment-variable.ts │ │ │ │ ├── encrypt-provider.spec.ts │ │ │ │ ├── encrypt-provider.ts │ │ │ │ └── index.ts │ │ │ ├── factories/ │ │ │ │ ├── channel.factory.ts │ │ │ │ ├── chat/ │ │ │ │ │ ├── chat.factory.ts │ │ │ │ │ ├── handlers/ │ │ │ │ │ │ ├── base.handler.ts │ │ │ │ │ │ ├── chat-webhook.handler.ts │ │ │ │ │ │ ├── discord.handler.ts │ │ │ │ │ │ ├── getstream.handler.ts │ │ │ │ │ │ ├── grafana-on-call.handler.ts │ │ │ │ │ │ ├── mattermost.handler.ts │ │ │ │ │ │ ├── msteams.handler.ts │ │ │ │ │ │ ├── novu-slack.handler.ts │ │ │ │ │ │ ├── rocket-chat.handler.ts │ │ │ │ │ │ ├── ryver.handler.ts │ │ │ │ │ │ ├── slack.handler.ts │ │ │ │ │ │ ├── whatsapp-business.handler.ts │ │ │ │ │ │ └── zulip.handler.ts │ │ │ │ │ └── interfaces/ │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── mail/ │ │ │ │ │ ├── handlers/ │ │ │ │ │ │ ├── base.handler.ts │ │ │ │ │ │ ├── braze.handler.ts │ │ │ │ │ │ ├── email-webhook.handler.ts │ │ │ │ │ │ ├── emailjs.handler.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── infobip.handler.ts │ │ │ │ │ │ ├── mailersend.handler.ts │ │ │ │ │ │ ├── mailgun.handler.ts │ │ │ │ │ │ ├── mailjet.handler.ts │ │ │ │ │ │ ├── mailtrap.handler.ts │ │ │ │ │ │ ├── mandrill.handler.ts │ │ │ │ │ │ ├── netcore.handler.ts │ │ │ │ │ │ ├── nodemailer.handler.ts │ │ │ │ │ │ ├── novu.handler.ts │ │ │ │ │ │ ├── outlook365.handler.ts │ │ │ │ │ │ ├── plunk.handler.ts │ │ │ │ │ │ ├── postmark.handler.ts │ │ │ │ │ │ ├── resend.handler.ts │ │ │ │ │ │ ├── sendgrid.handler.ts │ │ │ │ │ │ ├── sendinblue.handler.ts │ │ │ │ │ │ ├── ses.handler.ts │ │ │ │ │ │ └── sparkpost.handler.ts │ │ │ │ │ ├── interfaces/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── mail.factory.ts │ │ │ │ ├── push/ │ │ │ │ │ ├── handlers/ │ │ │ │ │ │ ├── apns.handler.ts │ │ │ │ │ │ ├── appio.handler.ts │ │ │ │ │ │ ├── base.handler.ts │ │ │ │ │ │ ├── expo.handler.ts │ │ │ │ │ │ ├── fcm.handler.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── one-signal.handler.ts │ │ │ │ │ │ ├── push-webhook.handler.ts │ │ │ │ │ │ ├── pusher-beams.handler.ts │ │ │ │ │ │ └── pushpad.handler.ts │ │ │ │ │ ├── interfaces/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── push.factory.interface.ts │ │ │ │ │ │ └── push.handler.interface.ts │ │ │ │ │ └── push.factory.ts │ │ │ │ ├── shared/ │ │ │ │ │ └── interfaces.ts │ │ │ │ └── sms/ │ │ │ │ ├── handlers/ │ │ │ │ │ ├── africas-talking.handler.ts │ │ │ │ │ ├── afro-sms.handler.ts │ │ │ │ │ ├── azure-sms.handler.ts │ │ │ │ │ ├── bandwidth.handler.ts │ │ │ │ │ ├── base.handler.ts │ │ │ │ │ ├── brevo-sms.handler.ts │ │ │ │ │ ├── bulk-sms.handler.ts │ │ │ │ │ ├── burst-sms.handler.ts │ │ │ │ │ ├── clickatell.handler.ts │ │ │ │ │ ├── clicksend.handler.ts │ │ │ │ │ ├── cm-telecom.handler.ts │ │ │ │ │ ├── eazy-sms.handler.ts │ │ │ │ │ ├── firetext.handler.ts │ │ │ │ │ ├── forty-six-elks.handler.ts │ │ │ │ │ ├── generic-sms.handler.ts │ │ │ │ │ ├── gupshup.handler.ts │ │ │ │ │ ├── imedia.handler.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── infobip.handler.ts │ │ │ │ │ ├── isend-sms.handler.ts │ │ │ │ │ ├── isendpro-sms.handler.ts │ │ │ │ │ ├── kannel.handler.ts │ │ │ │ │ ├── maqsam.handler.ts │ │ │ │ │ ├── messagebird.handler.ts │ │ │ │ │ ├── mobishastra.handler.ts │ │ │ │ │ ├── nexmo.handler.ts │ │ │ │ │ ├── novu.handler.ts │ │ │ │ │ ├── plivo.handler.ts │ │ │ │ │ ├── ring-central.handler.ts │ │ │ │ │ ├── sendchamp.handler.ts │ │ │ │ │ ├── simpletexting.handler.ts │ │ │ │ │ ├── sinch.handler.ts │ │ │ │ │ ├── sms-central.handler.ts │ │ │ │ │ ├── sms77.handler.ts │ │ │ │ │ ├── smsmode.handler.ts │ │ │ │ │ ├── sns.handler.ts │ │ │ │ │ ├── telnyx.handler.ts │ │ │ │ │ ├── termii.handler.ts │ │ │ │ │ ├── twilio.handler.ts │ │ │ │ │ └── unifonic.handler.ts │ │ │ │ ├── interfaces/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── sms.factory.interface.ts │ │ │ │ │ └── sms.handler.interface.ts │ │ │ │ └── sms.factory.ts │ │ │ ├── health/ │ │ │ │ ├── active-jobs-metric-queue.health-indicator.ts │ │ │ │ ├── cache.health-indicator.ts │ │ │ │ ├── dal.health-indicator.ts │ │ │ │ ├── health-indicator.interface.ts │ │ │ │ ├── inbound-parse-queue.health-indicator.ts │ │ │ │ ├── index.ts │ │ │ │ ├── queue-health-indicator.service.ts │ │ │ │ ├── standard-queue.health-indicator.ts │ │ │ │ ├── subscriber-process-queue.health-indicator.ts │ │ │ │ ├── web-sockets-queue.health-indicator.ts │ │ │ │ └── workflow-queue.health-indicator.ts │ │ │ ├── http/ │ │ │ │ ├── headers.types.ts │ │ │ │ ├── index.ts │ │ │ │ ├── responses.types.ts │ │ │ │ ├── utils.types.spec.ts │ │ │ │ └── utils.types.ts │ │ │ ├── index.ts │ │ │ ├── instrumentation/ │ │ │ │ ├── index.ts │ │ │ │ └── instrumentation.decorator.ts │ │ │ ├── logging/ │ │ │ │ ├── LogDecorator.ts │ │ │ │ ├── error-util.ts │ │ │ │ ├── index.ts │ │ │ │ └── masking.ts │ │ │ ├── modules/ │ │ │ │ ├── cron.module.ts │ │ │ │ ├── index.ts │ │ │ │ ├── interfaces.ts │ │ │ │ ├── metrics.module.ts │ │ │ │ └── queues.module.ts │ │ │ ├── pipes/ │ │ │ │ ├── index.ts │ │ │ │ ├── parse-slug-env-id.pipe.spec.ts │ │ │ │ ├── parse-slug-env-id.pipe.ts │ │ │ │ ├── parse-slug-id.pipe.spec.ts │ │ │ │ ├── parse-slug-id.pipe.ts │ │ │ │ └── parse-slug-id.ts │ │ │ ├── resilience/ │ │ │ │ ├── delay.ts │ │ │ │ └── index.ts │ │ │ ├── schemas/ │ │ │ │ ├── channel-endpoint/ │ │ │ │ │ ├── __tests__/ │ │ │ │ │ │ └── channel-endpoint.schema.spec.ts │ │ │ │ │ ├── channel-endpoint.schema.ts │ │ │ │ │ └── index.ts │ │ │ │ └── control/ │ │ │ │ ├── chat-control.schema.ts │ │ │ │ ├── delay-control.schema.ts │ │ │ │ ├── digest-control.schema.ts │ │ │ │ ├── email-control.schema.ts │ │ │ │ ├── in-app-control.schema.ts │ │ │ │ ├── index.ts │ │ │ │ ├── layout-control.schema.ts │ │ │ │ ├── push-control.schema.ts │ │ │ │ ├── shared.ts │ │ │ │ ├── sms-control.schema.ts │ │ │ │ └── throttle-control.schema.ts │ │ │ ├── services/ │ │ │ │ ├── analytic-logs/ │ │ │ │ │ ├── clickhouse-batch.service.spec.ts │ │ │ │ │ ├── clickhouse-batch.service.ts │ │ │ │ │ ├── clickhouse.service.ts │ │ │ │ │ ├── delivery-trend-counts/ │ │ │ │ │ │ ├── delivery-trend-counts.repository.ts │ │ │ │ │ │ ├── delivery-trend-counts.schema.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── log.repository.ts │ │ │ │ │ ├── request-log/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── request-log.repository.ts │ │ │ │ │ │ └── request-log.schema.ts │ │ │ │ │ ├── shared.ts │ │ │ │ │ ├── step-run/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── step-run.repository.ts │ │ │ │ │ │ └── step-run.schema.ts │ │ │ │ │ ├── trace-log/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── trace-log.repository.ts │ │ │ │ │ │ └── trace-log.schema.ts │ │ │ │ │ ├── trace-rollup/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── trace-rollup.repository.ts │ │ │ │ │ │ └── trace-rollup.schema.ts │ │ │ │ │ ├── types/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── workflow-run/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── workflow-run.repository.ts │ │ │ │ │ │ └── workflow-run.schema.ts │ │ │ │ │ └── workflow-run-count/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── workflow-run-count.repository.ts │ │ │ │ │ └── workflow-run-count.schema.ts │ │ │ │ ├── analytics.service.ts │ │ │ │ ├── auth/ │ │ │ │ │ ├── auth.service.interface.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── shared.ts │ │ │ │ ├── bull-mq/ │ │ │ │ │ ├── bull-mq.service.spec.ts │ │ │ │ │ ├── bull-mq.service.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── cache/ │ │ │ │ │ ├── cache-service.mock.ts │ │ │ │ │ ├── cache-service.spec.ts │ │ │ │ │ ├── cache.service.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── interceptors/ │ │ │ │ │ │ ├── cached-query.interceptor.ts │ │ │ │ │ │ ├── cached-response.decorator.spec.ts │ │ │ │ │ │ ├── cached-response.decorator.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── shared-cache.spec.ts │ │ │ │ │ │ └── shared-cache.ts │ │ │ │ │ ├── invalidate-cache.service.ts │ │ │ │ │ └── key-builders/ │ │ │ │ │ ├── builder.base.ts │ │ │ │ │ ├── builder.scoped.ts │ │ │ │ │ ├── crypto.ts │ │ │ │ │ ├── entities.spec.ts │ │ │ │ │ ├── entities.ts │ │ │ │ │ ├── identifiers.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── queries.spec.ts │ │ │ │ │ └── queries.ts │ │ │ │ ├── calculate-delay/ │ │ │ │ │ ├── compute-job-wait-duration.service.spec.ts │ │ │ │ │ ├── compute-job-wait-duration.service.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── timed-digest-delay.service.spec.ts │ │ │ │ │ └── timed-digest-delay.service.ts │ │ │ │ ├── cloudflare-scheduler/ │ │ │ │ │ ├── cloudflare-scheduler.service.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── content.service.ts │ │ │ │ ├── control-value-sanitizer.service.ts │ │ │ │ ├── cron/ │ │ │ │ │ ├── cron.constants.ts │ │ │ │ │ ├── cron.service.ts │ │ │ │ │ ├── cron.types.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── pulse-cron.service.ts │ │ │ │ ├── feature-flags/ │ │ │ │ │ ├── feature-flags.service.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── launch-darkly.service.ts │ │ │ │ │ ├── process-env.service.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── helper-service/ │ │ │ │ │ ├── helper.service.spec.ts │ │ │ │ │ ├── helper.service.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── http-client/ │ │ │ │ │ ├── http-client.service.ts │ │ │ │ │ ├── http-client.types.ts │ │ │ │ │ ├── http-request.utils.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── in-memory-lru-cache/ │ │ │ │ │ ├── in-memory-lru-cache.service.spec.ts │ │ │ │ │ ├── in-memory-lru-cache.service.ts │ │ │ │ │ ├── in-memory-lru-cache.store.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── in-memory-provider/ │ │ │ │ │ ├── cache-in-memory-provider.service.ts │ │ │ │ │ ├── in-memory-provider.service.spec.ts │ │ │ │ │ ├── in-memory-provider.service.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── providers/ │ │ │ │ │ │ ├── azure-cache-for-redis-cluster-provider.ts │ │ │ │ │ │ ├── elasticache-cluster-provider.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── memory-db-cluster-provider.ts │ │ │ │ │ │ ├── providers.spec.ts │ │ │ │ │ │ ├── redis-cluster-provider.ts │ │ │ │ │ │ ├── redis-master-slave-provider.ts │ │ │ │ │ │ ├── redis-provider.ts │ │ │ │ │ │ └── variable-mappers.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ ├── web-sockets-in-memory-provider.service.ts │ │ │ │ │ └── workflow-in-memory-provider.service.ts │ │ │ │ ├── index.ts │ │ │ │ ├── message-interaction.service.ts │ │ │ │ ├── metrics/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── metrics.interface.ts │ │ │ │ │ ├── metrics.service.spec.ts │ │ │ │ │ └── metrics.service.ts │ │ │ │ ├── query-parser/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── query-parser.service.spec.ts │ │ │ │ │ ├── query-parser.service.ts │ │ │ │ │ ├── query-validator.service.spec.ts │ │ │ │ │ ├── query-validator.service.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── queues/ │ │ │ │ │ ├── active-jobs-metric-queue.service.spec.ts │ │ │ │ │ ├── active-jobs-metric-queue.service.ts │ │ │ │ │ ├── inbound-parse-queue.service.spec.ts │ │ │ │ │ ├── inbound-parse-queue.service.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── queue-base.service.ts │ │ │ │ │ ├── standard-queue.service.spec.ts │ │ │ │ │ ├── standard-queue.service.ts │ │ │ │ │ ├── subscriber-process-queue.service.ts │ │ │ │ │ ├── web-sockets-queue.service.spec.ts │ │ │ │ │ ├── web-sockets-queue.service.ts │ │ │ │ │ ├── workflow-queue.service.spec.ts │ │ │ │ │ └── workflow-queue.service.ts │ │ │ │ ├── readiness/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── readiness.service.spec.ts │ │ │ │ │ └── readiness.service.ts │ │ │ │ ├── resource-validator.service.ts │ │ │ │ ├── sanitize/ │ │ │ │ │ ├── sanitizer-v0.service.spec.ts │ │ │ │ │ ├── sanitizer-v0.service.ts │ │ │ │ │ ├── sanitizer.service.spec.ts │ │ │ │ │ └── sanitizer.service.ts │ │ │ │ ├── socket-worker/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── socket-worker.service.ts │ │ │ │ ├── sqs/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── sqs-consumer.service.ts │ │ │ │ │ ├── sqs-job-adapter.ts │ │ │ │ │ ├── sqs.service.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── storage/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── non-existing-file.error.ts │ │ │ │ │ ├── storage-helper.service.spec.ts │ │ │ │ │ ├── storage-helper.service.ts │ │ │ │ │ └── storage.service.ts │ │ │ │ ├── support.service.ts │ │ │ │ ├── throttle/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── redis-throttle.service.ts │ │ │ │ │ └── throttle.types.ts │ │ │ │ ├── verify-payload.service.ts │ │ │ │ ├── workers/ │ │ │ │ │ ├── active-jobs-metric-worker.service.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── standard-worker.service.ts │ │ │ │ │ ├── subscriber-process-worker.service.ts │ │ │ │ │ ├── web-sockets-worker.service.ts │ │ │ │ │ ├── worker-base.service.ts │ │ │ │ │ └── workflow-worker.service.ts │ │ │ │ ├── workflow-data.container.ts │ │ │ │ └── workflow-run.service.ts │ │ │ ├── tracing/ │ │ │ │ ├── index.ts │ │ │ │ ├── otel-init.ts │ │ │ │ ├── otel-wrapper.ts │ │ │ │ ├── tracing.module.ts │ │ │ │ └── tracing.service.ts │ │ │ ├── types/ │ │ │ │ ├── compile-context.ts │ │ │ │ ├── index.ts │ │ │ │ └── maily.types.ts │ │ │ ├── usecases/ │ │ │ │ ├── build-step-data/ │ │ │ │ │ ├── build-step-data.command.ts │ │ │ │ │ ├── build-step-data.usecase.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── build-step-issues/ │ │ │ │ │ ├── build-step-issues.command.ts │ │ │ │ │ ├── build-step-issues.usecase.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── build-variable-schema/ │ │ │ │ │ ├── build-available-variable-schema.command.ts │ │ │ │ │ ├── build-available-variable-schema.usecase.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── bulk-create-execution-details/ │ │ │ │ │ ├── bulk-create-execution-details.command.ts │ │ │ │ │ ├── bulk-create-execution-details.usecase.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── calculate-limit-novu-integration/ │ │ │ │ │ ├── calculate-limit-novu-integration.command.ts │ │ │ │ │ ├── calculate-limit-novu-integration.usecase.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── compile-email-template/ │ │ │ │ │ ├── compile-email-template.command.ts │ │ │ │ │ ├── compile-email-template.usecase.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── templates/ │ │ │ │ │ └── basic.handlebars │ │ │ │ ├── compile-in-app-template/ │ │ │ │ │ ├── compile-in-app-template.command.ts │ │ │ │ │ ├── compile-in-app-template.usecase.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── compile-step-template/ │ │ │ │ │ ├── compile-step-template.command.ts │ │ │ │ │ ├── compile-step-template.usecase.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── compile-template/ │ │ │ │ │ ├── compile-template.base.ts │ │ │ │ │ ├── compile-template.command.ts │ │ │ │ │ ├── compile-template.spec.ts │ │ │ │ │ ├── compile-template.usecase.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── conditions-filter/ │ │ │ │ │ ├── conditions-filter.command.ts │ │ │ │ │ ├── conditions-filter.usecase.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── create-change/ │ │ │ │ │ ├── create-change.command.ts │ │ │ │ │ ├── create-change.usecase.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── create-execution-details/ │ │ │ │ │ ├── create-execution-details.command.ts │ │ │ │ │ ├── create-execution-details.spec.ts │ │ │ │ │ ├── create-execution-details.usecase.ts │ │ │ │ │ ├── dtos/ │ │ │ │ │ │ ├── execution-details-response.dto.ts │ │ │ │ │ │ ├── execution-details.dto.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── types/ │ │ │ │ │ └── index.ts │ │ │ │ ├── create-notification-jobs/ │ │ │ │ │ ├── create-notification-jobs.command.ts │ │ │ │ │ ├── create-notification-jobs.usecase.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── create-or-update-subscriber/ │ │ │ │ │ ├── create-or-update-subscriber.command.ts │ │ │ │ │ ├── create-or-update-subscriber.spec.ts │ │ │ │ │ ├── create-or-update-subscriber.usecase.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── create-tenant/ │ │ │ │ │ ├── create-tenant.command.ts │ │ │ │ │ ├── create-tenant.usecase.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── create-variables-object/ │ │ │ │ │ ├── create-variables-object.command.ts │ │ │ │ │ ├── create-variables-object.usecase.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── create-workflow-v0/ │ │ │ │ │ ├── create-workflow.command.ts │ │ │ │ │ ├── create-workflow.usecase.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── delete-preferences/ │ │ │ │ │ ├── delete-preferences.command.ts │ │ │ │ │ ├── delete-preferences.usecase.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── digest-filter-steps/ │ │ │ │ │ ├── digest-filter-steps.command.ts │ │ │ │ │ ├── digest-filter-steps.usecase.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── disconnect-step-resolver/ │ │ │ │ │ ├── disconnect-step-resolver.command.ts │ │ │ │ │ ├── disconnect-step-resolver.usecase.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── execute-bridge-request/ │ │ │ │ │ ├── execute-bridge-request.command.ts │ │ │ │ │ ├── execute-bridge-request.usecase.ts │ │ │ │ │ ├── execute-framework-request.usecase.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── execute-step-resolver/ │ │ │ │ │ ├── execute-step-resolver-request.usecase.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── get-active-integration/ │ │ │ │ │ ├── get-active-integration.command.ts │ │ │ │ │ ├── get-active-integration.usecase.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── get-decrypted-integrations/ │ │ │ │ │ ├── get-decrypted-integrations.command.ts │ │ │ │ │ ├── get-decrypted-integrations.usecase.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── get-decrypted-secret-key/ │ │ │ │ │ ├── get-decrypted-secret-key.command.ts │ │ │ │ │ ├── get-decrypted-secret-key.usecase.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── get-environment-tags/ │ │ │ │ │ ├── get-environment-tags.command.ts │ │ │ │ │ ├── get-environment-tags.usecase.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── get-layout-v0/ │ │ │ │ │ ├── get-layout.command.ts │ │ │ │ │ ├── get-layout.use-case.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── layout.dto.ts │ │ │ │ ├── get-layout-v2/ │ │ │ │ │ ├── get-layout.command.ts │ │ │ │ │ ├── get-layout.use-case.spec.ts │ │ │ │ │ ├── get-layout.use-case.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── mapper.ts │ │ │ │ ├── get-novu-layout/ │ │ │ │ │ ├── get-novu-layout.command.ts │ │ │ │ │ ├── get-novu-layout.spec.ts │ │ │ │ │ ├── get-novu-layout.usecase.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── templates/ │ │ │ │ │ └── layout.handlebars │ │ │ │ ├── get-novu-provider-credentials/ │ │ │ │ │ ├── get-novu-provider-credentials.command.ts │ │ │ │ │ ├── get-novu-provider-credentials.usecase.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── get-preferences/ │ │ │ │ │ ├── get-preferences.command.ts │ │ │ │ │ ├── get-preferences.dto.ts │ │ │ │ │ ├── get-preferences.usecase.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── get-subscriber-schedule/ │ │ │ │ │ ├── get-subscriber-schedule.command.ts │ │ │ │ │ ├── get-subscriber-schedule.usecase.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── get-subscriber-template-preference/ │ │ │ │ │ ├── get-subscriber-template-preference.command.ts │ │ │ │ │ ├── get-subscriber-template-preference.usecase.spec.ts │ │ │ │ │ ├── get-subscriber-template-preference.usecase.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── get-tenant/ │ │ │ │ │ ├── get-tenant.command.ts │ │ │ │ │ ├── get-tenant.usecase.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── get-topic-subscribers/ │ │ │ │ │ ├── get-topic-subscribers.command.ts │ │ │ │ │ ├── get-topic-subscribers.use-case.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── get-workflow/ │ │ │ │ │ ├── get-workflow.command.ts │ │ │ │ │ ├── get-workflow.usecase.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── get-workflow-with-preferences/ │ │ │ │ │ ├── get-workflow-with-preferences.command.ts │ │ │ │ │ ├── get-workflow-with-preferences.usecase.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── layout-variables-schema/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── layout-variables-schema.command.ts │ │ │ │ │ └── layout-variables-schema.usecase.ts │ │ │ │ ├── merge-preferences/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── merge-preferences.command.ts │ │ │ │ │ ├── merge-preferences.spec.ts │ │ │ │ │ └── merge-preferences.usecase.ts │ │ │ │ ├── message-template/ │ │ │ │ │ ├── create-message-template/ │ │ │ │ │ │ ├── create-message-template.command.ts │ │ │ │ │ │ ├── create-message-template.spec.ts │ │ │ │ │ │ ├── create-message-template.usecase.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── delete-message-template/ │ │ │ │ │ │ ├── delete-message-template.command.ts │ │ │ │ │ │ └── delete-message-template.usecase.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── shared.ts │ │ │ │ │ └── update-message-template/ │ │ │ │ │ ├── update-message-template.command.ts │ │ │ │ │ └── update-message-template.usecase.ts │ │ │ │ ├── normalize-variables/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── normalize-variables.command.ts │ │ │ │ │ └── normalize-variables.usecase.ts │ │ │ │ ├── preview/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── preview.command.ts │ │ │ │ │ ├── preview.constants.ts │ │ │ │ │ ├── preview.types.ts │ │ │ │ │ ├── preview.usecase.ts │ │ │ │ │ ├── services/ │ │ │ │ │ │ ├── mock-data-generator.service.ts │ │ │ │ │ │ ├── payload-merger.service.ts │ │ │ │ │ │ └── preview-payload-processor.service.ts │ │ │ │ │ └── utils/ │ │ │ │ │ ├── preview-error-handler.ts │ │ │ │ │ └── variable-helpers.ts │ │ │ │ ├── preview-step/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── preview-step.command.ts │ │ │ │ │ └── preview-step.usecase.ts │ │ │ │ ├── process-tenant/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── process-tenant.command.ts │ │ │ │ │ └── process-tenant.usecase.ts │ │ │ │ ├── promote-type-change.command.ts │ │ │ │ ├── select-integration/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── select-integration.command.ts │ │ │ │ │ ├── select-integration.spec.ts │ │ │ │ │ └── select-integration.usecase.ts │ │ │ │ ├── select-variant/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── select-variant.command.ts │ │ │ │ │ ├── select-variant.spec.ts │ │ │ │ │ └── select-variant.usecase.ts │ │ │ │ ├── subscribers/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── types/ │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── update-subscriber-channel/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── update-subscriber-channel.command.ts │ │ │ │ │ └── update-subscriber-channel.usecase.ts │ │ │ │ ├── tier-restrictions-validate/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── tier-restrictions-validate.command.ts │ │ │ │ │ ├── tier-restrictions-validate.response.ts │ │ │ │ │ └── tier-restrictions-validate.usecase.ts │ │ │ │ ├── trigger-base/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── trigger-base.usecase.ts │ │ │ │ ├── trigger-broadcast/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── trigger-broadcast.command.ts │ │ │ │ │ └── trigger-broadcast.usecase.ts │ │ │ │ ├── trigger-event/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── trigger-event.command.ts │ │ │ │ │ └── trigger-event.usecase.ts │ │ │ │ ├── trigger-multicast/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── trigger-multicast.command.ts │ │ │ │ │ └── trigger-multicast.usecase.ts │ │ │ │ ├── update-change/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── update-change.command.ts │ │ │ │ │ └── update-change.usecase.ts │ │ │ │ ├── update-subscriber/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── update-subscriber.command.ts │ │ │ │ │ ├── update-subscriber.spec.ts │ │ │ │ │ └── update-subscriber.usecase.ts │ │ │ │ ├── update-tenant/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── update-tenant.command.ts │ │ │ │ │ └── update-tenant.usecase.ts │ │ │ │ ├── update-workflow-v0/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── update-workflow.command.ts │ │ │ │ │ └── update-workflow.usecase.ts │ │ │ │ ├── upsert-control-values/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── upsert-control-values.command.ts │ │ │ │ │ └── upsert-control-values.usecase.ts │ │ │ │ ├── upsert-preferences/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── upsert-preferences.command.ts │ │ │ │ │ ├── upsert-preferences.usecase.ts │ │ │ │ │ ├── upsert-subscriber-global-preferences.command.ts │ │ │ │ │ ├── upsert-subscriber-workflow-preferences.command.ts │ │ │ │ │ ├── upsert-user-workflow-preferences.command.ts │ │ │ │ │ └── upsert-workflow-preferences.command.ts │ │ │ │ ├── upsert-workflow/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── upsert-workflow.command.ts │ │ │ │ │ └── upsert-workflow.usecase.ts │ │ │ │ ├── verify-payload/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── verify-payload.command.ts │ │ │ │ │ ├── verify-payload.spec.ts │ │ │ │ │ └── verify-payload.usecase.ts │ │ │ │ └── workflow/ │ │ │ │ ├── get-workflow-by-ids/ │ │ │ │ │ ├── get-workflow-by-ids.command.ts │ │ │ │ │ └── get-workflow-by-ids.usecase.ts │ │ │ │ └── index.ts │ │ │ ├── utils/ │ │ │ │ ├── base62/ │ │ │ │ │ ├── base.ts │ │ │ │ │ ├── base62-alphabet.const.ts │ │ │ │ │ ├── base62.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── bridge.ts │ │ │ │ ├── build-slug.ts │ │ │ │ ├── build-variables.ts │ │ │ │ ├── buildBridgeEndpointUrl.ts │ │ │ │ ├── compute-workflow-status.ts │ │ │ │ ├── constants.ts │ │ │ │ ├── create-schema.ts │ │ │ │ ├── deepmerge.ts │ │ │ │ ├── digest.ts │ │ │ │ ├── duration-utils.spec.ts │ │ │ │ ├── duration-utils.ts │ │ │ │ ├── email-normalization.ts │ │ │ │ ├── exceptions.ts │ │ │ │ ├── filter-processing-details.ts │ │ │ │ ├── filter.ts │ │ │ │ ├── generate-id.ts │ │ │ │ ├── generate-payload-example.ts │ │ │ │ ├── hmac.ts │ │ │ │ ├── html.ts │ │ │ │ ├── index.ts │ │ │ │ ├── issues.ts │ │ │ │ ├── json-schema-mock.ts │ │ │ │ ├── json-schema-utils.spec.ts │ │ │ │ ├── json-schema-utils.ts │ │ │ │ ├── jsonToSchema.ts │ │ │ │ ├── maily-utils.ts │ │ │ │ ├── map-step-type-to-result.mapper.ts │ │ │ │ ├── notification-template-mapper.ts │ │ │ │ ├── novu-integrations.ts │ │ │ │ ├── object.ts │ │ │ │ ├── parse-payload-schema.ts │ │ │ │ ├── parse-step-variables.ts │ │ │ │ ├── prettify.type.ts │ │ │ │ ├── sanitize-control-values.ts │ │ │ │ ├── ssrf-url-validation.ts │ │ │ │ ├── step-resolver-control-state.ts │ │ │ │ ├── step-type-to-control.mapper.ts │ │ │ │ ├── subscriber.ts │ │ │ │ ├── subscribers.utils.ts │ │ │ │ ├── subscription.ts │ │ │ │ ├── template-parser/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── liquid-engine.ts │ │ │ │ │ ├── new-liquid-parser.spec.ts │ │ │ │ │ ├── new-liquid-parser.ts │ │ │ │ │ ├── parser-utils.spec.ts │ │ │ │ │ ├── parser-utils.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── timestamp-hex.ts │ │ │ │ └── variants/ │ │ │ │ ├── index.ts │ │ │ │ ├── isVariantEmpty.spec.ts │ │ │ │ ├── isVariantEmpty.ts │ │ │ │ └── normalizeVariantDefault.ts │ │ │ ├── value-objects/ │ │ │ │ ├── content.issue.ts │ │ │ │ ├── i-step.control.ts │ │ │ │ ├── index.ts │ │ │ │ ├── json-schema.ts │ │ │ │ ├── message.filter.ts │ │ │ │ ├── notification-step-variant.command.ts │ │ │ │ ├── notification.step.ts │ │ │ │ ├── step.issue.ts │ │ │ │ └── step.issues.ts │ │ │ └── webhooks/ │ │ │ ├── dtos/ │ │ │ │ ├── index.ts │ │ │ │ ├── message-webhook.response.dto.ts │ │ │ │ └── webhook-payload.dto.ts │ │ │ ├── index.ts │ │ │ ├── mappers/ │ │ │ │ ├── index.ts │ │ │ │ └── message.mapper.ts │ │ │ ├── services/ │ │ │ │ ├── index.ts │ │ │ │ └── svix-provider.service.ts │ │ │ ├── usecases/ │ │ │ │ ├── index.ts │ │ │ │ └── send-webhook-message/ │ │ │ │ ├── index.ts │ │ │ │ ├── send-webhook-message.command.ts │ │ │ │ └── send-webhook-message.usecase.ts │ │ │ └── utils/ │ │ │ ├── app-id.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsconfig.module.json │ ├── automation/ │ │ ├── .editorconfig │ │ ├── .gitignore │ │ ├── .verdaccio/ │ │ │ └── config.yml │ │ ├── .vscode/ │ │ │ └── extensions.json │ │ ├── README.md │ │ ├── generators.json │ │ ├── nx.json │ │ ├── package.json │ │ ├── project.json │ │ ├── src/ │ │ │ └── generators/ │ │ │ └── provider/ │ │ │ ├── files/ │ │ │ │ ├── __name__.provider.ts.template │ │ │ │ └── __name__.test.provider.spec.ts.template │ │ │ ├── generator.ts │ │ │ ├── schema.json │ │ │ └── schema.ts │ │ ├── tsconfig.json │ │ ├── tsconfig.lib.json │ │ └── tsconfig.spec.json │ ├── dal/ │ │ ├── .dockerignore │ │ ├── .gitignore │ │ ├── package.json │ │ ├── project.json │ │ ├── src/ │ │ │ ├── dal.service.ts │ │ │ ├── index.ts │ │ │ ├── repositories/ │ │ │ │ ├── ai-chat/ │ │ │ │ │ ├── ai-chat.entity.ts │ │ │ │ │ ├── ai-chat.repository.ts │ │ │ │ │ ├── ai-chat.schema.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── base-repository-v2.ts │ │ │ │ ├── base-repository.ts │ │ │ │ ├── change/ │ │ │ │ │ ├── change.entity.ts │ │ │ │ │ ├── change.repository.ts │ │ │ │ │ ├── change.schema.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── channel-connection/ │ │ │ │ │ ├── channel-connection.entity.ts │ │ │ │ │ ├── channel-connection.repository.ts │ │ │ │ │ ├── channel-connection.schema.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── channel-endpoint/ │ │ │ │ │ ├── channel-endpoint.entity.ts │ │ │ │ │ ├── channel-endpoint.repository.ts │ │ │ │ │ ├── channel-endpoint.schema.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── context/ │ │ │ │ │ ├── context.entity.ts │ │ │ │ │ ├── context.repository.ts │ │ │ │ │ ├── context.schema.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── control-values/ │ │ │ │ │ ├── control-values.entity.ts │ │ │ │ │ ├── control-values.repository.ts │ │ │ │ │ ├── control-values.schema.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── environment/ │ │ │ │ │ ├── environment.entity.ts │ │ │ │ │ ├── environment.repository.ts │ │ │ │ │ ├── environment.schema.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── environment-variable/ │ │ │ │ │ ├── environment-variable.entity.ts │ │ │ │ │ ├── environment-variable.repository.ts │ │ │ │ │ ├── environment-variable.schema.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── execution-details/ │ │ │ │ │ ├── execution-details.entity.ts │ │ │ │ │ ├── execution-details.repository.ts │ │ │ │ │ ├── execution-details.schema.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── feed/ │ │ │ │ │ ├── feed.entity.ts │ │ │ │ │ ├── feed.repository.ts │ │ │ │ │ ├── feed.schema.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── integration/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── integration.entity.ts │ │ │ │ │ ├── integration.repository.ts │ │ │ │ │ └── integration.schema.ts │ │ │ │ ├── job/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── job.entity.ts │ │ │ │ │ ├── job.repository.ts │ │ │ │ │ └── job.schema.ts │ │ │ │ ├── layout/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── layout.entity.ts │ │ │ │ │ ├── layout.repository.ts │ │ │ │ │ ├── layout.schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── localization/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── localization.entity.ts │ │ │ │ │ ├── localization.repository.ts │ │ │ │ │ └── localization.schema.ts │ │ │ │ ├── localization-group/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── localization-group.entity.ts │ │ │ │ │ ├── localization-group.repository.ts │ │ │ │ │ └── localization-group.schema.ts │ │ │ │ ├── member/ │ │ │ │ │ ├── community.member.repository.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── member-repository.interface.ts │ │ │ │ │ ├── member.entity.ts │ │ │ │ │ ├── member.repository.ts │ │ │ │ │ └── member.schema.ts │ │ │ │ ├── message/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── message.entity.ts │ │ │ │ │ ├── message.repository.ts │ │ │ │ │ └── message.schema.ts │ │ │ │ ├── message-template/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── message-template.entity.ts │ │ │ │ │ ├── message-template.repository.ts │ │ │ │ │ ├── message-template.schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── notification/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── notification.entity.ts │ │ │ │ │ ├── notification.feed.Item.entity.ts │ │ │ │ │ ├── notification.repository.ts │ │ │ │ │ └── notification.schema.ts │ │ │ │ ├── notification-group/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── notification-group.entity.ts │ │ │ │ │ ├── notification-group.repository.ts │ │ │ │ │ └── notification-group.schema.ts │ │ │ │ ├── notification-template/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── notification-template.entity.ts │ │ │ │ │ ├── notification-template.repository.ts │ │ │ │ │ └── notification-template.schema.ts │ │ │ │ ├── organization/ │ │ │ │ │ ├── community.organization.repository.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── organization-repository.interface.ts │ │ │ │ │ ├── organization.entity.ts │ │ │ │ │ ├── organization.repository.ts │ │ │ │ │ ├── organization.schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── preferences/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── preferences.entity.ts │ │ │ │ │ ├── preferences.repository.ts │ │ │ │ │ └── preferences.schema.ts │ │ │ │ ├── projection.types.ts │ │ │ │ ├── schema-default.options.ts │ │ │ │ ├── snapshot/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── snapshot.entity.ts │ │ │ │ │ ├── snapshot.repository.ts │ │ │ │ │ └── snapshot.schema.ts │ │ │ │ ├── subscriber/ │ │ │ │ │ ├── bulk.create.subscriber.entity.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── subscriber.entity.ts │ │ │ │ │ ├── subscriber.repository.ts │ │ │ │ │ ├── subscriber.schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── tenant/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── tenant.entity.ts │ │ │ │ │ ├── tenant.repository.ts │ │ │ │ │ ├── tenant.schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── topic/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── topic-subscribers.entity.ts │ │ │ │ │ ├── topic-subscribers.repository.ts │ │ │ │ │ ├── topic-subscribers.schema.ts │ │ │ │ │ ├── topic.entity.ts │ │ │ │ │ ├── topic.repository.ts │ │ │ │ │ ├── topic.schema.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── translation-group/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── translation-group.entity.ts │ │ │ │ │ ├── translation-group.repository.ts │ │ │ │ │ └── translation-group.schema.ts │ │ │ │ ├── translations/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── translation.entity.ts │ │ │ │ │ ├── translation.repository.ts │ │ │ │ │ └── translation.schema.ts │ │ │ │ ├── user/ │ │ │ │ │ ├── community.user.repository.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── user-repository.interface.ts │ │ │ │ │ ├── user.entity.ts │ │ │ │ │ ├── user.repository.ts │ │ │ │ │ └── user.schema.ts │ │ │ │ └── workflow-override/ │ │ │ │ ├── index.ts │ │ │ │ ├── types.ts │ │ │ │ ├── workflow-override.entity.ts │ │ │ │ ├── workflow-override.repository.ts │ │ │ │ └── workflow-override.schema.ts │ │ │ ├── shared/ │ │ │ │ ├── consts/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── ttl.ts │ │ │ │ ├── exceptions/ │ │ │ │ │ ├── dal.exception.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ └── types/ │ │ │ │ ├── index.ts │ │ │ │ └── index.type.ts │ │ │ └── types/ │ │ │ ├── auth.ts │ │ │ ├── enforce.ts │ │ │ ├── env.d.ts │ │ │ ├── error.enum.ts │ │ │ ├── helpers.ts │ │ │ ├── index.ts │ │ │ ├── results.ts │ │ │ └── sort-order.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── internal-sdk/ │ │ ├── .gitattributes │ │ ├── .gitignore │ │ ├── .npmignore │ │ ├── .speakeasy/ │ │ │ ├── gen.yaml │ │ │ ├── speakeasy-modifications-overlay.yaml │ │ │ └── workflow.yaml │ │ ├── CONTRIBUTING.md │ │ ├── RUNTIMES.md │ │ ├── eslint.config.mjs │ │ ├── examples/ │ │ │ ├── README.md │ │ │ ├── package.json │ │ │ └── trigger.example.ts │ │ ├── jsr.json │ │ ├── package.json │ │ ├── postman/ │ │ │ ├── .gitignore │ │ │ └── novu_api_postman_collection.json │ │ ├── project.json │ │ ├── sources/ │ │ │ └── temp.json │ │ ├── src/ │ │ │ ├── core.ts │ │ │ ├── funcs/ │ │ │ │ ├── activityChartsRetrieve.ts │ │ │ │ ├── activityRequestsList.ts │ │ │ │ ├── activityRequestsRetrieve.ts │ │ │ │ ├── activityTrack.ts │ │ │ │ ├── activityWorkflowRunsList.ts │ │ │ │ ├── activityWorkflowRunsRetrieve.ts │ │ │ │ ├── cancel.ts │ │ │ │ ├── channelConnectionsCreate.ts │ │ │ │ ├── channelConnectionsDelete.ts │ │ │ │ ├── channelConnectionsList.ts │ │ │ │ ├── channelConnectionsRetrieve.ts │ │ │ │ ├── channelConnectionsUpdate.ts │ │ │ │ ├── channelEndpointsCreate.ts │ │ │ │ ├── channelEndpointsDelete.ts │ │ │ │ ├── channelEndpointsList.ts │ │ │ │ ├── channelEndpointsRetrieve.ts │ │ │ │ ├── channelEndpointsUpdate.ts │ │ │ │ ├── contextsCreate.ts │ │ │ │ ├── contextsDelete.ts │ │ │ │ ├── contextsList.ts │ │ │ │ ├── contextsRetrieve.ts │ │ │ │ ├── contextsUpdate.ts │ │ │ │ ├── environmentVariablesCreate.ts │ │ │ │ ├── environmentVariablesDelete.ts │ │ │ │ ├── environmentVariablesList.ts │ │ │ │ ├── environmentVariablesRetrieve.ts │ │ │ │ ├── environmentVariablesUpdate.ts │ │ │ │ ├── environmentVariablesUsage.ts │ │ │ │ ├── environmentsCreate.ts │ │ │ │ ├── environmentsDelete.ts │ │ │ │ ├── environmentsDiff.ts │ │ │ │ ├── environmentsGetTags.ts │ │ │ │ ├── environmentsList.ts │ │ │ │ ├── environmentsPublish.ts │ │ │ │ ├── environmentsUpdate.ts │ │ │ │ ├── integrationsCreate.ts │ │ │ │ ├── integrationsDelete.ts │ │ │ │ ├── integrationsGenerateChatOAuthUrl.ts │ │ │ │ ├── integrationsIntegrationsControllerAutoConfigureIntegration.ts │ │ │ │ ├── integrationsList.ts │ │ │ │ ├── integrationsListActive.ts │ │ │ │ ├── integrationsSetAsPrimary.ts │ │ │ │ ├── integrationsUpdate.ts │ │ │ │ ├── layoutsCreate.ts │ │ │ │ ├── layoutsDelete.ts │ │ │ │ ├── layoutsDuplicate.ts │ │ │ │ ├── layoutsGeneratePreview.ts │ │ │ │ ├── layoutsList.ts │ │ │ │ ├── layoutsRetrieve.ts │ │ │ │ ├── layoutsUpdate.ts │ │ │ │ ├── layoutsUsage.ts │ │ │ │ ├── messagesDelete.ts │ │ │ │ ├── messagesDeleteByTransactionId.ts │ │ │ │ ├── messagesRetrieve.ts │ │ │ │ ├── notificationsList.ts │ │ │ │ ├── notificationsRetrieve.ts │ │ │ │ ├── subscribersCreate.ts │ │ │ │ ├── subscribersCreateBulk.ts │ │ │ │ ├── subscribersCredentialsAppend.ts │ │ │ │ ├── subscribersCredentialsDelete.ts │ │ │ │ ├── subscribersCredentialsUpdate.ts │ │ │ │ ├── subscribersDelete.ts │ │ │ │ ├── subscribersMessagesMarkAll.ts │ │ │ │ ├── subscribersMessagesMarkAllAs.ts │ │ │ │ ├── subscribersMessagesUpdateAsSeen.ts │ │ │ │ ├── subscribersNotificationsArchive.ts │ │ │ │ ├── subscribersNotificationsArchiveAll.ts │ │ │ │ ├── subscribersNotificationsArchiveAllRead.ts │ │ │ │ ├── subscribersNotificationsCompleteAction.ts │ │ │ │ ├── subscribersNotificationsCount.ts │ │ │ │ ├── subscribersNotificationsDelete.ts │ │ │ │ ├── subscribersNotificationsDeleteAll.ts │ │ │ │ ├── subscribersNotificationsFeed.ts │ │ │ │ ├── subscribersNotificationsList.ts │ │ │ │ ├── subscribersNotificationsMarkAllAsRead.ts │ │ │ │ ├── subscribersNotificationsMarkAsRead.ts │ │ │ │ ├── subscribersNotificationsMarkAsSeen.ts │ │ │ │ ├── subscribersNotificationsMarkAsUnread.ts │ │ │ │ ├── subscribersNotificationsRevertAction.ts │ │ │ │ ├── subscribersNotificationsSnooze.ts │ │ │ │ ├── subscribersNotificationsUnarchive.ts │ │ │ │ ├── subscribersNotificationsUnseenCount.ts │ │ │ │ ├── subscribersNotificationsUnsnooze.ts │ │ │ │ ├── subscribersPatch.ts │ │ │ │ ├── subscribersPreferencesBulkUpdate.ts │ │ │ │ ├── subscribersPreferencesList.ts │ │ │ │ ├── subscribersPreferencesUpdate.ts │ │ │ │ ├── subscribersPropertiesUpdateOnlineFlag.ts │ │ │ │ ├── subscribersRetrieve.ts │ │ │ │ ├── subscribersSearch.ts │ │ │ │ ├── subscribersTopicsList.ts │ │ │ │ ├── topicsCreate.ts │ │ │ │ ├── topicsDelete.ts │ │ │ │ ├── topicsGet.ts │ │ │ │ ├── topicsList.ts │ │ │ │ ├── topicsSubscribersRetrieve.ts │ │ │ │ ├── topicsSubscriptionsCreate.ts │ │ │ │ ├── topicsSubscriptionsDelete.ts │ │ │ │ ├── topicsSubscriptionsGetSubscription.ts │ │ │ │ ├── topicsSubscriptionsList.ts │ │ │ │ ├── topicsSubscriptionsUpdate.ts │ │ │ │ ├── topicsUpdate.ts │ │ │ │ ├── translationsCreate.ts │ │ │ │ ├── translationsDelete.ts │ │ │ │ ├── translationsGroupsDelete.ts │ │ │ │ ├── translationsGroupsRetrieve.ts │ │ │ │ ├── translationsMasterImport.ts │ │ │ │ ├── translationsMasterRetrieve.ts │ │ │ │ ├── translationsMasterUpload.ts │ │ │ │ ├── translationsRetrieve.ts │ │ │ │ ├── translationsUpload.ts │ │ │ │ ├── trigger.ts │ │ │ │ ├── triggerBroadcast.ts │ │ │ │ ├── triggerBulk.ts │ │ │ │ ├── workflowsCreate.ts │ │ │ │ ├── workflowsDelete.ts │ │ │ │ ├── workflowsDuplicate.ts │ │ │ │ ├── workflowsGet.ts │ │ │ │ ├── workflowsList.ts │ │ │ │ ├── workflowsPatch.ts │ │ │ │ ├── workflowsStepsGeneratePreview.ts │ │ │ │ ├── workflowsStepsRetrieve.ts │ │ │ │ ├── workflowsSync.ts │ │ │ │ └── workflowsUpdate.ts │ │ │ ├── hooks/ │ │ │ │ ├── hooks.ts │ │ │ │ ├── index.ts │ │ │ │ ├── novu-custom-hook.ts │ │ │ │ ├── registration.ts │ │ │ │ └── types.ts │ │ │ ├── index.ts │ │ │ ├── lib/ │ │ │ │ ├── base64.ts │ │ │ │ ├── config.ts │ │ │ │ ├── dlv.ts │ │ │ │ ├── encodings.ts │ │ │ │ ├── files.ts │ │ │ │ ├── http.ts │ │ │ │ ├── is-plain-object.ts │ │ │ │ ├── logger.ts │ │ │ │ ├── matchers.ts │ │ │ │ ├── primitives.ts │ │ │ │ ├── retries.ts │ │ │ │ ├── schemas.ts │ │ │ │ ├── sdks.ts │ │ │ │ ├── security.ts │ │ │ │ └── url.ts │ │ │ ├── models/ │ │ │ │ ├── components/ │ │ │ │ │ ├── actiondto.ts │ │ │ │ │ ├── activitiesresponsedto.ts │ │ │ │ │ ├── activitynotificationexecutiondetailresponsedto.ts │ │ │ │ │ ├── activitynotificationjobresponsedto.ts │ │ │ │ │ ├── activitynotificationresponsedto.ts │ │ │ │ │ ├── activitynotificationstepresponsedto.ts │ │ │ │ │ ├── activitynotificationsubscriberresponsedto.ts │ │ │ │ │ ├── activitynotificationtemplateresponsedto.ts │ │ │ │ │ ├── activitytopicdto.ts │ │ │ │ │ ├── actorfeeditemdto.ts │ │ │ │ │ ├── actortypeenum.ts │ │ │ │ │ ├── apikeydto.ts │ │ │ │ │ ├── authdto.ts │ │ │ │ │ ├── autoconfigureintegrationresponsedto.ts │ │ │ │ │ ├── bridgeconfigurationdto.ts │ │ │ │ │ ├── builderfieldtypeenum.ts │ │ │ │ │ ├── bulkcreatesubscriberresponsedto.ts │ │ │ │ │ ├── bulksubscribercreatedto.ts │ │ │ │ │ ├── bulktriggereventdto.ts │ │ │ │ │ ├── bulkupdatesubscriberpreferenceitemdto.ts │ │ │ │ │ ├── bulkupdatesubscriberpreferencesdto.ts │ │ │ │ │ ├── buttontypeenum.ts │ │ │ │ │ ├── channelcredentials.ts │ │ │ │ │ ├── channelcredentialsdto.ts │ │ │ │ │ ├── channelctatypeenum.ts │ │ │ │ │ ├── channelpreferencedto.ts │ │ │ │ │ ├── channelsettingsdto.ts │ │ │ │ │ ├── channeltypeenum.ts │ │ │ │ │ ├── chatcontroldto.ts │ │ │ │ │ ├── chatcontrolsmetadataresponsedto.ts │ │ │ │ │ ├── chatorpushproviderenum.ts │ │ │ │ │ ├── chatrenderoutput.ts │ │ │ │ │ ├── chatstepresponsedto.ts │ │ │ │ │ ├── chatstepupsertdto.ts │ │ │ │ │ ├── configurationsdto.ts │ │ │ │ │ ├── constraintvalidation.ts │ │ │ │ │ ├── contentissueenum.ts │ │ │ │ │ ├── controlsmetadatadto.ts │ │ │ │ │ ├── createchannelconnectionrequestdto.ts │ │ │ │ │ ├── createcontextrequestdto.ts │ │ │ │ │ ├── createdsubscriberdto.ts │ │ │ │ │ ├── createenvironmentrequestdto.ts │ │ │ │ │ ├── createenvironmentvariablerequestdto.ts │ │ │ │ │ ├── createintegrationrequestdto.ts │ │ │ │ │ ├── createlayoutdto.ts │ │ │ │ │ ├── createmsteamschannelendpointdto.ts │ │ │ │ │ ├── createmsteamsuserendpointdto.ts │ │ │ │ │ ├── createphoneendpointdto.ts │ │ │ │ │ ├── createslackchannelendpointdto.ts │ │ │ │ │ ├── createslackuserendpointdto.ts │ │ │ │ │ ├── createsubscriberrequestdto.ts │ │ │ │ │ ├── createsubscriptionsresponsedto.ts │ │ │ │ │ ├── createtopicsubscriptionsrequestdto.ts │ │ │ │ │ ├── createtranslationrequestdto.ts │ │ │ │ │ ├── createupdatetopicrequestdto.ts │ │ │ │ │ ├── createwebhookendpointdto.ts │ │ │ │ │ ├── createworkflowdto.ts │ │ │ │ │ ├── credentialsdto.ts │ │ │ │ │ ├── customcontroldto.ts │ │ │ │ │ ├── customcontrolsmetadataresponsedto.ts │ │ │ │ │ ├── customstepresponsedto.ts │ │ │ │ │ ├── customstepupsertdto.ts │ │ │ │ │ ├── delaycontroldto.ts │ │ │ │ │ ├── delaycontrolsmetadataresponsedto.ts │ │ │ │ │ ├── delayregularmetadata.ts │ │ │ │ │ ├── delayscheduledmetadata.ts │ │ │ │ │ ├── delaystepresponsedto.ts │ │ │ │ │ ├── delaystepupsertdto.ts │ │ │ │ │ ├── deletemessageresponsedto.ts │ │ │ │ │ ├── deletetopicresponsedto.ts │ │ │ │ │ ├── deletetopicsubscriberidentifierdto.ts │ │ │ │ │ ├── deletetopicsubscriptionsrequestdto.ts │ │ │ │ │ ├── deletetopicsubscriptionsresponsedto.ts │ │ │ │ │ ├── dependencyreasonenum.ts │ │ │ │ │ ├── diffactionenum.ts │ │ │ │ │ ├── diffenvironmentrequestdto.ts │ │ │ │ │ ├── diffenvironmentresponsedto.ts │ │ │ │ │ ├── diffsummarydto.ts │ │ │ │ │ ├── digestcontroldto.ts │ │ │ │ │ ├── digestcontrolsmetadataresponsedto.ts │ │ │ │ │ ├── digestmetadatadto.ts │ │ │ │ │ ├── digestregularmetadata.ts │ │ │ │ │ ├── digestregularoutput.ts │ │ │ │ │ ├── digeststepresponsedto.ts │ │ │ │ │ ├── digeststepupsertdto.ts │ │ │ │ │ ├── digesttimedconfigdto.ts │ │ │ │ │ ├── digesttimedmetadata.ts │ │ │ │ │ ├── digesttypeenum.ts │ │ │ │ │ ├── digestunitenum.ts │ │ │ │ │ ├── directionenum.ts │ │ │ │ │ ├── duplicatelayoutdto.ts │ │ │ │ │ ├── duplicateworkflowdto.ts │ │ │ │ │ ├── emailblock.ts │ │ │ │ │ ├── emailblockstyles.ts │ │ │ │ │ ├── emailblocktypeenum.ts │ │ │ │ │ ├── emailchanneloverrides.ts │ │ │ │ │ ├── emailcontroldto.ts │ │ │ │ │ ├── emailcontrolsdto.ts │ │ │ │ │ ├── emailcontrolsmetadataresponsedto.ts │ │ │ │ │ ├── emaillayoutrenderoutput.ts │ │ │ │ │ ├── emailrenderoutput.ts │ │ │ │ │ ├── emailstepresponsedto.ts │ │ │ │ │ ├── emailstepupsertdto.ts │ │ │ │ │ ├── environmentdiffsummarydto.ts │ │ │ │ │ ├── environmentresponsedto.ts │ │ │ │ │ ├── environmentvariableresponsedto.ts │ │ │ │ │ ├── environmentvariablevaluedto.ts │ │ │ │ │ ├── environmentvariablevalueresponsedto.ts │ │ │ │ │ ├── environmentvariableworkflowinfodto.ts │ │ │ │ │ ├── eventbody.ts │ │ │ │ │ ├── executiondetailssourceenum.ts │ │ │ │ │ ├── executiondetailsstatusenum.ts │ │ │ │ │ ├── failedoperationdto.ts │ │ │ │ │ ├── failedworkflowdto.ts │ │ │ │ │ ├── feedresponsedto.ts │ │ │ │ │ ├── fieldfilterpartdto.ts │ │ │ │ │ ├── generatechatoauthurlrequestdto.ts │ │ │ │ │ ├── generatechatoauthurlresponsedto.ts │ │ │ │ │ ├── generatelayoutpreviewresponsedto.ts │ │ │ │ │ ├── generatepreviewrequestdto.ts │ │ │ │ │ ├── generatepreviewresponsedto.ts │ │ │ │ │ ├── getchannelconnectionresponsedto.ts │ │ │ │ │ ├── getchannelendpointresponsedto.ts │ │ │ │ │ ├── getchartsresponsedto.ts │ │ │ │ │ ├── getcontextresponsedto.ts │ │ │ │ │ ├── getenvironmenttagsdto.ts │ │ │ │ │ ├── getenvironmentvariableusageresponsedto.ts │ │ │ │ │ ├── getlayoutusageresponsedto.ts │ │ │ │ │ ├── getmasterjsonresponsedto.ts │ │ │ │ │ ├── getpreferencesresponsedto.ts │ │ │ │ │ ├── getrequestresponsedto.ts │ │ │ │ │ ├── getrequestsresponsedto.ts │ │ │ │ │ ├── getsubscribernotificationscountresponsedto.ts │ │ │ │ │ ├── getsubscribernotificationsresponsedto.ts │ │ │ │ │ ├── getsubscriberpreferencesdto.ts │ │ │ │ │ ├── getworkflowrunresponsedto.ts │ │ │ │ │ ├── getworkflowrunsdto.ts │ │ │ │ │ ├── getworkflowrunsresponsedto.ts │ │ │ │ │ ├── grouppreferencefilterdetailsdto.ts │ │ │ │ │ ├── grouppreferencefilterdto.ts │ │ │ │ │ ├── httpmethodenum.ts │ │ │ │ │ ├── httprequestcontroldto.ts │ │ │ │ │ ├── httprequestcontrolsmetadataresponsedto.ts │ │ │ │ │ ├── httprequestkeyvaluepairdto.ts │ │ │ │ │ ├── httprequeststepresponsedto.ts │ │ │ │ │ ├── httprequeststepupsertdto.ts │ │ │ │ │ ├── importmasterjsonrequestdto.ts │ │ │ │ │ ├── importmasterjsonresponsedto.ts │ │ │ │ │ ├── inappcontroldto.ts │ │ │ │ │ ├── inappcontrolsmetadataresponsedto.ts │ │ │ │ │ ├── inapprenderoutput.ts │ │ │ │ │ ├── inappstepresponsedto.ts │ │ │ │ │ ├── inappstepupsertdto.ts │ │ │ │ │ ├── inboundparsedomaindto.ts │ │ │ │ │ ├── inboxactiondto.ts │ │ │ │ │ ├── inboxnotificationdto.ts │ │ │ │ │ ├── inboxsubscriberresponsedto.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── integrationissueenum.ts │ │ │ │ │ ├── integrationresponsedto.ts │ │ │ │ │ ├── layoutcontrolsdto.ts │ │ │ │ │ ├── layoutcontrolvaluesdto.ts │ │ │ │ │ ├── layoutcreationsourceenum.ts │ │ │ │ │ ├── layoutpreviewpayloaddto.ts │ │ │ │ │ ├── layoutpreviewrequestdto.ts │ │ │ │ │ ├── layoutresponsedto.ts │ │ │ │ │ ├── layoutresponsedtosortfield.ts │ │ │ │ │ ├── listchannelconnectionsresponsedto.ts │ │ │ │ │ ├── listchannelendpointsresponsedto.ts │ │ │ │ │ ├── listcontextsresponsedto.ts │ │ │ │ │ ├── listlayoutresponsedto.ts │ │ │ │ │ ├── listsubscribersresponsedto.ts │ │ │ │ │ ├── listtopicsresponsedto.ts │ │ │ │ │ ├── listtopicsubscriptionsresponsedto.ts │ │ │ │ │ ├── listworkflowresponse.ts │ │ │ │ │ ├── lookbackwindowdto.ts │ │ │ │ │ ├── markallmessageasrequestdto.ts │ │ │ │ │ ├── markmessageactionasseendto.ts │ │ │ │ │ ├── marksubscribernotificationsasseendto.ts │ │ │ │ │ ├── messageaction.ts │ │ │ │ │ ├── messageactionresult.ts │ │ │ │ │ ├── messageactionstatusenum.ts │ │ │ │ │ ├── messagebutton.ts │ │ │ │ │ ├── messagecta.ts │ │ │ │ │ ├── messagectadata.ts │ │ │ │ │ ├── messagemarkasrequestdto.ts │ │ │ │ │ ├── messageresponsedto.ts │ │ │ │ │ ├── messagesresponsedto.ts │ │ │ │ │ ├── messagestatusenum.ts │ │ │ │ │ ├── messagetemplate.ts │ │ │ │ │ ├── messagetemplatedto.ts │ │ │ │ │ ├── metadto.ts │ │ │ │ │ ├── monthlytypeenum.ts │ │ │ │ │ ├── msteamschannelendpointdto.ts │ │ │ │ │ ├── msteamsuserendpointdto.ts │ │ │ │ │ ├── notificationfeeditemdto.ts │ │ │ │ │ ├── notificationgroup.ts │ │ │ │ │ ├── notificationstepdata.ts │ │ │ │ │ ├── notificationstepdto.ts │ │ │ │ │ ├── notificationtrigger.ts │ │ │ │ │ ├── notificationtriggerdto.ts │ │ │ │ │ ├── notificationtriggervariable.ts │ │ │ │ │ ├── notificationworkflowdto.ts │ │ │ │ │ ├── ordinalenum.ts │ │ │ │ │ ├── ordinalvalueenum.ts │ │ │ │ │ ├── patchpreferencechannelsdto.ts │ │ │ │ │ ├── patchsubscriberpreferencesdto.ts │ │ │ │ │ ├── patchsubscriberrequestdto.ts │ │ │ │ │ ├── patchworkflowdto.ts │ │ │ │ │ ├── payloadvalidationerrordto.ts │ │ │ │ │ ├── phoneendpointdto.ts │ │ │ │ │ ├── preferencelevelenum.ts │ │ │ │ │ ├── preferenceoverridesourceenum.ts │ │ │ │ │ ├── preferencesrequestdto.ts │ │ │ │ │ ├── previewerrordto.ts │ │ │ │ │ ├── previewpayloaddto.ts │ │ │ │ │ ├── providersidenum.ts │ │ │ │ │ ├── publishenvironmentrequestdto.ts │ │ │ │ │ ├── publishenvironmentresponsedto.ts │ │ │ │ │ ├── publishsummarydto.ts │ │ │ │ │ ├── pushcontroldto.ts │ │ │ │ │ ├── pushcontrolsmetadataresponsedto.ts │ │ │ │ │ ├── pushrenderoutput.ts │ │ │ │ │ ├── pushstepresponsedto.ts │ │ │ │ │ ├── pushstepupsertdto.ts │ │ │ │ │ ├── redirectdto.ts │ │ │ │ │ ├── removesubscriberresponsedto.ts │ │ │ │ │ ├── replycallback.ts │ │ │ │ │ ├── requestlogresponsedto.ts │ │ │ │ │ ├── resourcedependencydto.ts │ │ │ │ │ ├── resourcediffdto.ts │ │ │ │ │ ├── resourcediffresultdto.ts │ │ │ │ │ ├── resourceoriginenum.ts │ │ │ │ │ ├── resourcetopublishdto.ts │ │ │ │ │ ├── resourcetypeenum.ts │ │ │ │ │ ├── runtimeissuedto.ts │ │ │ │ │ ├── scheduledto.ts │ │ │ │ │ ├── security.ts │ │ │ │ │ ├── severitylevelenum.ts │ │ │ │ │ ├── skippedworkflowdto.ts │ │ │ │ │ ├── slackchannelendpointdto.ts │ │ │ │ │ ├── slackuserendpointdto.ts │ │ │ │ │ ├── smscontroldto.ts │ │ │ │ │ ├── smscontrolsmetadataresponsedto.ts │ │ │ │ │ ├── smsrenderoutput.ts │ │ │ │ │ ├── smsstepresponsedto.ts │ │ │ │ │ ├── smsstepupsertdto.ts │ │ │ │ │ ├── snoozesubscribernotificationdto.ts │ │ │ │ │ ├── stepcontentissuedto.ts │ │ │ │ │ ├── stepexecutiondetaildto.ts │ │ │ │ │ ├── stepfilterdto.ts │ │ │ │ │ ├── stepintegrationissue.ts │ │ │ │ │ ├── stepissuesdto.ts │ │ │ │ │ ├── steplistresponsedto.ts │ │ │ │ │ ├── stepresponsedto.ts │ │ │ │ │ ├── steprundto.ts │ │ │ │ │ ├── stepsoverrides.ts │ │ │ │ │ ├── subscriberchanneldto.ts │ │ │ │ │ ├── subscriberdto.ts │ │ │ │ │ ├── subscriberfeedresponsedto.ts │ │ │ │ │ ├── subscriberglobalpreferencedto.ts │ │ │ │ │ ├── subscriberpayloaddto.ts │ │ │ │ │ ├── subscriberpreferencechannels.ts │ │ │ │ │ ├── subscriberpreferenceoverridedto.ts │ │ │ │ │ ├── subscriberpreferencesworkflowinfodto.ts │ │ │ │ │ ├── subscriberresponsedto.ts │ │ │ │ │ ├── subscriberresponsedtooptional.ts │ │ │ │ │ ├── subscriberworkflowpreferencedto.ts │ │ │ │ │ ├── subscriptiondetailsresponsedto.ts │ │ │ │ │ ├── subscriptiondto.ts │ │ │ │ │ ├── subscriptionerrordto.ts │ │ │ │ │ ├── subscriptionpreferencedto.ts │ │ │ │ │ ├── subscriptionresponsedto.ts │ │ │ │ │ ├── subscriptionsdeleteerrordto.ts │ │ │ │ │ ├── syncactionenum.ts │ │ │ │ │ ├── syncedworkflowdto.ts │ │ │ │ │ ├── syncresultdto.ts │ │ │ │ │ ├── syncworkflowdto.ts │ │ │ │ │ ├── tenantpayloaddto.ts │ │ │ │ │ ├── textalignenum.ts │ │ │ │ │ ├── throttlecontroldto.ts │ │ │ │ │ ├── throttlecontrolsmetadataresponsedto.ts │ │ │ │ │ ├── throttlestepresponsedto.ts │ │ │ │ │ ├── throttlestepupsertdto.ts │ │ │ │ │ ├── timedconfig.ts │ │ │ │ │ ├── timerangedto.ts │ │ │ │ │ ├── timeunitenum.ts │ │ │ │ │ ├── topicdto.ts │ │ │ │ │ ├── topicpayloaddto.ts │ │ │ │ │ ├── topicresponsedto.ts │ │ │ │ │ ├── topicsubscriberdto.ts │ │ │ │ │ ├── topicsubscriberidentifierdto.ts │ │ │ │ │ ├── topicsubscriptionresponsedto.ts │ │ │ │ │ ├── traceresponsedto.ts │ │ │ │ │ ├── translationgroupdto.ts │ │ │ │ │ ├── translationresponsedto.ts │ │ │ │ │ ├── triggereventrequestdto.ts │ │ │ │ │ ├── triggereventresponsedto.ts │ │ │ │ │ ├── triggereventtoallrequestdto.ts │ │ │ │ │ ├── triggerrecipientstypeenum.ts │ │ │ │ │ ├── uicomponentenum.ts │ │ │ │ │ ├── uischema.ts │ │ │ │ │ ├── uischemagroupenum.ts │ │ │ │ │ ├── uischemaproperty.ts │ │ │ │ │ ├── unseencountresponse.ts │ │ │ │ │ ├── updateallsubscribernotificationsdto.ts │ │ │ │ │ ├── updatechannelconnectionrequestdto.ts │ │ │ │ │ ├── updatechannelendpointrequestdto.ts │ │ │ │ │ ├── updatecontextrequestdto.ts │ │ │ │ │ ├── updatedsubscriberdto.ts │ │ │ │ │ ├── updateenvironmentrequestdto.ts │ │ │ │ │ ├── updateenvironmentvariablerequestdto.ts │ │ │ │ │ ├── updateintegrationrequestdto.ts │ │ │ │ │ ├── updatelayoutdto.ts │ │ │ │ │ ├── updatesubscriberchannelrequestdto.ts │ │ │ │ │ ├── updatesubscriberonlineflagrequestdto.ts │ │ │ │ │ ├── updatetopicrequestdto.ts │ │ │ │ │ ├── updatetopicsubscriptionrequestdto.ts │ │ │ │ │ ├── updateworkflowdto.ts │ │ │ │ │ ├── uploadtranslationsresponsedto.ts │ │ │ │ │ ├── webhookendpointdto.ts │ │ │ │ │ ├── webhookresultdto.ts │ │ │ │ │ ├── workflowcreationsourceenum.ts │ │ │ │ │ ├── workflowinfodto.ts │ │ │ │ │ ├── workflowlistresponsedto.ts │ │ │ │ │ ├── workflowpreferencedto.ts │ │ │ │ │ ├── workflowpreferencerequestdto.ts │ │ │ │ │ ├── workflowpreferencesdto.ts │ │ │ │ │ ├── workflowpreferencesresponsedto.ts │ │ │ │ │ ├── workflowresponse.ts │ │ │ │ │ ├── workflowresponsedto.ts │ │ │ │ │ ├── workflowresponsedtosortfield.ts │ │ │ │ │ ├── workflowrunstepsdetailsdto.ts │ │ │ │ │ ├── workflowstatusenum.ts │ │ │ │ │ └── workspacedto.ts │ │ │ │ ├── errors/ │ │ │ │ │ ├── errordto.ts │ │ │ │ │ ├── httpclienterrors.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── novuerror.ts │ │ │ │ │ ├── payloadvalidationexceptiondto.ts │ │ │ │ │ ├── responsevalidationerror.ts │ │ │ │ │ ├── sdkerror.ts │ │ │ │ │ ├── sdkvalidationerror.ts │ │ │ │ │ ├── subscriberresponsedto.ts │ │ │ │ │ ├── topicresponsedto.ts │ │ │ │ │ └── validationerrordto.ts │ │ │ │ └── operations/ │ │ │ │ ├── activitycontrollergetcharts.ts │ │ │ │ ├── activitycontrollergetlogs.ts │ │ │ │ ├── activitycontrollergetrequesttraces.ts │ │ │ │ ├── activitycontrollergetworkflowrun.ts │ │ │ │ ├── activitycontrollergetworkflowruns.ts │ │ │ │ ├── channelconnectionscontrollercreatechannelconnection.ts │ │ │ │ ├── channelconnectionscontrollerdeletechannelconnection.ts │ │ │ │ ├── channelconnectionscontrollergetchannelconnectionbyidentifier.ts │ │ │ │ ├── channelconnectionscontrollerlistchannelconnections.ts │ │ │ │ ├── channelconnectionscontrollerupdatechannelconnection.ts │ │ │ │ ├── channelendpointscontrollercreatechannelendpoint.ts │ │ │ │ ├── channelendpointscontrollerdeletechannelendpoint.ts │ │ │ │ ├── channelendpointscontrollergetchannelendpoint.ts │ │ │ │ ├── channelendpointscontrollerlistchannelendpoints.ts │ │ │ │ ├── channelendpointscontrollerupdatechannelendpoint.ts │ │ │ │ ├── contextscontrollercreatecontext.ts │ │ │ │ ├── contextscontrollerdeletecontext.ts │ │ │ │ ├── contextscontrollergetcontext.ts │ │ │ │ ├── contextscontrollerlistcontexts.ts │ │ │ │ ├── contextscontrollerupdatecontext.ts │ │ │ │ ├── environmentscontrollerdiffenvironment.ts │ │ │ │ ├── environmentscontrollergetenvironmenttags.ts │ │ │ │ ├── environmentscontrollerpublishenvironment.ts │ │ │ │ ├── environmentscontrollerv1createenvironment.ts │ │ │ │ ├── environmentscontrollerv1deleteenvironment.ts │ │ │ │ ├── environmentscontrollerv1listmyenvironments.ts │ │ │ │ ├── environmentscontrollerv1updatemyenvironment.ts │ │ │ │ ├── environmentvariablescontrollercreateenvironmentvariable.ts │ │ │ │ ├── environmentvariablescontrollerdeleteenvironmentvariable.ts │ │ │ │ ├── environmentvariablescontrollergetenvironmentvariable.ts │ │ │ │ ├── environmentvariablescontrollergetenvironmentvariableusage.ts │ │ │ │ ├── environmentvariablescontrollerlistenvironmentvariables.ts │ │ │ │ ├── environmentvariablescontrollerupdateenvironmentvariable.ts │ │ │ │ ├── eventscontrollerbroadcasteventtoall.ts │ │ │ │ ├── eventscontrollercancel.ts │ │ │ │ ├── eventscontrollertrigger.ts │ │ │ │ ├── eventscontrollertriggerbulk.ts │ │ │ │ ├── inboundwebhookscontrollerhandlewebhook.ts │ │ │ │ ├── index.ts │ │ │ │ ├── integrationscontrollerautoconfigureintegration.ts │ │ │ │ ├── integrationscontrollercreateintegration.ts │ │ │ │ ├── integrationscontrollergetactiveintegrations.ts │ │ │ │ ├── integrationscontrollergetchatoauthurl.ts │ │ │ │ ├── integrationscontrollerlistintegrations.ts │ │ │ │ ├── integrationscontrollerremoveintegration.ts │ │ │ │ ├── integrationscontrollersetintegrationasprimary.ts │ │ │ │ ├── integrationscontrollerupdateintegrationbyid.ts │ │ │ │ ├── layoutscontrollercreate.ts │ │ │ │ ├── layoutscontrollerdelete.ts │ │ │ │ ├── layoutscontrollerduplicate.ts │ │ │ │ ├── layoutscontrollergeneratepreview.ts │ │ │ │ ├── layoutscontrollerget.ts │ │ │ │ ├── layoutscontrollergetusage.ts │ │ │ │ ├── layoutscontrollerlist.ts │ │ │ │ ├── layoutscontrollerupdate.ts │ │ │ │ ├── messagescontrollerdeletemessage.ts │ │ │ │ ├── messagescontrollerdeletemessagesbytransactionid.ts │ │ │ │ ├── messagescontrollergetmessages.ts │ │ │ │ ├── notificationscontrollergetnotification.ts │ │ │ │ ├── notificationscontrollerlistnotifications.ts │ │ │ │ ├── subscriberscontrollerarchiveallnotifications.ts │ │ │ │ ├── subscriberscontrollerarchiveallreadnotifications.ts │ │ │ │ ├── subscriberscontrollerarchivenotification.ts │ │ │ │ ├── subscriberscontrollerbulkupdatesubscriberpreferences.ts │ │ │ │ ├── subscriberscontrollercompletenotificationaction.ts │ │ │ │ ├── subscriberscontrollercreatesubscriber.ts │ │ │ │ ├── subscriberscontrollerdeleteallnotifications.ts │ │ │ │ ├── subscriberscontrollerdeletenotification.ts │ │ │ │ ├── subscriberscontrollergetsubscriber.ts │ │ │ │ ├── subscriberscontrollergetsubscribernotifications.ts │ │ │ │ ├── subscriberscontrollergetsubscribernotificationscount.ts │ │ │ │ ├── subscriberscontrollergetsubscriberpreferences.ts │ │ │ │ ├── subscriberscontrollerlistsubscribertopics.ts │ │ │ │ ├── subscriberscontrollermarkallnotificationsasread.ts │ │ │ │ ├── subscriberscontrollermarknotificationasread.ts │ │ │ │ ├── subscriberscontrollermarknotificationasunread.ts │ │ │ │ ├── subscriberscontrollermarknotificationsasseen.ts │ │ │ │ ├── subscriberscontrollerpatchsubscriber.ts │ │ │ │ ├── subscriberscontrollerremovesubscriber.ts │ │ │ │ ├── subscriberscontrollerrevertnotificationaction.ts │ │ │ │ ├── subscriberscontrollersearchsubscribers.ts │ │ │ │ ├── subscriberscontrollersnoozenotification.ts │ │ │ │ ├── subscriberscontrollerunarchivenotification.ts │ │ │ │ ├── subscriberscontrollerunsnoozenotification.ts │ │ │ │ ├── subscriberscontrollerupdatesubscriberpreferences.ts │ │ │ │ ├── subscribersv1controllerbulkcreatesubscribers.ts │ │ │ │ ├── subscribersv1controllerdeletesubscribercredentials.ts │ │ │ │ ├── subscribersv1controllergetnotificationsfeed.ts │ │ │ │ ├── subscribersv1controllergetunseencount.ts │ │ │ │ ├── subscribersv1controllermarkactionasseen.ts │ │ │ │ ├── subscribersv1controllermarkallunreadasread.ts │ │ │ │ ├── subscribersv1controllermarkmessagesas.ts │ │ │ │ ├── subscribersv1controllermodifysubscriberchannel.ts │ │ │ │ ├── subscribersv1controllerupdatesubscriberchannel.ts │ │ │ │ ├── subscribersv1controllerupdatesubscriberonlineflag.ts │ │ │ │ ├── topicscontrollercreatetopicsubscriptions.ts │ │ │ │ ├── topicscontrollerdeletetopic.ts │ │ │ │ ├── topicscontrollerdeletetopicsubscriptions.ts │ │ │ │ ├── topicscontrollergettopic.ts │ │ │ │ ├── topicscontrollergettopicsubscription.ts │ │ │ │ ├── topicscontrollerlisttopics.ts │ │ │ │ ├── topicscontrollerlisttopicsubscriptions.ts │ │ │ │ ├── topicscontrollerupdatetopic.ts │ │ │ │ ├── topicscontrollerupdatetopicsubscription.ts │ │ │ │ ├── topicscontrollerupserttopic.ts │ │ │ │ ├── topicsv1controllergettopicsubscriber.ts │ │ │ │ ├── translationcontrollercreatetranslationendpoint.ts │ │ │ │ ├── translationcontrollerdeletetranslationendpoint.ts │ │ │ │ ├── translationcontrollerdeletetranslationgroupendpoint.ts │ │ │ │ ├── translationcontrollergetmasterjsonendpoint.ts │ │ │ │ ├── translationcontrollergetsingletranslation.ts │ │ │ │ ├── translationcontrollergettranslationgroupendpoint.ts │ │ │ │ ├── translationcontrollerimportmasterjsonendpoint.ts │ │ │ │ ├── translationcontrolleruploadmasterjsonendpoint.ts │ │ │ │ ├── translationcontrolleruploadtranslationfiles.ts │ │ │ │ ├── workflowcontrollercreate.ts │ │ │ │ ├── workflowcontrollerduplicateworkflow.ts │ │ │ │ ├── workflowcontrollergeneratepreview.ts │ │ │ │ ├── workflowcontrollergetworkflow.ts │ │ │ │ ├── workflowcontrollergetworkflowstepdata.ts │ │ │ │ ├── workflowcontrollerpatchworkflow.ts │ │ │ │ ├── workflowcontrollerremoveworkflow.ts │ │ │ │ ├── workflowcontrollersearchworkflows.ts │ │ │ │ ├── workflowcontrollersync.ts │ │ │ │ └── workflowcontrollerupdate.ts │ │ │ ├── react-query/ │ │ │ │ ├── _context.tsx │ │ │ │ ├── _types.ts │ │ │ │ ├── activityChartsRetrieve.core.ts │ │ │ │ ├── activityChartsRetrieve.ts │ │ │ │ ├── activityRequestsList.core.ts │ │ │ │ ├── activityRequestsList.ts │ │ │ │ ├── activityRequestsRetrieve.core.ts │ │ │ │ ├── activityRequestsRetrieve.ts │ │ │ │ ├── activityTrack.ts │ │ │ │ ├── activityWorkflowRunsList.core.ts │ │ │ │ ├── activityWorkflowRunsList.ts │ │ │ │ ├── activityWorkflowRunsRetrieve.core.ts │ │ │ │ ├── activityWorkflowRunsRetrieve.ts │ │ │ │ ├── cancel.ts │ │ │ │ ├── channelConnectionsCreate.ts │ │ │ │ ├── channelConnectionsDelete.ts │ │ │ │ ├── channelConnectionsList.core.ts │ │ │ │ ├── channelConnectionsList.ts │ │ │ │ ├── channelConnectionsRetrieve.core.ts │ │ │ │ ├── channelConnectionsRetrieve.ts │ │ │ │ ├── channelConnectionsUpdate.ts │ │ │ │ ├── channelEndpointsCreate.ts │ │ │ │ ├── channelEndpointsDelete.ts │ │ │ │ ├── channelEndpointsList.core.ts │ │ │ │ ├── channelEndpointsList.ts │ │ │ │ ├── channelEndpointsRetrieve.core.ts │ │ │ │ ├── channelEndpointsRetrieve.ts │ │ │ │ ├── channelEndpointsUpdate.ts │ │ │ │ ├── contextsCreate.ts │ │ │ │ ├── contextsDelete.ts │ │ │ │ ├── contextsList.core.ts │ │ │ │ ├── contextsList.ts │ │ │ │ ├── contextsRetrieve.core.ts │ │ │ │ ├── contextsRetrieve.ts │ │ │ │ ├── contextsUpdate.ts │ │ │ │ ├── environmentVariablesCreate.ts │ │ │ │ ├── environmentVariablesDelete.ts │ │ │ │ ├── environmentVariablesList.core.ts │ │ │ │ ├── environmentVariablesList.ts │ │ │ │ ├── environmentVariablesRetrieve.core.ts │ │ │ │ ├── environmentVariablesRetrieve.ts │ │ │ │ ├── environmentVariablesUpdate.ts │ │ │ │ ├── environmentVariablesUsage.core.ts │ │ │ │ ├── environmentVariablesUsage.ts │ │ │ │ ├── environmentsCreate.ts │ │ │ │ ├── environmentsDelete.ts │ │ │ │ ├── environmentsDiff.ts │ │ │ │ ├── environmentsGetTags.core.ts │ │ │ │ ├── environmentsGetTags.ts │ │ │ │ ├── environmentsList.core.ts │ │ │ │ ├── environmentsList.ts │ │ │ │ ├── environmentsPublish.ts │ │ │ │ ├── environmentsUpdate.ts │ │ │ │ ├── index.ts │ │ │ │ ├── integrationsCreate.ts │ │ │ │ ├── integrationsDelete.ts │ │ │ │ ├── integrationsGenerateChatOAuthUrl.ts │ │ │ │ ├── integrationsIntegrationsControllerAutoConfigureIntegration.ts │ │ │ │ ├── integrationsList.core.ts │ │ │ │ ├── integrationsList.ts │ │ │ │ ├── integrationsListActive.core.ts │ │ │ │ ├── integrationsListActive.ts │ │ │ │ ├── integrationsSetAsPrimary.ts │ │ │ │ ├── integrationsUpdate.ts │ │ │ │ ├── layoutsCreate.ts │ │ │ │ ├── layoutsDelete.ts │ │ │ │ ├── layoutsDuplicate.ts │ │ │ │ ├── layoutsGeneratePreview.ts │ │ │ │ ├── layoutsList.core.ts │ │ │ │ ├── layoutsList.ts │ │ │ │ ├── layoutsRetrieve.core.ts │ │ │ │ ├── layoutsRetrieve.ts │ │ │ │ ├── layoutsUpdate.ts │ │ │ │ ├── layoutsUsage.core.ts │ │ │ │ ├── layoutsUsage.ts │ │ │ │ ├── messagesDelete.ts │ │ │ │ ├── messagesDeleteByTransactionId.ts │ │ │ │ ├── messagesRetrieve.core.ts │ │ │ │ ├── messagesRetrieve.ts │ │ │ │ ├── notificationsList.core.ts │ │ │ │ ├── notificationsList.ts │ │ │ │ ├── notificationsRetrieve.core.ts │ │ │ │ ├── notificationsRetrieve.ts │ │ │ │ ├── subscribersCreate.ts │ │ │ │ ├── subscribersCreateBulk.ts │ │ │ │ ├── subscribersCredentialsAppend.ts │ │ │ │ ├── subscribersCredentialsDelete.ts │ │ │ │ ├── subscribersCredentialsUpdate.ts │ │ │ │ ├── subscribersDelete.ts │ │ │ │ ├── subscribersMessagesMarkAll.ts │ │ │ │ ├── subscribersMessagesMarkAllAs.ts │ │ │ │ ├── subscribersMessagesUpdateAsSeen.ts │ │ │ │ ├── subscribersNotificationsArchive.ts │ │ │ │ ├── subscribersNotificationsArchiveAll.ts │ │ │ │ ├── subscribersNotificationsArchiveAllRead.ts │ │ │ │ ├── subscribersNotificationsCompleteAction.ts │ │ │ │ ├── subscribersNotificationsCount.core.ts │ │ │ │ ├── subscribersNotificationsCount.ts │ │ │ │ ├── subscribersNotificationsDelete.ts │ │ │ │ ├── subscribersNotificationsDeleteAll.ts │ │ │ │ ├── subscribersNotificationsFeed.core.ts │ │ │ │ ├── subscribersNotificationsFeed.ts │ │ │ │ ├── subscribersNotificationsList.core.ts │ │ │ │ ├── subscribersNotificationsList.ts │ │ │ │ ├── subscribersNotificationsMarkAllAsRead.ts │ │ │ │ ├── subscribersNotificationsMarkAsRead.ts │ │ │ │ ├── subscribersNotificationsMarkAsSeen.ts │ │ │ │ ├── subscribersNotificationsMarkAsUnread.ts │ │ │ │ ├── subscribersNotificationsRevertAction.ts │ │ │ │ ├── subscribersNotificationsSnooze.ts │ │ │ │ ├── subscribersNotificationsUnarchive.ts │ │ │ │ ├── subscribersNotificationsUnseenCount.core.ts │ │ │ │ ├── subscribersNotificationsUnseenCount.ts │ │ │ │ ├── subscribersNotificationsUnsnooze.ts │ │ │ │ ├── subscribersPatch.ts │ │ │ │ ├── subscribersPreferencesBulkUpdate.ts │ │ │ │ ├── subscribersPreferencesList.core.ts │ │ │ │ ├── subscribersPreferencesList.ts │ │ │ │ ├── subscribersPreferencesUpdate.ts │ │ │ │ ├── subscribersPropertiesUpdateOnlineFlag.ts │ │ │ │ ├── subscribersRetrieve.core.ts │ │ │ │ ├── subscribersRetrieve.ts │ │ │ │ ├── subscribersSearch.core.ts │ │ │ │ ├── subscribersSearch.ts │ │ │ │ ├── subscribersTopicsList.core.ts │ │ │ │ ├── subscribersTopicsList.ts │ │ │ │ ├── topicsCreate.ts │ │ │ │ ├── topicsDelete.ts │ │ │ │ ├── topicsGet.core.ts │ │ │ │ ├── topicsGet.ts │ │ │ │ ├── topicsList.core.ts │ │ │ │ ├── topicsList.ts │ │ │ │ ├── topicsSubscribersRetrieve.core.ts │ │ │ │ ├── topicsSubscribersRetrieve.ts │ │ │ │ ├── topicsSubscriptionsCreate.ts │ │ │ │ ├── topicsSubscriptionsDelete.ts │ │ │ │ ├── topicsSubscriptionsGetSubscription.core.ts │ │ │ │ ├── topicsSubscriptionsGetSubscription.ts │ │ │ │ ├── topicsSubscriptionsList.core.ts │ │ │ │ ├── topicsSubscriptionsList.ts │ │ │ │ ├── topicsSubscriptionsUpdate.ts │ │ │ │ ├── topicsUpdate.ts │ │ │ │ ├── translationsCreate.ts │ │ │ │ ├── translationsDelete.ts │ │ │ │ ├── translationsGroupsDelete.ts │ │ │ │ ├── translationsGroupsRetrieve.core.ts │ │ │ │ ├── translationsGroupsRetrieve.ts │ │ │ │ ├── translationsMasterImport.ts │ │ │ │ ├── translationsMasterRetrieve.core.ts │ │ │ │ ├── translationsMasterRetrieve.ts │ │ │ │ ├── translationsMasterUpload.ts │ │ │ │ ├── translationsRetrieve.core.ts │ │ │ │ ├── translationsRetrieve.ts │ │ │ │ ├── translationsUpload.ts │ │ │ │ ├── trigger.ts │ │ │ │ ├── triggerBroadcast.ts │ │ │ │ ├── triggerBulk.ts │ │ │ │ ├── workflowsCreate.ts │ │ │ │ ├── workflowsDelete.ts │ │ │ │ ├── workflowsDuplicate.ts │ │ │ │ ├── workflowsGet.core.ts │ │ │ │ ├── workflowsGet.ts │ │ │ │ ├── workflowsList.core.ts │ │ │ │ ├── workflowsList.ts │ │ │ │ ├── workflowsPatch.ts │ │ │ │ ├── workflowsStepsGeneratePreview.ts │ │ │ │ ├── workflowsStepsRetrieve.core.ts │ │ │ │ ├── workflowsStepsRetrieve.ts │ │ │ │ ├── workflowsSync.ts │ │ │ │ └── workflowsUpdate.ts │ │ │ ├── sdk/ │ │ │ │ ├── activity.ts │ │ │ │ ├── channelconnections.ts │ │ │ │ ├── channelendpoints.ts │ │ │ │ ├── charts.ts │ │ │ │ ├── contexts.ts │ │ │ │ ├── credentials.ts │ │ │ │ ├── environments.ts │ │ │ │ ├── environmentvariables.ts │ │ │ │ ├── groups.ts │ │ │ │ ├── index.ts │ │ │ │ ├── integrations.ts │ │ │ │ ├── layouts.ts │ │ │ │ ├── master.ts │ │ │ │ ├── messages.ts │ │ │ │ ├── notifications.ts │ │ │ │ ├── novumessages.ts │ │ │ │ ├── novunotifications.ts │ │ │ │ ├── novusubscribers.ts │ │ │ │ ├── novutopics.ts │ │ │ │ ├── preferences.ts │ │ │ │ ├── properties.ts │ │ │ │ ├── requests.ts │ │ │ │ ├── sdk.ts │ │ │ │ ├── steps.ts │ │ │ │ ├── subscribers.ts │ │ │ │ ├── subscriptions.ts │ │ │ │ ├── topics.ts │ │ │ │ ├── translations.ts │ │ │ │ ├── workflowruns.ts │ │ │ │ └── workflows.ts │ │ │ └── types/ │ │ │ ├── async.ts │ │ │ ├── blobs.ts │ │ │ ├── constdatetime.ts │ │ │ ├── discriminatedUnion.ts │ │ │ ├── enums.ts │ │ │ ├── fp.ts │ │ │ ├── index.ts │ │ │ ├── operations.ts │ │ │ ├── rfcdate.ts │ │ │ ├── streams.ts │ │ │ └── unrecognized.ts │ │ └── tsconfig.json │ ├── maily-core/ │ │ ├── package.json │ │ ├── postcss.config.cjs │ │ ├── readme.md │ │ ├── src/ │ │ │ ├── blocks/ │ │ │ │ ├── button.tsx │ │ │ │ ├── code.tsx │ │ │ │ ├── footers.tsx │ │ │ │ ├── headers.tsx │ │ │ │ ├── image.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── list.tsx │ │ │ │ ├── types.ts │ │ │ │ └── typography.tsx │ │ │ ├── blocks.ts │ │ │ ├── editor/ │ │ │ │ ├── bubble-suggestions/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── providers/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── inline-decorator-provider.ts │ │ │ │ │ │ └── variable-provider.ts │ │ │ │ │ ├── suggestion-input.tsx │ │ │ │ │ ├── suggestion-provider.ts │ │ │ │ │ ├── suggestion-registry.ts │ │ │ │ │ └── use-suggestion-providers.ts │ │ │ │ ├── components/ │ │ │ │ │ ├── alignment-switch.tsx │ │ │ │ │ ├── base-button.tsx │ │ │ │ │ ├── bubble-menu-button.tsx │ │ │ │ │ ├── column-menu/ │ │ │ │ │ │ ├── columns-bubble-menu-content.tsx │ │ │ │ │ │ ├── columns-bubble-menu.tsx │ │ │ │ │ │ ├── columns-width-config.tsx │ │ │ │ │ │ ├── columns-width.tsx │ │ │ │ │ │ └── use-columns-state.tsx │ │ │ │ │ ├── content-menu.tsx │ │ │ │ │ ├── editor-menu-bar.tsx │ │ │ │ │ ├── html-menu/ │ │ │ │ │ │ ├── html-menu.tsx │ │ │ │ │ │ └── use-html-state.ts │ │ │ │ │ ├── icons/ │ │ │ │ │ │ ├── border-color.tsx │ │ │ │ │ │ ├── grid-lines.tsx │ │ │ │ │ │ ├── logo-with-cover-image.tsx │ │ │ │ │ │ ├── logo-with-text-horizon.tsx │ │ │ │ │ │ ├── logo-with-text-vertical.tsx │ │ │ │ │ │ ├── margin-icon.tsx │ │ │ │ │ │ └── padding-icon.tsx │ │ │ │ │ ├── image-menu/ │ │ │ │ │ │ ├── image-bubble-menu.tsx │ │ │ │ │ │ ├── image-size.tsx │ │ │ │ │ │ ├── lock-aspect-ratio-button.tsx │ │ │ │ │ │ └── use-image-state.tsx │ │ │ │ │ ├── inline-image-menu/ │ │ │ │ │ │ ├── inline-image-bubble-menu.tsx │ │ │ │ │ │ └── use-inline-image-state.tsx │ │ │ │ │ ├── input.tsx │ │ │ │ │ ├── popover.tsx │ │ │ │ │ ├── repeat-menu/ │ │ │ │ │ │ ├── repeat-bubble-menu.tsx │ │ │ │ │ │ └── use-repeat-state.ts │ │ │ │ │ ├── section-menu/ │ │ │ │ │ │ ├── section-bubble-menu.tsx │ │ │ │ │ │ └── use-section-state.tsx │ │ │ │ │ ├── show-popover.tsx │ │ │ │ │ ├── spacer-menu/ │ │ │ │ │ │ ├── spacer-bubble-menu.tsx │ │ │ │ │ │ └── use-spacer-state.ts │ │ │ │ │ ├── text-menu/ │ │ │ │ │ │ ├── text-bubble-content.tsx │ │ │ │ │ │ ├── text-bubble-menu.tsx │ │ │ │ │ │ ├── turn-into-block.tsx │ │ │ │ │ │ ├── use-text-menu-state.tsx │ │ │ │ │ │ └── use-turn-into-block-options.tsx │ │ │ │ │ ├── textarea.tsx │ │ │ │ │ ├── ui/ │ │ │ │ │ │ ├── color-picker.tsx │ │ │ │ │ │ ├── divider.tsx │ │ │ │ │ │ ├── dropdown-menu.tsx │ │ │ │ │ │ ├── edge-spacing-controls.tsx │ │ │ │ │ │ ├── input-autocomplete.tsx │ │ │ │ │ │ ├── link-input-popover.tsx │ │ │ │ │ │ ├── number-input.tsx │ │ │ │ │ │ ├── select.tsx │ │ │ │ │ │ └── tooltip.tsx │ │ │ │ │ ├── variable-menu/ │ │ │ │ │ │ └── variable-bubble-menu.tsx │ │ │ │ │ └── vertical-alignment-switch.tsx │ │ │ │ ├── extensions/ │ │ │ │ │ ├── color.ts │ │ │ │ │ ├── horizontal-rule.tsx │ │ │ │ │ ├── image-upload/ │ │ │ │ │ │ └── image-upload.ts │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── inline-decorator/ │ │ │ │ │ │ ├── default-decorator-component.tsx │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── inline-decorator-list.tsx │ │ │ │ │ │ └── inline-decorator.ts │ │ │ │ │ ├── link-card.ts │ │ │ │ │ ├── maily-kit.tsx │ │ │ │ │ ├── placeholder.ts │ │ │ │ │ ├── slash-command/ │ │ │ │ │ │ ├── default-slash-commands.tsx │ │ │ │ │ │ ├── slash-command-item.tsx │ │ │ │ │ │ ├── slash-command-search.tsx │ │ │ │ │ │ ├── slash-command-view.tsx │ │ │ │ │ │ └── slash-command.ts │ │ │ │ │ └── tailing-node/ │ │ │ │ │ └── tailing-node.ts │ │ │ │ ├── index.tsx │ │ │ │ ├── nodes/ │ │ │ │ │ ├── button/ │ │ │ │ │ │ ├── button-label-input.tsx │ │ │ │ │ │ ├── button-view.tsx │ │ │ │ │ │ └── button.tsx │ │ │ │ │ ├── columns/ │ │ │ │ │ │ ├── column.ts │ │ │ │ │ │ └── columns.ts │ │ │ │ │ ├── footer.ts │ │ │ │ │ ├── heading/ │ │ │ │ │ │ └── heading.ts │ │ │ │ │ ├── html/ │ │ │ │ │ │ ├── html-view.tsx │ │ │ │ │ │ └── html.tsx │ │ │ │ │ ├── image/ │ │ │ │ │ │ ├── image-view.tsx │ │ │ │ │ │ └── image.ts │ │ │ │ │ ├── inline-image/ │ │ │ │ │ │ └── inline-image.tsx │ │ │ │ │ ├── link-card.tsx │ │ │ │ │ ├── link.ts │ │ │ │ │ ├── logo/ │ │ │ │ │ │ ├── logo-view.tsx │ │ │ │ │ │ └── logo.ts │ │ │ │ │ ├── paragraph/ │ │ │ │ │ │ └── paragraph.ts │ │ │ │ │ ├── repeat/ │ │ │ │ │ │ ├── repeat-view.tsx │ │ │ │ │ │ └── repeat.ts │ │ │ │ │ ├── section/ │ │ │ │ │ │ └── section.ts │ │ │ │ │ ├── spacer.ts │ │ │ │ │ └── variable/ │ │ │ │ │ ├── variable-suggestions-popover.tsx │ │ │ │ │ ├── variable-suggestions.tsx │ │ │ │ │ ├── variable-view.tsx │ │ │ │ │ └── variable.ts │ │ │ │ ├── plugins/ │ │ │ │ │ ├── drag-handle/ │ │ │ │ │ │ ├── drag-handle-plugin.ts │ │ │ │ │ │ └── drag-handle.tsx │ │ │ │ │ └── image-upload/ │ │ │ │ │ └── image-upload-plugin.ts │ │ │ │ ├── provider.tsx │ │ │ │ └── utils/ │ │ │ │ ├── aspect-ratio.ts │ │ │ │ ├── border-radius.ts │ │ │ │ ├── classname.ts │ │ │ │ ├── columns.ts │ │ │ │ ├── constants.ts │ │ │ │ ├── delete-node.ts │ │ │ │ ├── get-render-container.ts │ │ │ │ ├── is-custom-node-selected.ts │ │ │ │ ├── is-text-selected.ts │ │ │ │ ├── node-options.ts │ │ │ │ ├── replace-deprecated.ts │ │ │ │ ├── spacing.ts │ │ │ │ ├── update-attribute.ts │ │ │ │ ├── update-scroll-view.ts │ │ │ │ ├── use-event.ts │ │ │ │ ├── use-outside-click.ts │ │ │ │ └── variable.ts │ │ │ ├── extensions.ts │ │ │ ├── index.ts │ │ │ └── styles/ │ │ │ ├── index.css │ │ │ ├── preflight.css │ │ │ └── tailwind.css │ │ ├── tailwind.config.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── maily-render/ │ │ ├── .babelrc │ │ ├── package.json │ │ ├── readme.md │ │ ├── src/ │ │ │ ├── index.ts │ │ │ ├── maily.tsx │ │ │ ├── meta.tsx │ │ │ ├── render.test.ts │ │ │ ├── render.ts │ │ │ └── utils.ts │ │ ├── tsconfig.json │ │ ├── tsup.config.ts │ │ └── vitest.config.ts │ ├── maily-tailwind-config/ │ │ ├── package.json │ │ └── tailwind.config.ts │ ├── maily-tsconfig/ │ │ ├── base.json │ │ ├── nextjs.json │ │ ├── package.json │ │ └── react-library.json │ ├── notifications/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── package.json │ │ ├── project.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ ├── templates/ │ │ │ │ └── layout.tsx │ │ │ └── workflows/ │ │ │ ├── usage-limits/ │ │ │ │ ├── email.tsx │ │ │ │ └── usage-limits.workflow.ts │ │ │ └── usage-report/ │ │ │ ├── email.tsx │ │ │ ├── schemas.ts │ │ │ └── usage-report.workflow.ts │ │ ├── tsconfig.json │ │ └── tsconfig.module.json │ └── testing/ │ ├── .dockerignore │ ├── .gitignore │ ├── package.json │ ├── project.json │ ├── src/ │ │ ├── constants.ts │ │ ├── create-notification-template.interface.ts │ │ ├── ee/ │ │ │ ├── clerk-client.mock.ts │ │ │ ├── clerk-mock-data.ts │ │ │ ├── ee.organization.service.ts │ │ │ ├── ee.repository.factory.ts │ │ │ ├── ee.user.service.ts │ │ │ └── types.ts │ │ ├── environment.service.ts │ │ ├── index.ts │ │ ├── integration.service.ts │ │ ├── jobs.service.ts │ │ ├── notification-template.service.ts │ │ ├── notifications.service.ts │ │ ├── organization.service.ts │ │ ├── subscribers.service.ts │ │ ├── test-server.service.ts │ │ ├── testing-queue.service.ts │ │ ├── user.service.ts │ │ ├── user.session.ts │ │ ├── utils/ │ │ │ ├── index.ts │ │ │ └── processTestAgentExpectedStatusCode.ts │ │ └── workflow-override.service.ts │ ├── tsconfig.build.json │ └── tsconfig.json ├── novu.code-workspace ├── nx.json ├── package.json ├── packages/ │ ├── add-inbox/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── package.json │ │ ├── project.json │ │ ├── src/ │ │ │ ├── cli/ │ │ │ │ └── index.ts │ │ │ ├── config/ │ │ │ │ ├── framework.ts │ │ │ │ ├── package-manager.spec.ts │ │ │ │ └── package-manager.ts │ │ │ ├── constants/ │ │ │ │ └── index.ts │ │ │ ├── generators/ │ │ │ │ ├── component.ts │ │ │ │ ├── env.ts │ │ │ │ ├── frameworks/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── nextjs.ts │ │ │ │ │ └── react.ts │ │ │ │ └── react-version.ts │ │ │ └── utils/ │ │ │ ├── analytics.ts │ │ │ ├── file.ts │ │ │ └── logger.ts │ │ ├── tsconfig.json │ │ └── vitest.config.js │ ├── agent-toolkit/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── ai-sdk/ │ │ │ │ ├── index.ts │ │ │ │ └── tool-converter.ts │ │ │ ├── core/ │ │ │ │ ├── index.ts │ │ │ │ ├── novu-tool.ts │ │ │ │ ├── novu-toolkit.ts │ │ │ │ └── types.ts │ │ │ ├── human-in-the-loop/ │ │ │ │ ├── index.ts │ │ │ │ └── types.ts │ │ │ ├── index.ts │ │ │ ├── langchain/ │ │ │ │ ├── index.ts │ │ │ │ └── tool-converter.ts │ │ │ ├── openai/ │ │ │ │ ├── index.ts │ │ │ │ └── tool-converter.ts │ │ │ └── tools/ │ │ │ ├── index.ts │ │ │ ├── preferences.ts │ │ │ ├── trigger-workflow.ts │ │ │ └── workflows-as-tools.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── framework/ │ │ ├── .gitignore │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── express/ │ │ │ └── package.json │ │ ├── h3/ │ │ │ └── package.json │ │ ├── internal/ │ │ │ └── package.json │ │ ├── lambda/ │ │ │ └── package.json │ │ ├── nest/ │ │ │ └── package.json │ │ ├── next/ │ │ │ └── package.json │ │ ├── nuxt/ │ │ │ └── package.json │ │ ├── package.json │ │ ├── project.json │ │ ├── remix/ │ │ │ └── package.json │ │ ├── scripts/ │ │ │ ├── INSTRUCTIONS.md │ │ │ ├── devtool.ts │ │ │ ├── schema_input.json │ │ │ └── schema_output.json │ │ ├── src/ │ │ │ ├── client.test.ts │ │ │ ├── client.ts │ │ │ ├── client.validation.test.ts │ │ │ ├── constants/ │ │ │ │ ├── action.constants.ts │ │ │ │ ├── api.constants.ts │ │ │ │ ├── cron.constants.ts │ │ │ │ ├── error.constants.ts │ │ │ │ ├── http-headers.constants.ts │ │ │ │ ├── http-methods.constants.ts │ │ │ │ ├── http-query.constants.ts │ │ │ │ ├── http-status.constants.ts │ │ │ │ ├── index.ts │ │ │ │ ├── resource.constants.ts │ │ │ │ ├── step.constants.ts │ │ │ │ └── workflow.constants.ts │ │ │ ├── errors/ │ │ │ │ ├── base.errors.ts │ │ │ │ ├── bridge.errors.ts │ │ │ │ ├── execution.errors.ts │ │ │ │ ├── guard.errors.test.ts │ │ │ │ ├── guard.errors.ts │ │ │ │ ├── handler.errors.ts │ │ │ │ ├── import.errors.ts │ │ │ │ ├── index.ts │ │ │ │ ├── platform.errors.ts │ │ │ │ ├── provider.errors.ts │ │ │ │ ├── resource.errors.ts │ │ │ │ ├── signature.errors.ts │ │ │ │ ├── step.errors.ts │ │ │ │ └── workflow.errors.ts │ │ │ ├── filters/ │ │ │ │ ├── digest.ts │ │ │ │ ├── index.ts │ │ │ │ ├── pluralize.test.ts │ │ │ │ ├── pluralize.ts │ │ │ │ ├── to-sentence.test.ts │ │ │ │ ├── to-sentence.ts │ │ │ │ ├── types.ts │ │ │ │ └── validators.ts │ │ │ ├── globals.d.ts │ │ │ ├── handler.test.ts │ │ │ ├── handler.ts │ │ │ ├── index.ts │ │ │ ├── internal/ │ │ │ │ └── index.ts │ │ │ ├── jsonSchemaFaker.js │ │ │ ├── resources/ │ │ │ │ ├── index.ts │ │ │ │ ├── step-resolver/ │ │ │ │ │ └── step.ts │ │ │ │ └── workflow/ │ │ │ │ ├── discover-action-step-factory.ts │ │ │ │ ├── discover-channel-step-factory.ts │ │ │ │ ├── discover-custom-step-factory.ts │ │ │ │ ├── discover-providers.ts │ │ │ │ ├── discover-step.ts │ │ │ │ ├── index.ts │ │ │ │ ├── map-preferences.test.ts │ │ │ │ ├── map-preferences.ts │ │ │ │ ├── pretty-print-discovery.ts │ │ │ │ ├── workflow.resource.test-d.ts │ │ │ │ ├── workflow.resource.ts │ │ │ │ └── workflow.test.ts │ │ │ ├── schemas/ │ │ │ │ ├── index.ts │ │ │ │ ├── providers/ │ │ │ │ │ ├── chat/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── slack.schema.ts │ │ │ │ │ ├── email/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── mailgun.schema.ts │ │ │ │ │ │ ├── mailjet.schema.ts │ │ │ │ │ │ ├── nodemailer.schema.ts │ │ │ │ │ │ ├── novu-email.schema.ts │ │ │ │ │ │ └── sendgrid.schema.ts │ │ │ │ │ ├── generic.schema.ts │ │ │ │ │ ├── inApp/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── novu-inapp.schema.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── push/ │ │ │ │ │ │ ├── apns.schema.ts │ │ │ │ │ │ ├── expo.schema.ts │ │ │ │ │ │ ├── fcm.schema.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── one-signal.schema.ts │ │ │ │ │ └── sms/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── novu-sms.schema.ts │ │ │ │ │ └── twilio.schema.ts │ │ │ │ └── steps/ │ │ │ │ ├── actions/ │ │ │ │ │ ├── delay.schema.ts │ │ │ │ │ ├── digest.schema.test.ts │ │ │ │ │ ├── digest.schema.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── throttle.schema.ts │ │ │ │ ├── channels/ │ │ │ │ │ ├── chat.schema.ts │ │ │ │ │ ├── email.schema.ts │ │ │ │ │ ├── in-app.schema.test.ts │ │ │ │ │ ├── in-app.schema.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── push.schema.ts │ │ │ │ │ └── sms.schema.ts │ │ │ │ ├── empty.schema.ts │ │ │ │ ├── index.ts │ │ │ │ └── trigger.schema.ts │ │ │ ├── servers/ │ │ │ │ ├── express.ts │ │ │ │ ├── h3.ts │ │ │ │ ├── lambda.ts │ │ │ │ ├── nest/ │ │ │ │ │ ├── nest.client.ts │ │ │ │ │ ├── nest.constants.ts │ │ │ │ │ ├── nest.controller.ts │ │ │ │ │ ├── nest.handler.ts │ │ │ │ │ ├── nest.interface.ts │ │ │ │ │ ├── nest.module-definition.ts │ │ │ │ │ ├── nest.module.ts │ │ │ │ │ ├── nest.register-api-path.ts │ │ │ │ │ └── nest.utils.ts │ │ │ │ ├── nest.ts │ │ │ │ ├── next.ts │ │ │ │ ├── nuxt.ts │ │ │ │ ├── remix.ts │ │ │ │ └── sveltekit.ts │ │ │ ├── shared.ts │ │ │ ├── step-resolver.ts │ │ │ ├── types/ │ │ │ │ ├── code.types.ts │ │ │ │ ├── config.types.ts │ │ │ │ ├── context.types.ts │ │ │ │ ├── discover.types.ts │ │ │ │ ├── environment.types.ts │ │ │ │ ├── error.types.ts │ │ │ │ ├── errors.types.test.ts │ │ │ │ ├── event.types.ts │ │ │ │ ├── execution.types.ts │ │ │ │ ├── health-check.types.ts │ │ │ │ ├── import.types.ts │ │ │ │ ├── index.ts │ │ │ │ ├── provider.types.ts │ │ │ │ ├── schema.types/ │ │ │ │ │ ├── base.schema.types.test-d.ts │ │ │ │ │ ├── base.schema.types.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── json.schema.types.test-d.ts │ │ │ │ │ ├── json.schema.types.ts │ │ │ │ │ ├── zod.schema.types.test-d.ts │ │ │ │ │ └── zod.schema.types.ts │ │ │ │ ├── server.types.ts │ │ │ │ ├── skip.types.ts │ │ │ │ ├── step.types.ts │ │ │ │ ├── subscriber.types.ts │ │ │ │ ├── util.types.test-d.ts │ │ │ │ ├── util.types.ts │ │ │ │ ├── validator.types.ts │ │ │ │ └── workflow.types.ts │ │ │ ├── utils/ │ │ │ │ ├── clone.utils.test.ts │ │ │ │ ├── clone.utils.ts │ │ │ │ ├── crypto.utils.test.ts │ │ │ │ ├── crypto.utils.ts │ │ │ │ ├── deepmerge.utils.test.ts │ │ │ │ ├── env.utils.test.ts │ │ │ │ ├── env.utils.ts │ │ │ │ ├── http.utils.ts │ │ │ │ ├── import.utils.test.ts │ │ │ │ ├── import.utils.ts │ │ │ │ ├── index.ts │ │ │ │ ├── liquid.utils.test.ts │ │ │ │ ├── liquid.utils.ts │ │ │ │ ├── log.utils.ts │ │ │ │ ├── normalize-controls.utils.test.ts │ │ │ │ ├── normalize-controls.utils.ts │ │ │ │ ├── object.utils.ts │ │ │ │ ├── options.utils.ts │ │ │ │ ├── platform.utils.ts │ │ │ │ ├── sanitize.utils.test.ts │ │ │ │ ├── sanitize.utils.ts │ │ │ │ ├── string.utils.test.ts │ │ │ │ └── string.utils.ts │ │ │ ├── validators/ │ │ │ │ ├── base.validator.ts │ │ │ │ ├── index.ts │ │ │ │ ├── json-schema.validator.ts │ │ │ │ ├── validator.test.ts │ │ │ │ └── zod.validator.ts │ │ │ └── validators.ts │ │ ├── step-resolver/ │ │ │ └── package.json │ │ ├── sveltekit/ │ │ │ └── package.json │ │ ├── tsconfig.json │ │ ├── tsup-debug.config.ts │ │ ├── tsup.config.ts │ │ ├── validators/ │ │ │ └── package.json │ │ └── vitest.config.ts │ ├── js/ │ │ ├── .gitignore │ │ ├── .vscode/ │ │ │ └── settings.json │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── internal/ │ │ │ └── package.json │ │ ├── jest.config.cjs │ │ ├── jest.setup.ts │ │ ├── package.cjs.json │ │ ├── package.esm.json │ │ ├── package.json │ │ ├── postcss.config.js │ │ ├── project.json │ │ ├── scripts/ │ │ │ ├── copy-package-json.sh │ │ │ └── size-limit.mjs │ │ ├── src/ │ │ │ ├── api/ │ │ │ │ ├── http-client.test.ts │ │ │ │ ├── http-client.ts │ │ │ │ ├── inbox-service.ts │ │ │ │ └── index.ts │ │ │ ├── base-module.test.ts │ │ │ ├── base-module.ts │ │ │ ├── cache/ │ │ │ │ ├── in-memory-cache.ts │ │ │ │ ├── index.ts │ │ │ │ ├── notifications-cache.test.ts │ │ │ │ ├── notifications-cache.ts │ │ │ │ ├── preferences-cache.ts │ │ │ │ ├── schedule-cache.ts │ │ │ │ ├── subscriptions-cache.ts │ │ │ │ └── types.ts │ │ │ ├── event-emitter/ │ │ │ │ ├── index.ts │ │ │ │ ├── novu-event-emitter.ts │ │ │ │ └── types.ts │ │ │ ├── global.d.ts │ │ │ ├── index.ts │ │ │ ├── notifications/ │ │ │ │ ├── helpers.ts │ │ │ │ ├── index.ts │ │ │ │ ├── notification.ts │ │ │ │ ├── notifications.ts │ │ │ │ ├── types.ts │ │ │ │ └── visibility-tracker.ts │ │ │ ├── novu.test.ts │ │ │ ├── novu.ts │ │ │ ├── preferences/ │ │ │ │ ├── helpers.ts │ │ │ │ ├── index.ts │ │ │ │ ├── preference-schedule.ts │ │ │ │ ├── preference.ts │ │ │ │ ├── preferences.ts │ │ │ │ ├── schedule.ts │ │ │ │ └── types.ts │ │ │ ├── session/ │ │ │ │ ├── index.ts │ │ │ │ ├── session.ts │ │ │ │ └── types.ts │ │ │ ├── subscriptions/ │ │ │ │ ├── helpers.ts │ │ │ │ ├── index.ts │ │ │ │ ├── subscription-preference.ts │ │ │ │ ├── subscription.ts │ │ │ │ ├── subscriptions.ts │ │ │ │ └── types.ts │ │ │ ├── types.ts │ │ │ ├── ui/ │ │ │ │ ├── api/ │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── useArchiveAll.ts │ │ │ │ │ │ ├── useArchiveAllRead.ts │ │ │ │ │ │ ├── useDeleteAll.ts │ │ │ │ │ │ ├── useNotifications.ts │ │ │ │ │ │ ├── usePreferences.ts │ │ │ │ │ │ ├── useReadAll.ts │ │ │ │ │ │ └── useSubscription.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── components/ │ │ │ │ │ ├── ExternalElementRenderer.tsx │ │ │ │ │ ├── Inbox.tsx │ │ │ │ │ ├── InboxTabs/ │ │ │ │ │ │ ├── InboxTab.tsx │ │ │ │ │ │ ├── InboxTabs.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Notification/ │ │ │ │ │ │ ├── DefaultNotification.tsx │ │ │ │ │ │ ├── NewMessagesCta.tsx │ │ │ │ │ │ ├── Notification.tsx │ │ │ │ │ │ ├── NotificationActions.tsx │ │ │ │ │ │ ├── NotificationList.tsx │ │ │ │ │ │ ├── NotificationListSkeleton.tsx │ │ │ │ │ │ ├── SnoozeDateTimePicker.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── Renderer.tsx │ │ │ │ │ ├── elements/ │ │ │ │ │ │ ├── Bell/ │ │ │ │ │ │ │ ├── Bell.tsx │ │ │ │ │ │ │ ├── DefaultBellContainer.tsx │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── Footer.tsx │ │ │ │ │ │ ├── Header/ │ │ │ │ │ │ │ ├── ActionsContainer.tsx │ │ │ │ │ │ │ ├── Header.tsx │ │ │ │ │ │ │ ├── MoreActionsDropdown.tsx │ │ │ │ │ │ │ ├── MoreActionsOptions.tsx │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── InboxStatus/ │ │ │ │ │ │ │ ├── InboxStatusDropdown.tsx │ │ │ │ │ │ │ ├── InboxStatusOptions.tsx │ │ │ │ │ │ │ └── constants.ts │ │ │ │ │ │ ├── Markdown.tsx │ │ │ │ │ │ ├── Preferences/ │ │ │ │ │ │ │ ├── ChannelRow.tsx │ │ │ │ │ │ │ ├── DayScheduleCopy.tsx │ │ │ │ │ │ │ ├── DefaultPreferences.tsx │ │ │ │ │ │ │ ├── GroupedPreferences.tsx │ │ │ │ │ │ │ ├── GroupedPreferencesRow.tsx │ │ │ │ │ │ │ ├── Preferences.tsx │ │ │ │ │ │ │ ├── PreferencesHeader.tsx │ │ │ │ │ │ │ ├── PreferencesListSkeleton.tsx │ │ │ │ │ │ │ ├── PreferencesRow.tsx │ │ │ │ │ │ │ ├── ScheduleRow.tsx │ │ │ │ │ │ │ ├── ScheduleTable.tsx │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ └── utils.ts │ │ │ │ │ │ ├── Root.tsx │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── primitives/ │ │ │ │ │ │ ├── Badge.tsx │ │ │ │ │ │ ├── Button.tsx │ │ │ │ │ │ ├── Checkbox.tsx │ │ │ │ │ │ ├── Collapsible.tsx │ │ │ │ │ │ ├── CopyToClipboard.tsx │ │ │ │ │ │ ├── DatePicker.tsx │ │ │ │ │ │ ├── Dropdown/ │ │ │ │ │ │ │ ├── DropdownContent.tsx │ │ │ │ │ │ │ ├── DropdownItem.tsx │ │ │ │ │ │ │ ├── DropdownRoot.tsx │ │ │ │ │ │ │ ├── DropdownTrigger.tsx │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── Input.tsx │ │ │ │ │ │ ├── Motion.tsx │ │ │ │ │ │ ├── Popover/ │ │ │ │ │ │ │ ├── PopoverClose.tsx │ │ │ │ │ │ │ ├── PopoverContent.tsx │ │ │ │ │ │ │ ├── PopoverRoot.tsx │ │ │ │ │ │ │ ├── PopoverTrigger.tsx │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ ├── Portal.tsx │ │ │ │ │ │ ├── Skeleton.tsx │ │ │ │ │ │ ├── Switch.tsx │ │ │ │ │ │ ├── Tabs/ │ │ │ │ │ │ │ ├── TabsContent.tsx │ │ │ │ │ │ │ ├── TabsList.tsx │ │ │ │ │ │ │ ├── TabsRoot.tsx │ │ │ │ │ │ │ ├── TabsTrigger.tsx │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ └── useKeyboardNavigation.ts │ │ │ │ │ │ ├── TimePicker.tsx │ │ │ │ │ │ ├── TimeSelect.tsx │ │ │ │ │ │ ├── Tooltip/ │ │ │ │ │ │ │ ├── TooltipContent.tsx │ │ │ │ │ │ │ ├── TooltipRoot.tsx │ │ │ │ │ │ │ ├── TooltipTrigger.tsx │ │ │ │ │ │ │ └── index.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── shared/ │ │ │ │ │ │ └── IconRendererWrapper.tsx │ │ │ │ │ └── subscription/ │ │ │ │ │ ├── EmptyState.tsx │ │ │ │ │ ├── NotSubscribedState.tsx │ │ │ │ │ ├── Subscription.tsx │ │ │ │ │ ├── SubscriptionButton.tsx │ │ │ │ │ ├── SubscriptionButtonWrapper.tsx │ │ │ │ │ ├── SubscriptionCog.tsx │ │ │ │ │ ├── SubscriptionPreferenceGroupRow.tsx │ │ │ │ │ ├── SubscriptionPreferenceRow.tsx │ │ │ │ │ ├── SubscriptionPreferences.tsx │ │ │ │ │ ├── SubscriptionPreferencesFallback.tsx │ │ │ │ │ ├── SubscriptionPreferencesListSkeleton.tsx │ │ │ │ │ └── SubscriptionPreferencesWrapper.tsx │ │ │ │ ├── config/ │ │ │ │ │ ├── appearanceKeys.ts │ │ │ │ │ ├── defaultLocalization.ts │ │ │ │ │ ├── defaultVariables.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── context/ │ │ │ │ │ ├── AppearanceContext.tsx │ │ │ │ │ ├── CountContext.tsx │ │ │ │ │ ├── FocusManagerContext.tsx │ │ │ │ │ ├── InboxContext.tsx │ │ │ │ │ ├── LocalizationContext.tsx │ │ │ │ │ ├── NovuContext.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── helpers/ │ │ │ │ │ ├── browser.ts │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── createDelayedLoading.ts │ │ │ │ │ ├── createInfiniteScroll.ts │ │ │ │ │ ├── formatToRelativeTime.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── mergeRefs.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ ├── useBrowserTabsChannel.ts │ │ │ │ │ ├── useFocusTrap.ts │ │ │ │ │ ├── useNotificationVisibility.ts │ │ │ │ │ ├── useNovuEvent.ts │ │ │ │ │ ├── useStyle.ts │ │ │ │ │ ├── useTabsDropdown.ts │ │ │ │ │ ├── useUncontrolledState.ts │ │ │ │ │ ├── useWebSocketEvent.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── icons/ │ │ │ │ │ ├── ArrowDown.tsx │ │ │ │ │ ├── ArrowDropDown.tsx │ │ │ │ │ ├── ArrowLeft.tsx │ │ │ │ │ ├── ArrowRight.tsx │ │ │ │ │ ├── ArrowUpRight.tsx │ │ │ │ │ ├── Bell.tsx │ │ │ │ │ ├── BellCross.tsx │ │ │ │ │ ├── BellPlus.tsx │ │ │ │ │ ├── CalendarSchedule.tsx │ │ │ │ │ ├── Chat.tsx │ │ │ │ │ ├── Check.tsx │ │ │ │ │ ├── Clock.tsx │ │ │ │ │ ├── Cogs.tsx │ │ │ │ │ ├── Copy.tsx │ │ │ │ │ ├── Dots.tsx │ │ │ │ │ ├── Email.tsx │ │ │ │ │ ├── InApp.tsx │ │ │ │ │ ├── Info.tsx │ │ │ │ │ ├── Key.tsx │ │ │ │ │ ├── Loader.tsx │ │ │ │ │ ├── Lock.tsx │ │ │ │ │ ├── MarkAsArchived.tsx │ │ │ │ │ ├── MarkAsArchivedRead.tsx │ │ │ │ │ ├── MarkAsRead.tsx │ │ │ │ │ ├── MarkAsUnarchived.tsx │ │ │ │ │ ├── MarkAsUnread.tsx │ │ │ │ │ ├── NodeTree.tsx │ │ │ │ │ ├── Novu.tsx │ │ │ │ │ ├── Push.tsx │ │ │ │ │ ├── RouteFill.tsx │ │ │ │ │ ├── Sms.tsx │ │ │ │ │ ├── Unread.tsx │ │ │ │ │ ├── Unsnooze.tsx │ │ │ │ │ └── index.ts │ │ │ │ ├── index.css │ │ │ │ ├── index.ts │ │ │ │ ├── internal/ │ │ │ │ │ ├── buildContextKey.ts │ │ │ │ │ ├── buildSubscriber.ts │ │ │ │ │ ├── buildSubscriptionIdentifier.ts │ │ │ │ │ ├── createNotification.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── parseMarkdown.tsx │ │ │ │ ├── novuUI.tsx │ │ │ │ ├── themes/ │ │ │ │ │ ├── dark.ts │ │ │ │ │ └── index.ts │ │ │ │ └── types.ts │ │ │ ├── umd.ts │ │ │ ├── utils/ │ │ │ │ ├── arrays.ts │ │ │ │ ├── errors.ts │ │ │ │ ├── is-browser.ts │ │ │ │ ├── notification-utils.ts │ │ │ │ └── strings.ts │ │ │ └── ws/ │ │ │ ├── base-socket.ts │ │ │ ├── index.ts │ │ │ ├── party-socket.ts │ │ │ ├── socket-factory.ts │ │ │ └── socket.ts │ │ ├── tailwind.config.js │ │ ├── test-sdk.ts │ │ ├── themes/ │ │ │ └── package.json │ │ ├── tsconfig.cjs.json │ │ ├── tsconfig.json │ │ ├── tsup.config.ts │ │ ├── ui/ │ │ │ └── package.json │ │ └── webpack.config.cjs │ ├── nextjs/ │ │ ├── .gitignore │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── hooks/ │ │ │ └── package.json │ │ ├── package.json │ │ ├── project.json │ │ ├── server/ │ │ │ └── package.json │ │ ├── src/ │ │ │ ├── app-router/ │ │ │ │ ├── Inbox.tsx │ │ │ │ ├── Subscription.tsx │ │ │ │ └── index.ts │ │ │ ├── hooks/ │ │ │ │ └── index.ts │ │ │ ├── pages-router/ │ │ │ │ ├── Inbox.tsx │ │ │ │ ├── Subscription.tsx │ │ │ │ └── index.ts │ │ │ ├── server/ │ │ │ │ └── index.ts │ │ │ └── themes/ │ │ │ └── index.ts │ │ ├── themes/ │ │ │ └── package.json │ │ ├── tsconfig.declarations.json │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── novu/ │ │ ├── .gitignore │ │ ├── CHANGELOG.md │ │ ├── README.MD │ │ ├── nodemon-debug.json │ │ ├── nodemon.json │ │ ├── package.json │ │ ├── project.json │ │ ├── src/ │ │ │ ├── client/ │ │ │ │ ├── cli.client.ts │ │ │ │ └── index.ts │ │ │ ├── commands/ │ │ │ │ ├── animation.ts │ │ │ │ ├── dev/ │ │ │ │ │ ├── dev.ts │ │ │ │ │ ├── enums.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── index.ts │ │ │ │ ├── init/ │ │ │ │ │ ├── create-app.ts │ │ │ │ │ ├── helpers/ │ │ │ │ │ │ ├── copy.ts │ │ │ │ │ │ ├── examples.ts │ │ │ │ │ │ ├── get-pkg-manager.ts │ │ │ │ │ │ ├── git.ts │ │ │ │ │ │ ├── install.ts │ │ │ │ │ │ ├── is-folder-empty.ts │ │ │ │ │ │ ├── is-online.ts │ │ │ │ │ │ ├── is-writeable.ts │ │ │ │ │ │ └── validate-pkg.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── templates/ │ │ │ │ │ ├── app/ │ │ │ │ │ │ └── ts/ │ │ │ │ │ │ └── app/ │ │ │ │ │ │ └── page.module.css │ │ │ │ │ ├── app-react-email/ │ │ │ │ │ │ └── ts/ │ │ │ │ │ │ ├── README-template.md │ │ │ │ │ │ ├── app/ │ │ │ │ │ │ │ ├── api/ │ │ │ │ │ │ │ │ ├── dev-studio-status/ │ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ │ ├── events/ │ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ │ ├── novu/ │ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ │ └── trigger/ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ │ └── NotificationToast/ │ │ │ │ │ │ │ │ ├── Notifications.module.css │ │ │ │ │ │ │ │ └── Notifications.tsx │ │ │ │ │ │ │ ├── globals.css │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ ├── novu/ │ │ │ │ │ │ │ │ ├── emails/ │ │ │ │ │ │ │ │ │ └── novu-onboarding-email.tsx │ │ │ │ │ │ │ │ └── workflows/ │ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ │ └── welcome-onboarding-email/ │ │ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ │ │ ├── schemas.ts │ │ │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ │ │ └── workflow.ts │ │ │ │ │ │ │ ├── page.module.css │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── eslintrc.json │ │ │ │ │ │ ├── gitignore │ │ │ │ │ │ ├── next-env.d.ts │ │ │ │ │ │ ├── next.config.mjs │ │ │ │ │ │ ├── postcss.config.cjs │ │ │ │ │ │ ├── tailwind.config.ts │ │ │ │ │ │ └── tsconfig.json │ │ │ │ │ ├── github/ │ │ │ │ │ │ └── workflows/ │ │ │ │ │ │ └── novu.yml │ │ │ │ │ ├── index.ts │ │ │ │ │ └── types.ts │ │ │ │ ├── shared.ts │ │ │ │ ├── step/ │ │ │ │ │ ├── __fixtures__/ │ │ │ │ │ │ └── templates/ │ │ │ │ │ │ ├── no-default-export.tsx │ │ │ │ │ │ ├── no-react-email.tsx │ │ │ │ │ │ ├── should-be-ignored.test.tsx │ │ │ │ │ │ ├── test-file.test.tsx │ │ │ │ │ │ ├── test-template.tsx │ │ │ │ │ │ └── valid-template.tsx │ │ │ │ │ ├── api/ │ │ │ │ │ │ ├── client.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ ├── bundler/ │ │ │ │ │ │ ├── bundler.spec.ts │ │ │ │ │ │ ├── bundler.ts │ │ │ │ │ │ ├── config.spec.ts │ │ │ │ │ │ ├── config.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── node-paths.ts │ │ │ │ │ │ └── schema-extractor.ts │ │ │ │ │ ├── config/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── loader.ts │ │ │ │ │ │ ├── schema.spec.ts │ │ │ │ │ │ └── schema.ts │ │ │ │ │ ├── discovery/ │ │ │ │ │ │ ├── email-template-discovery.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── step-discovery.spec.ts │ │ │ │ │ │ └── step-discovery.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── publish.ts │ │ │ │ │ ├── templates/ │ │ │ │ │ │ ├── __snapshots__/ │ │ │ │ │ │ │ ├── step-file.spec.ts.snap │ │ │ │ │ │ │ └── worker-wrapper.spec.ts.snap │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── step-file.spec.ts │ │ │ │ │ │ ├── step-file.ts │ │ │ │ │ │ ├── worker-wrapper.spec.ts │ │ │ │ │ │ └── worker-wrapper.ts │ │ │ │ │ ├── types.ts │ │ │ │ │ └── utils/ │ │ │ │ │ ├── environment.ts │ │ │ │ │ ├── file-paths.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── package-manager.ts │ │ │ │ │ ├── spinner.ts │ │ │ │ │ └── table.ts │ │ │ │ ├── sync.spec.ts │ │ │ │ ├── sync.ts │ │ │ │ └── translations/ │ │ │ │ ├── README.md │ │ │ │ ├── client.ts │ │ │ │ ├── index.ts │ │ │ │ ├── pull.ts │ │ │ │ ├── push.ts │ │ │ │ ├── types.ts │ │ │ │ └── utils.ts │ │ │ ├── constants/ │ │ │ │ ├── constants.ts │ │ │ │ └── index.ts │ │ │ ├── dev-server/ │ │ │ │ ├── http-server.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ └── services/ │ │ │ ├── analytics.service.ts │ │ │ ├── config.service.ts │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── vitest.config.ts │ ├── providers/ │ │ ├── .czrc │ │ ├── .gitignore │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── package.json │ │ ├── project.json │ │ ├── src/ │ │ │ ├── base.provider.ts │ │ │ ├── index.ts │ │ │ ├── lib/ │ │ │ │ ├── chat/ │ │ │ │ │ ├── chat-webhook/ │ │ │ │ │ │ └── chat-webhook.provider.ts │ │ │ │ │ ├── discord/ │ │ │ │ │ │ ├── discord.provider.spec.ts │ │ │ │ │ │ └── discord.provider.ts │ │ │ │ │ ├── getstream/ │ │ │ │ │ │ ├── getstream.provider.spec.ts │ │ │ │ │ │ └── getstream.provider.ts │ │ │ │ │ ├── grafana-on-call/ │ │ │ │ │ │ ├── grafana-on-call.provider.spec.ts │ │ │ │ │ │ └── grafana-on-call.provider.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── mattermost/ │ │ │ │ │ │ ├── mattermost.provider.spec.ts │ │ │ │ │ │ └── mattermost.provider.ts │ │ │ │ │ ├── msTeams/ │ │ │ │ │ │ ├── msTeams.provider.spec.ts │ │ │ │ │ │ └── msTeams.provider.ts │ │ │ │ │ ├── rocket-chat/ │ │ │ │ │ │ ├── rocket-chat.provider.spec.ts │ │ │ │ │ │ └── rocket-chat.provider.ts │ │ │ │ │ ├── ryver/ │ │ │ │ │ │ ├── ryver.provider.spec.ts │ │ │ │ │ │ └── ryver.provider.ts │ │ │ │ │ ├── slack/ │ │ │ │ │ │ ├── slack.provider.spec.ts │ │ │ │ │ │ └── slack.provider.ts │ │ │ │ │ ├── whatsapp-business/ │ │ │ │ │ │ ├── consts/ │ │ │ │ │ │ │ └── whatsapp-business.enum.ts │ │ │ │ │ │ ├── types/ │ │ │ │ │ │ │ └── whatsapp-business.types.ts │ │ │ │ │ │ ├── whatsapp-business.provider.spec.ts │ │ │ │ │ │ └── whatsapp-business.provider.ts │ │ │ │ │ └── zulip/ │ │ │ │ │ ├── zulip.provider.spec.ts │ │ │ │ │ └── zulip.provider.ts │ │ │ │ ├── email/ │ │ │ │ │ ├── braze/ │ │ │ │ │ │ ├── braze.provider.spec.ts │ │ │ │ │ │ └── braze.provider.ts │ │ │ │ │ ├── brevo/ │ │ │ │ │ │ ├── brevo.provider.spec.ts │ │ │ │ │ │ └── brevo.provider.ts │ │ │ │ │ ├── email-webhook/ │ │ │ │ │ │ ├── email-webhook.provider.spec.ts │ │ │ │ │ │ └── email-webhook.provider.ts │ │ │ │ │ ├── emailjs/ │ │ │ │ │ │ ├── emailjs.config.ts │ │ │ │ │ │ ├── emailjs.provider.spec.ts │ │ │ │ │ │ └── emailjs.provider.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── infobip/ │ │ │ │ │ │ ├── infobip.provider.spec.ts │ │ │ │ │ │ └── infobip.provider.ts │ │ │ │ │ ├── mailersend/ │ │ │ │ │ │ └── mailersend.provider.ts │ │ │ │ │ ├── mailgun/ │ │ │ │ │ │ ├── mailgun.provider.spec.ts │ │ │ │ │ │ └── mailgun.provider.ts │ │ │ │ │ ├── mailjet/ │ │ │ │ │ │ ├── mailjet.provider.spec.ts │ │ │ │ │ │ └── mailjet.provider.ts │ │ │ │ │ ├── mailtrap/ │ │ │ │ │ │ ├── mailtrap.provider.spec.ts │ │ │ │ │ │ └── mailtrap.provider.ts │ │ │ │ │ ├── mandrill/ │ │ │ │ │ │ ├── mandril.interface.ts │ │ │ │ │ │ ├── mandrill.provider.spec.ts │ │ │ │ │ │ └── mandrill.provider.ts │ │ │ │ │ ├── netcore/ │ │ │ │ │ │ ├── netcore-types.ts │ │ │ │ │ │ ├── netcore.provider.spec.ts │ │ │ │ │ │ └── netcore.provider.ts │ │ │ │ │ ├── nodemailer/ │ │ │ │ │ │ ├── nodemailer.provider.spec.ts │ │ │ │ │ │ └── nodemailer.provider.ts │ │ │ │ │ ├── outlook365/ │ │ │ │ │ │ ├── outlook365.provider.spec.ts │ │ │ │ │ │ └── outlook365.provider.ts │ │ │ │ │ ├── plunk/ │ │ │ │ │ │ ├── plunk.interface.ts │ │ │ │ │ │ ├── plunk.provider.spec.ts │ │ │ │ │ │ └── plunk.provider.ts │ │ │ │ │ ├── postmark/ │ │ │ │ │ │ ├── postmark.provider.spec.ts │ │ │ │ │ │ └── postmark.provider.ts │ │ │ │ │ ├── resend/ │ │ │ │ │ │ ├── resend.provider.spec.ts │ │ │ │ │ │ └── resend.provider.ts │ │ │ │ │ ├── sendgrid/ │ │ │ │ │ │ ├── sendgrid.provider.spec.ts │ │ │ │ │ │ └── sendgrid.provider.ts │ │ │ │ │ ├── ses/ │ │ │ │ │ │ ├── ses.config.ts │ │ │ │ │ │ ├── ses.provider.spec.ts │ │ │ │ │ │ └── ses.provider.ts │ │ │ │ │ └── sparkpost/ │ │ │ │ │ ├── sparkpost.error.ts │ │ │ │ │ ├── sparkpost.provider.spec.ts │ │ │ │ │ └── sparkpost.provider.ts │ │ │ │ ├── index.ts │ │ │ │ ├── push/ │ │ │ │ │ ├── apns/ │ │ │ │ │ │ ├── apns.provider.spec.ts │ │ │ │ │ │ └── apns.provider.ts │ │ │ │ │ ├── appio/ │ │ │ │ │ │ ├── appio.provider.spec.ts │ │ │ │ │ │ └── appio.provider.ts │ │ │ │ │ ├── expo/ │ │ │ │ │ │ ├── expo.provider.spec.ts │ │ │ │ │ │ └── expo.provider.ts │ │ │ │ │ ├── fcm/ │ │ │ │ │ │ ├── fcm.provider.spec.ts │ │ │ │ │ │ └── fcm.provider.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── one-signal/ │ │ │ │ │ │ ├── one-signal.provider.spec.ts │ │ │ │ │ │ ├── one-signal.provider.ts │ │ │ │ │ │ └── one-signal.providerV2.spec.ts │ │ │ │ │ ├── push-webhook/ │ │ │ │ │ │ ├── push-webhook.provider.spec.ts │ │ │ │ │ │ └── push-webhook.provider.ts │ │ │ │ │ ├── push.base-provider.ts │ │ │ │ │ ├── pusher-beams/ │ │ │ │ │ │ ├── pusher-beams.provider.spec.ts │ │ │ │ │ │ └── pusher-beams.provider.ts │ │ │ │ │ └── pushpad/ │ │ │ │ │ ├── pushpad.provider.spec.ts │ │ │ │ │ └── pushpad.provider.ts │ │ │ │ └── sms/ │ │ │ │ ├── africas-talking/ │ │ │ │ │ ├── africas-talking.provider.spec.ts │ │ │ │ │ └── africas-talking.provider.ts │ │ │ │ ├── afro-sms/ │ │ │ │ │ └── afro-sms.provider.ts │ │ │ │ ├── azure-sms/ │ │ │ │ │ ├── azure-sms.provider.spec.ts │ │ │ │ │ └── azure-sms.provider.ts │ │ │ │ ├── bandwidth/ │ │ │ │ │ ├── bandwidth.provider.spec.ts │ │ │ │ │ └── bandwidth.provider.ts │ │ │ │ ├── brevo-sms/ │ │ │ │ │ ├── brevo-sms.provider.spec.ts │ │ │ │ │ └── brevo-sms.provider.ts │ │ │ │ ├── bulk-sms/ │ │ │ │ │ ├── bulk-sms.provider.spec.ts │ │ │ │ │ └── bulk-sms.provider.ts │ │ │ │ ├── burst-sms/ │ │ │ │ │ ├── burst-sms.provider.spec.ts │ │ │ │ │ └── burst-sms.provider.ts │ │ │ │ ├── clickatell/ │ │ │ │ │ ├── clickatell.provider.spec.ts │ │ │ │ │ └── clickatell.provider.ts │ │ │ │ ├── clicksend/ │ │ │ │ │ ├── clicksend.provider.spec.ts │ │ │ │ │ └── clicksend.provider.ts │ │ │ │ ├── cm-telecom/ │ │ │ │ │ ├── cm-telecom.provider.spec.ts │ │ │ │ │ └── cm-telecom.provider.ts │ │ │ │ ├── eazy-sms/ │ │ │ │ │ ├── eazy-sms.provider.spec.ts │ │ │ │ │ └── eazy-sms.provider.ts │ │ │ │ ├── firetext/ │ │ │ │ │ ├── firetext.provider.spec.ts │ │ │ │ │ └── firetext.provider.ts │ │ │ │ ├── forty-six-elks/ │ │ │ │ │ ├── forty-six-elks.provider.spec.ts │ │ │ │ │ └── forty-six-elks.provider.ts │ │ │ │ ├── generic-sms/ │ │ │ │ │ ├── generic-sms.provider.spec.ts │ │ │ │ │ └── generic-sms.provider.ts │ │ │ │ ├── gupshup/ │ │ │ │ │ ├── gupshup.provider.spec.ts │ │ │ │ │ └── gupshup.provider.ts │ │ │ │ ├── imedia/ │ │ │ │ │ ├── imedia.provider.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── infobip/ │ │ │ │ │ ├── infobip.provider.spec.ts │ │ │ │ │ └── infobip.provider.ts │ │ │ │ ├── isend-sms/ │ │ │ │ │ ├── isend-sms.provider.spec.ts │ │ │ │ │ └── isend-sms.provider.ts │ │ │ │ ├── isendpro-sms/ │ │ │ │ │ ├── isendpro-sms.provider.ts │ │ │ │ │ └── isendpro-sms.test.provider.spec.ts │ │ │ │ ├── kannel/ │ │ │ │ │ ├── kannel.provider.spec.ts │ │ │ │ │ └── kannel.provider.ts │ │ │ │ ├── maqsam/ │ │ │ │ │ ├── maqsam.provider.spec.ts │ │ │ │ │ └── maqsam.provider.ts │ │ │ │ ├── messagebird/ │ │ │ │ │ ├── messagebird.provider.spec.ts │ │ │ │ │ └── messagebird.provider.ts │ │ │ │ ├── mobishastra/ │ │ │ │ │ ├── mobishastra.provider.spec.ts │ │ │ │ │ └── mobishastra.provider.ts │ │ │ │ ├── nexmo/ │ │ │ │ │ ├── nexmo.provider.spec.ts │ │ │ │ │ └── nexmo.provider.ts │ │ │ │ ├── plivo/ │ │ │ │ │ ├── plivo.provider.spec.ts │ │ │ │ │ └── plivo.provider.ts │ │ │ │ ├── ring-central/ │ │ │ │ │ ├── ring-central.provider.spec.ts │ │ │ │ │ └── ring-central.provider.ts │ │ │ │ ├── sendchamp/ │ │ │ │ │ ├── sendchamp.provider.spec.ts │ │ │ │ │ └── sendchamp.provider.ts │ │ │ │ ├── simpletexting/ │ │ │ │ │ ├── simpletexting.provider.spec.ts │ │ │ │ │ └── simpletexting.provider.ts │ │ │ │ ├── sinch/ │ │ │ │ │ ├── sinch.provider.spec.ts │ │ │ │ │ └── sinch.provider.ts │ │ │ │ ├── sms-central/ │ │ │ │ │ ├── sms-central.provider.spec.ts │ │ │ │ │ └── sms-central.provider.ts │ │ │ │ ├── sms77/ │ │ │ │ │ ├── sms77.provider.spec.ts │ │ │ │ │ └── sms77.provider.ts │ │ │ │ ├── smsmode/ │ │ │ │ │ ├── smsmode.provider.ts │ │ │ │ │ └── smsmode.test.provider.spec.ts │ │ │ │ ├── sns/ │ │ │ │ │ ├── sns.config.ts │ │ │ │ │ ├── sns.provider.spec.ts │ │ │ │ │ └── sns.provider.ts │ │ │ │ ├── telnyx/ │ │ │ │ │ ├── telnyx.interface.ts │ │ │ │ │ ├── telnyx.provider.spec.ts │ │ │ │ │ └── telnyx.provider.ts │ │ │ │ ├── termii/ │ │ │ │ │ ├── sms.ts │ │ │ │ │ ├── termii.provider.spec.ts │ │ │ │ │ └── termii.provider.ts │ │ │ │ ├── twilio/ │ │ │ │ │ ├── twilio.provider.spec.ts │ │ │ │ │ └── twilio.provider.ts │ │ │ │ └── unifonic/ │ │ │ │ └── unifonic.provider.ts │ │ │ └── utils/ │ │ │ ├── change-case/ │ │ │ │ ├── change-case.spec.ts │ │ │ │ ├── functions.ts │ │ │ │ └── index.ts │ │ │ ├── deepmerge.utils.spec.ts │ │ │ ├── deepmerge.utils.ts │ │ │ ├── test/ │ │ │ │ └── spy-axios.ts │ │ │ └── types.ts │ │ ├── tsconfig.esm.json │ │ ├── tsconfig.json │ │ └── vitest.config.js │ ├── react/ │ │ ├── .gitignore │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── hooks/ │ │ │ └── package.json │ │ ├── internal/ │ │ │ └── package.json │ │ ├── package.json │ │ ├── project.json │ │ ├── server/ │ │ │ └── package.json │ │ ├── src/ │ │ │ ├── components/ │ │ │ │ ├── Bell.tsx │ │ │ │ ├── Inbox.tsx │ │ │ │ ├── InboxContent.tsx │ │ │ │ ├── Mounter.tsx │ │ │ │ ├── Notifications.tsx │ │ │ │ ├── NovuUI.tsx │ │ │ │ ├── Preferences.tsx │ │ │ │ ├── Renderer.tsx │ │ │ │ ├── ShadowRootDetector.tsx │ │ │ │ ├── index.ts │ │ │ │ └── subscription/ │ │ │ │ ├── DefaultSubscription.tsx │ │ │ │ ├── Subscription.tsx │ │ │ │ ├── SubscriptionButton.tsx │ │ │ │ └── SubscriptionPreferences.tsx │ │ │ ├── context/ │ │ │ │ ├── NovuUIContext.tsx │ │ │ │ └── RendererContext.tsx │ │ │ ├── hooks/ │ │ │ │ ├── NovuProvider.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── internal/ │ │ │ │ │ ├── useBrowserTabsChannel.ts │ │ │ │ │ ├── useDataRef.ts │ │ │ │ │ └── useWebsocketEvent.ts │ │ │ │ ├── useCounts.ts │ │ │ │ ├── useCreateSubscription.ts │ │ │ │ ├── useNotifications.ts │ │ │ │ ├── usePreferences.ts │ │ │ │ ├── useRemoveSubscription.ts │ │ │ │ ├── useSchedule.ts │ │ │ │ ├── useSubscription.ts │ │ │ │ ├── useSubscriptions.ts │ │ │ │ └── useUpdateSubscription.ts │ │ │ ├── index.ts │ │ │ ├── internal/ │ │ │ │ └── index.ts │ │ │ ├── server/ │ │ │ │ └── index.tsx │ │ │ ├── themes/ │ │ │ │ └── index.ts │ │ │ └── utils/ │ │ │ ├── appearance.ts │ │ │ ├── createContextAndHook.ts │ │ │ ├── requestLock.ts │ │ │ └── types.ts │ │ ├── themes/ │ │ │ └── package.json │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── react-native/ │ │ ├── .gitignore │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── package.json │ │ ├── project.json │ │ ├── src/ │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── shared/ │ │ ├── .dockerignore │ │ ├── .gitignore │ │ ├── CHANGELOG.md │ │ ├── package.json │ │ ├── project.json │ │ ├── src/ │ │ │ ├── config/ │ │ │ │ ├── contextPath.ts │ │ │ │ ├── index.ts │ │ │ │ ├── job-queue.ts │ │ │ │ ├── processEnv.ts │ │ │ │ └── redisPrefix.ts │ │ │ ├── consts/ │ │ │ │ ├── data-retention/ │ │ │ │ │ └── index.ts │ │ │ │ ├── feature-tiers-constants.ts │ │ │ │ ├── filters/ │ │ │ │ │ ├── filters.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── handlebar-helpers/ │ │ │ │ │ ├── getTemplateVariables.ts │ │ │ │ │ ├── handlebarHelpers.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── inviteTeamMemberNudge.ts │ │ │ │ ├── layouts.ts │ │ │ │ ├── notification-item-buttons/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── notificationItemButton.ts │ │ │ │ ├── password-helper/ │ │ │ │ │ ├── PasswordResetFlowEnum.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── passwordHelper.ts │ │ │ │ ├── preferences/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── preferences.const.ts │ │ │ │ ├── productFeatureEnabledForServiceLevel.ts │ │ │ │ ├── providers/ │ │ │ │ │ ├── channels/ │ │ │ │ │ │ ├── chat.ts │ │ │ │ │ │ ├── email.ts │ │ │ │ │ │ ├── http-request.ts │ │ │ │ │ │ ├── in-app.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── push.ts │ │ │ │ │ │ └── sms.ts │ │ │ │ │ ├── configurations/ │ │ │ │ │ │ └── provider-configuration.ts │ │ │ │ │ ├── credentials/ │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── provider-credentials.ts │ │ │ │ │ │ └── secure-credentials.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── provider.interface.ts │ │ │ │ │ └── providers.ts │ │ │ │ ├── rate-limiting/ │ │ │ │ │ ├── apiRateLimits.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── severity.ts │ │ │ │ ├── template-store/ │ │ │ │ │ └── index.ts │ │ │ │ ├── translation/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── translation.constants.spec.ts │ │ │ │ │ └── translation.constants.ts │ │ │ │ ├── upsert-validation-constants.ts │ │ │ │ └── validIdRegex.ts │ │ │ ├── dto/ │ │ │ │ ├── bridge/ │ │ │ │ │ ├── bridge.interface.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── environment-variable/ │ │ │ │ │ ├── environment-variable.dto.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── environments/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── tags.interface.ts │ │ │ │ ├── events/ │ │ │ │ │ ├── event.interface.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── index.ts │ │ │ │ ├── integration/ │ │ │ │ │ ├── construct-integration.interface.ts │ │ │ │ │ ├── create-integration.dto.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── update-integration.dto.ts │ │ │ │ ├── layout/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── layout.dto.ts │ │ │ │ ├── message-template/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── message-template.dto.ts │ │ │ │ ├── notification-templates/ │ │ │ │ │ ├── create-template.dto.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── update-template.dto.ts │ │ │ │ ├── organization/ │ │ │ │ │ ├── create-organization.dto.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── members/ │ │ │ │ │ │ ├── bulk-invite-members.dto.ts │ │ │ │ │ │ └── get-invite.dto.ts │ │ │ │ │ └── update-external-organization.dto.ts │ │ │ │ ├── pagination/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── pagination.dto.ts │ │ │ │ ├── session/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── session.dto.ts │ │ │ │ ├── shared/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── shared.ts │ │ │ │ ├── stateless-control-values/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── stateless-controls.ts │ │ │ │ ├── subscriber/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── subscriber.dto.ts │ │ │ │ ├── subscription/ │ │ │ │ │ ├── get-subscription.dto.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── tenant/ │ │ │ │ │ ├── create-tenant.dto.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── tenant.dto.ts │ │ │ │ │ └── update-tenant.dto.ts │ │ │ │ ├── topic/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── topic-subscriber.interface.ts │ │ │ │ │ └── topic.dto.ts │ │ │ │ ├── widget/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── notification.dto.ts │ │ │ │ │ └── subscriber-preference/ │ │ │ │ │ └── update-subscriber-preference.dto.ts │ │ │ │ ├── workflow-override/ │ │ │ │ │ ├── create-workflow-override-request.dto.ts │ │ │ │ │ ├── create-workflow-override-response.dto.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── update-workflow-override-request.dto.ts │ │ │ │ │ ├── update-workflow-override-response.dto.ts │ │ │ │ │ └── workflow-override.dto.ts │ │ │ │ └── workflows/ │ │ │ │ ├── create-workflow-deprecated.dto.ts │ │ │ │ ├── generate-preview-request.dto.ts │ │ │ │ ├── get-list-query-params.ts │ │ │ │ ├── index.ts │ │ │ │ ├── json-schema-dto.ts │ │ │ │ ├── preview-step-response.dto.ts │ │ │ │ ├── promote-workflow-dto.ts │ │ │ │ ├── step.dto.ts │ │ │ │ ├── update-workflow-deprecated.dto.ts │ │ │ │ ├── workflow-deprecated.dto.ts │ │ │ │ ├── workflow-status-enum.ts │ │ │ │ ├── workflow-test-data-response-dto.ts │ │ │ │ └── workflow.dto.ts │ │ │ ├── entities/ │ │ │ │ ├── activity-feed/ │ │ │ │ │ ├── activity.interface.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── actor/ │ │ │ │ │ ├── actor.interface.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── apiKeys/ │ │ │ │ │ ├── apiKeys.interface.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── change/ │ │ │ │ │ ├── change.interface.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── environment/ │ │ │ │ │ ├── environment.interface.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── environment-variable/ │ │ │ │ │ ├── environment-variable.interface.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── execution-details/ │ │ │ │ │ ├── execution-details.interface.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── feed/ │ │ │ │ │ └── feed.interface.ts │ │ │ │ ├── integration/ │ │ │ │ │ ├── configuration.interface.ts │ │ │ │ │ ├── credential.interface.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── integration.interface.ts │ │ │ │ ├── job/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── job.interface.ts │ │ │ │ │ └── status.enum.ts │ │ │ │ ├── layout/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── layout.interface.ts │ │ │ │ ├── log/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── log.enums.ts │ │ │ │ ├── message-template/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── message-template.interface.ts │ │ │ │ ├── messages/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── messages.interface.ts │ │ │ │ ├── notification/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── notification.interface.ts │ │ │ │ ├── notification-group/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── notification-group.interface.ts │ │ │ │ ├── notification-template/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── notification-template.interface.ts │ │ │ │ ├── notification-trigger/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── notification-trigger.interface.ts │ │ │ │ ├── organization/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── member.enum.ts │ │ │ │ │ ├── member.interface.ts │ │ │ │ │ └── organization.interface.ts │ │ │ │ ├── step/ │ │ │ │ │ └── index.ts │ │ │ │ ├── subscriber-preference/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── subscriber-preference.interface.ts │ │ │ │ ├── tenant/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── tenant.interface.ts │ │ │ │ ├── user/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── subscriber-user.interface.ts │ │ │ │ │ ├── user.enums.ts │ │ │ │ │ └── user.interface.ts │ │ │ │ ├── workflow-override/ │ │ │ │ │ ├── index.ts │ │ │ │ │ └── workflow-override.interface.ts │ │ │ │ └── workflow-run/ │ │ │ │ ├── delivery-lifecycle-detail.enum.ts │ │ │ │ ├── delivery-lifecycle-event-type.ts │ │ │ │ ├── delivery-lifecycle-status.enum.ts │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── services/ │ │ │ │ ├── feature-flags/ │ │ │ │ │ ├── feature-flags.util.spec.ts │ │ │ │ │ ├── feature-flags.util.ts │ │ │ │ │ └── index.ts │ │ │ │ └── index.ts │ │ │ ├── types/ │ │ │ │ ├── ai.ts │ │ │ │ ├── auth.ts │ │ │ │ ├── billing.ts │ │ │ │ ├── builder.ts │ │ │ │ ├── channel-connection.ts │ │ │ │ ├── channel-endpoint.ts │ │ │ │ ├── channel.ts │ │ │ │ ├── context.ts │ │ │ │ ├── controls.ts │ │ │ │ ├── cron.ts │ │ │ │ ├── environment-variable.ts │ │ │ │ ├── environment.ts │ │ │ │ ├── events.ts │ │ │ │ ├── feature-flags.spec.ts │ │ │ │ ├── feature-flags.ts │ │ │ │ ├── files.ts │ │ │ │ ├── general.ts │ │ │ │ ├── index.ts │ │ │ │ ├── jobs.ts │ │ │ │ ├── layout.ts │ │ │ │ ├── message-templates.ts │ │ │ │ ├── messages.ts │ │ │ │ ├── notification-templates.ts │ │ │ │ ├── organization.ts │ │ │ │ ├── providers.ts │ │ │ │ ├── rate-limiting.ts │ │ │ │ ├── resource-limiting.ts │ │ │ │ ├── response.ts │ │ │ │ ├── secrets.ts │ │ │ │ ├── storage.ts │ │ │ │ ├── subscriber.ts │ │ │ │ ├── tenant.ts │ │ │ │ ├── timezones.ts │ │ │ │ ├── topic.ts │ │ │ │ ├── user.ts │ │ │ │ ├── utils.ts │ │ │ │ ├── workflow-channel-preferences.ts │ │ │ │ ├── workflow-override.ts │ │ │ │ └── ws.ts │ │ │ ├── ui/ │ │ │ │ ├── index.ts │ │ │ │ └── marketing.ts │ │ │ ├── utils/ │ │ │ │ ├── bridge.utils.ts │ │ │ │ ├── buildWorkflowPreferences.spec.ts │ │ │ │ ├── buildWorkflowPreferences.ts │ │ │ │ ├── checkIsResponseError.spec.ts │ │ │ │ ├── checkIsResponseError.ts │ │ │ │ ├── env.ts │ │ │ │ ├── index.ts │ │ │ │ ├── issues.ts │ │ │ │ ├── locales/ │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── locale-registry.ts │ │ │ │ │ ├── locale-validator.ts │ │ │ │ │ └── locales.ts │ │ │ │ ├── normalizeEmail.ts │ │ │ │ ├── schema/ │ │ │ │ │ ├── create-mock-object-from-schema.spec.ts │ │ │ │ │ └── create-mock-object-from-schema.ts │ │ │ │ └── slugify/ │ │ │ │ ├── builtinReplacements.ts │ │ │ │ ├── index.ts │ │ │ │ ├── slugify.spec.ts │ │ │ │ ├── slugify.ts │ │ │ │ └── transliterate.ts │ │ │ └── webhooks/ │ │ │ ├── index.ts │ │ │ └── webhook-event.enum.ts │ │ ├── tsconfig.esm.json │ │ └── tsconfig.json │ └── stateless/ │ ├── .czrc │ ├── .gitignore │ ├── CHANGELOG.md │ ├── README.md │ ├── jest.config.js │ ├── package.json │ ├── project.json │ ├── src/ │ │ ├── index.ts │ │ ├── lib/ │ │ │ ├── content/ │ │ │ │ ├── content.engine.spec.ts │ │ │ │ └── content.engine.ts │ │ │ ├── events/ │ │ │ │ └── types.d.ts │ │ │ ├── handler/ │ │ │ │ ├── chat.handler.spec.ts │ │ │ │ ├── chat.handler.ts │ │ │ │ ├── email.handler.spec.ts │ │ │ │ ├── email.handler.ts │ │ │ │ ├── sms.handler.spec.ts │ │ │ │ └── sms.handler.ts │ │ │ ├── novu.interface.ts │ │ │ ├── novu.spec.ts │ │ │ ├── novu.ts │ │ │ ├── provider/ │ │ │ │ ├── channel-data.type.ts │ │ │ │ ├── provider.enum.ts │ │ │ │ ├── provider.interface.ts │ │ │ │ ├── provider.store.spec.ts │ │ │ │ └── provider.store.ts │ │ │ ├── template/ │ │ │ │ ├── template.interface.ts │ │ │ │ ├── template.store.spec.ts │ │ │ │ └── template.store.ts │ │ │ ├── theme/ │ │ │ │ ├── theme.interface.ts │ │ │ │ ├── theme.store.spec.ts │ │ │ │ └── theme.store.ts │ │ │ └── trigger/ │ │ │ ├── trigger.engine.spec.ts │ │ │ └── trigger.engine.ts │ │ └── types/ │ │ └── example.d.ts │ ├── tsconfig.esm.json │ └── tsconfig.json ├── playground/ │ ├── nestjs/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── nest-cli.json │ │ ├── package.json │ │ ├── src/ │ │ │ ├── app.controller.ts │ │ │ ├── app.module.ts │ │ │ ├── app.service.ts │ │ │ ├── app.spec.ts │ │ │ ├── main.ts │ │ │ ├── notification.service.ts │ │ │ └── user.service.ts │ │ ├── tsconfig.json │ │ └── vitest.config.mts │ └── nextjs/ │ ├── .gitignore │ ├── components.json │ ├── next.config.mjs │ ├── package.json │ ├── postcss.config.mjs │ ├── src/ │ │ ├── app/ │ │ │ ├── agent-toolkit/ │ │ │ │ ├── app-sidenav.tsx │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ │ ├── api/ │ │ │ │ └── agent-toolkit/ │ │ │ │ ├── chat/ │ │ │ │ │ └── route.ts │ │ │ │ ├── lib/ │ │ │ │ │ └── toolkit.ts │ │ │ │ ├── result/ │ │ │ │ │ └── route.ts │ │ │ │ └── webhook/ │ │ │ │ └── route.ts │ │ │ ├── app-router/ │ │ │ │ └── inbox/ │ │ │ │ └── page.tsx │ │ │ ├── inbox-client/ │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── components/ │ │ │ ├── Header.tsx │ │ │ ├── SideNav.tsx │ │ │ ├── Title.tsx │ │ │ ├── ai-elements/ │ │ │ │ ├── agent.tsx │ │ │ │ ├── artifact.tsx │ │ │ │ ├── attachments.tsx │ │ │ │ ├── audio-player.tsx │ │ │ │ ├── canvas.tsx │ │ │ │ ├── chain-of-thought.tsx │ │ │ │ ├── checkpoint.tsx │ │ │ │ ├── code-block.tsx │ │ │ │ ├── commit.tsx │ │ │ │ ├── confirmation.tsx │ │ │ │ ├── connection.tsx │ │ │ │ ├── context.tsx │ │ │ │ ├── controls.tsx │ │ │ │ ├── conversation.tsx │ │ │ │ ├── edge.tsx │ │ │ │ ├── environment-variables.tsx │ │ │ │ ├── file-tree.tsx │ │ │ │ ├── image.tsx │ │ │ │ ├── inline-citation.tsx │ │ │ │ ├── jsx-preview.tsx │ │ │ │ ├── message.tsx │ │ │ │ ├── mic-selector.tsx │ │ │ │ ├── model-selector.tsx │ │ │ │ ├── node.tsx │ │ │ │ ├── open-in-chat.tsx │ │ │ │ ├── package-info.tsx │ │ │ │ ├── panel.tsx │ │ │ │ ├── persona.tsx │ │ │ │ ├── plan.tsx │ │ │ │ ├── prompt-input.tsx │ │ │ │ ├── queue.tsx │ │ │ │ ├── reasoning.tsx │ │ │ │ ├── sandbox.tsx │ │ │ │ ├── schema-display.tsx │ │ │ │ ├── shimmer.tsx │ │ │ │ ├── snippet.tsx │ │ │ │ ├── sources.tsx │ │ │ │ ├── speech-input.tsx │ │ │ │ ├── stack-trace.tsx │ │ │ │ ├── suggestion.tsx │ │ │ │ ├── task.tsx │ │ │ │ ├── terminal.tsx │ │ │ │ ├── test-results.tsx │ │ │ │ ├── tool.tsx │ │ │ │ ├── toolbar.tsx │ │ │ │ ├── transcription.tsx │ │ │ │ ├── voice-selector.tsx │ │ │ │ └── web-preview.tsx │ │ │ ├── hooks/ │ │ │ │ ├── demo/ │ │ │ │ │ ├── inbox-item.tsx │ │ │ │ │ ├── more-actions-dropdown.tsx │ │ │ │ │ ├── notion-theme.tsx │ │ │ │ │ ├── show.tsx │ │ │ │ │ ├── sidebar-item.tsx │ │ │ │ │ ├── status-context.tsx │ │ │ │ │ └── status-dropdown.tsx │ │ │ │ └── icons.tsx │ │ │ └── ui/ │ │ │ ├── accordion.tsx │ │ │ ├── alert.tsx │ │ │ ├── avatar.tsx │ │ │ ├── badge.tsx │ │ │ ├── button-group.tsx │ │ │ ├── button.tsx │ │ │ ├── card.tsx │ │ │ ├── carousel.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── collapsible.tsx │ │ │ ├── command.tsx │ │ │ ├── dialog.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── hover-card.tsx │ │ │ ├── input-group.tsx │ │ │ ├── input.tsx │ │ │ ├── popover.tsx │ │ │ ├── progress.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── select.tsx │ │ │ ├── separator.tsx │ │ │ ├── spinner.tsx │ │ │ ├── switch.tsx │ │ │ ├── tabs.tsx │ │ │ ├── textarea.tsx │ │ │ └── tooltip.tsx │ │ ├── lib/ │ │ │ └── utils.ts │ │ ├── pages/ │ │ │ ├── _app.tsx │ │ │ ├── _document.tsx │ │ │ ├── api/ │ │ │ │ └── hello.ts │ │ │ ├── custom-icons/ │ │ │ │ └── index.tsx │ │ │ ├── custom-popover/ │ │ │ │ └── index.tsx │ │ │ ├── custom-subject-body/ │ │ │ │ └── index.tsx │ │ │ ├── hooks/ │ │ │ │ └── index.tsx │ │ │ ├── index.tsx │ │ │ ├── layout.tsx │ │ │ ├── notifications/ │ │ │ │ └── index.tsx │ │ │ ├── novu-theme/ │ │ │ │ ├── index.tsx │ │ │ │ └── novu-theme.module.css │ │ │ ├── preferences/ │ │ │ │ └── index.tsx │ │ │ ├── render-bell/ │ │ │ │ └── index.tsx │ │ │ ├── render-notification/ │ │ │ │ └── index.tsx │ │ │ ├── subscription/ │ │ │ │ └── index.tsx │ │ │ ├── subscription-components/ │ │ │ │ └── index.tsx │ │ │ └── subscription-hooks/ │ │ │ └── index.tsx │ │ ├── styles/ │ │ │ └── globals.css │ │ └── utils/ │ │ ├── config.ts │ │ └── tw.ts │ └── tsconfig.json ├── pnpm-workspace.yaml ├── renovate.json ├── scripts/ │ ├── clean-build.sh │ ├── dev-environment-setup.sh │ ├── dotenvcreate.mjs │ ├── get-affected-batch.mjs │ ├── get-packages-folder.mjs │ ├── jarvis.js │ ├── pnpm-context.mjs │ ├── print-affected-array.mjs │ ├── publish-preview-packages.mjs │ ├── release.mjs │ ├── seed-agent-data.mjs │ ├── set-package-dependencies.mjs │ ├── setup-agent.sh │ ├── setup-env-files.js │ └── symlink-ee.mjs └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .agents/skills/email-best-practices/SKILL.md ================================================ --- name: email-best-practices description: Use when building email features, emails going to spam, high bounce rates, setting up SPF/DKIM/DMARC authentication, implementing email capture, ensuring compliance (CAN-SPAM, GDPR, CASL), handling webhooks, retry logic, or deciding transactional vs marketing. --- # Email Best Practices Guidance for building deliverable, compliant, user-friendly emails. ## Architecture Overview ``` [User] → [Email Form] → [Validation] → [Double Opt-In] ↓ [Consent Recorded] ↓ [Suppression Check] ←──────────────[Ready to Send] ↓ [Idempotent Send + Retry] ──────→ [Email API] ↓ [Webhook Events] ↓ ┌────────┬────────┬─────────────┐ ↓ ↓ ↓ ↓ Delivered Bounced Complained Opened/Clicked ↓ ↓ [Suppression List Updated] ↓ [List Hygiene Jobs] ``` ## Quick Reference | Need to... | See | |------------|-----| | Set up SPF/DKIM/DMARC, fix spam issues | [Deliverability](./resources/deliverability.md) | | Build password reset, OTP, confirmations | [Transactional Emails](./resources/transactional-emails.md) | | Plan which emails your app needs | [Transactional Email Catalog](./resources/transactional-email-catalog.md) | | Build newsletter signup, validate emails | [Email Capture](./resources/email-capture.md) | | Send newsletters, promotions | [Marketing Emails](./resources/marketing-emails.md) | | Ensure CAN-SPAM/GDPR/CASL compliance | [Compliance](./resources/compliance.md) | | Decide transactional vs marketing | [Email Types](./resources/email-types.md) | | Handle retries, idempotency, errors | [Sending Reliability](./resources/sending-reliability.md) | | Process delivery events, set up webhooks | [Webhooks & Events](./resources/webhooks-events.md) | | Manage bounces, complaints, suppression | [List Management](./resources/list-management.md) | ## Start Here **New app?** Start with the [Catalog](./resources/transactional-email-catalog.md) to plan which emails your app needs (password reset, verification, etc.), then set up [Deliverability](./resources/deliverability.md) (DNS authentication) before sending your first email. **Spam issues?** Check [Deliverability](./resources/deliverability.md) first—authentication problems are the most common cause. Gmail/Yahoo reject unauthenticated emails. **Marketing emails?** Follow this path: [Email Capture](./resources/email-capture.md) (collect consent) → [Compliance](./resources/compliance.md) (legal requirements) → [Marketing Emails](./resources/marketing-emails.md) (best practices). **Production-ready sending?** Add reliability: [Sending Reliability](./resources/sending-reliability.md) (retry + idempotency) → [Webhooks & Events](./resources/webhooks-events.md) (track delivery) → [List Management](./resources/list-management.md) (handle bounces). ================================================ FILE: .agents/skills/email-best-practices/resources/branding.md ================================================ # Email Branding ## BIMI (Optional) Display brand logo in email clients. Requires DMARC `p=quarantine` or `p=reject`. ================================================ FILE: .agents/skills/email-best-practices/resources/compliance.md ================================================ # Email Compliance Legal requirements for email by jurisdiction. **Not legal advice—consult an attorney for your specific situation.** ## Quick Reference | Law | Region | Key Requirement | Penalty | |-----|--------|-----------------|---------| | CAN-SPAM | US | Opt-out mechanism, physical address | $53k/email | | GDPR | EU | Explicit opt-in consent | €20M or 4% revenue | | CASL | Canada | Express consent, opt-out mechanism | $1M (individual) to $10M (organization) CAD | ## CAN-SPAM (United States) **Requirements:** - Accurate header info (From, To, Reply-To) - Non-deceptive subject lines - Physical mailing address in every email - Clear opt-out mechanism - Honor opt-out within 10 business days **Transactional emails:** Can send without opt-in if related to a transaction and not promotional. ## GDPR (European Union) **Requirements:** - Explicit opt-in consent (not pre-checked boxes) - Consent must be freely given, specific, informed - Easy to withdraw consent (as easy as giving it) - Right to access data and deletion ("right to be forgotten") - Process unsubscribe immediately **Consent records:** Document who, when, how, and what they consented to. **Transactional emails:** Can send based on contract fulfillment or legitimate interest. ## CASL (Canada) **Consent types:** - **Express consent:** Explicit opt-in (ideal) - **Implied consent:** Existing business relationship (2 years) or inquiry (6 months) **Requirements:** - Clear sender identification that will be valid for 60 days after send - Unsubscribe functional for 60 days after send - Process unsubscribe no later than 10 business days - Keep consent records 3 years after expiration ## Other Regions | Region | Law | Key Points | |--------|-----|------------| | Australia | Spam Act 2003 | Consent required, honor unsubscribe within 5 days | | UK | PECR + GDPR | Same as GDPR | | Brazil | LGPD | Similar to GDPR, explicit consent for marketing | ## Unsubscribe Requirements Summary | Law | Timing | Notes | |-----|--------|-------| | CAN-SPAM | 10 business days | Must work 30 days after send | | GDPR | Immediately | Must be as easy as opting in | | CASL | 10 business days | Must work 60 days after send | **Universal best practices:** Prominent link, one-click when possible, no login required, free, confirm action. ## Managing preferences vs Unsubscribe from all Most legistlations require a one-click unsubscribe. `Managing preferences` is a nice-to-have and can lead to lower unsubscribe rate but doesn't replace `Unsubscribe`. If possible, offer both. ## Consent Management **Record:** - Email address - Date/time of consent - Method (form, checkbox) - What they consented to - Source (which page/form) **Storage:** Database with timestamps, audit trail of changes, link to user account. ## Data Retention | Law | Requirement | |-----|-------------| | GDPR | Keep only as long as necessary, delete when no longer needed | | CASL | Keep consent records 3 years after expiration | **Best practice:** Have clear retention policy, honor deletion requests promptly, review and clean regularly. ## Privacy Policy Must Include - What data you collect - How you use data - Who you share data with - User rights (access, deletion) - How to contact about privacy ## International Sending **Best practice:** Follow the most restrictive requirements (usually GDPR) to ensure compliance across all regions. ## Related - [Email Capture](./email-capture.md) - Implement consent forms and double opt-in - [Marketing Emails](./marketing-emails.md) - Consent and unsubscribe requirements - [List Management](./list-management.md) - Handle unsubscribes and deletion requests ================================================ FILE: .agents/skills/email-best-practices/resources/deliverability.md ================================================ # Email Deliverability Maximizing the chances that your emails are delivered successfully to the recipients. ## Email Authentication **Required by Gmail/Yahoo/Microsoft** - unauthenticated emails will be rejected or spam-filtered. ### SPF (Sender Policy Framework) Specifies which servers can send email for your domain. ``` v=spf1 include:amazonses.com ~all ``` - Add TXT record to DNS - Use `~all` (soft fail) ### DKIM (DomainKeys Identified Mail) Cryptographic signature proving email authenticity. - Your email service will provide you with a TXT record ### DMARC Policy for handling SPF/DKIM failures + reporting. ``` v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com ``` **Rollout:** `p=none` (monitor) → `p=quarantine; pct=25` → `p=reject` Learn more: https://resend.com/blog/dmarc-policy-modes ### Verify Your Setup Check DNS records directly: ```bash # SPF record dig TXT yourdomain.com +short # DKIM record (replace 'resend' with your selector) dig TXT resend._domainkey.yourdomain.com +short # DMARC record dig TXT _dmarc.yourdomain.com +short ``` **Expected output:** Each command should return your configured record. No output = record missing. ## Sender Reputation ### IP Warming New IP/domain? Gradually increase volume: | Week | Daily Volume | |------|-------------| | 1 | 50-100 | | 2 | 200-500 | | 3 | 1,000-2,000 | | 4 | 5,000-10,000 | Start with engaged users. Send consistently. Don't rush. Learn more: https://resend.com/docs/knowledge-base/warming-up ### Maintaining Reputation **Do:** Send to engaged users, keep bounce <4%, complaints <0.1%, remove inactive subscribers. **Don't:** Send to purchased lists, ignore bounces/complaints, send inconsistent volumes ## Bounce Handling | Type | Cause | Action | |------|-------|--------| | Hard bounce | Permanent failure to deliver | Remove immediately | | Soft bounce | Transient failure to deliver | Retry: 1h → 4h → 24h, remove after 3-5 failures | **Targets:** <1% good, 1-3% acceptable, 3-4% concerning, >4% critical ## Complaint Handling **Targets:** <0.01% excellent, 0.01-0.05% good, >0.05% critical **Reduce complaints:** - Only send to opted-in users - Make unsubscribe easy and immediate - Use clear sender names and "From" addresses **Feedback loops:** Set up with Gmail (Postmaster Tools), Yahoo, Microsoft SNDS. Remove complainers immediately. ## Infrastructure **Dedicated sending domain:** Use different subdomains for different sending purposes (e.g., `t.yourdomain.com` for transactional emails and `m.yourdomain.com` for marketing emails). **DNS TTL:** Low (300s) during setup, high (3600s+) after stable. ## Troubleshooting **Emails going to spam?** Check in order: 1. Authentication (SPF, DKIM, DMARC) 2. Sender reputation (blacklists, complaint rates) 3. Content 4. Sending patterns (sudden volume spikes) **Diagnostic tools:** [Google Postmaster Tools](https://postmaster.google.com) ## Related - [List Management](./list-management.md) - Handle bounces and complaints to protect reputation - [Sending Reliability](./sending-reliability.md) - Retry logic and error handling ================================================ FILE: .agents/skills/email-best-practices/resources/email-capture.md ================================================ # Email Capture Best Practices Collecting email addresses responsibly with validation, verification, and proper consent. ## Email Validation ### Client-Side **HTML5:** ```html ``` **Best practices:** - Validate on blur or with short debounce - Show clear error messages - Don't be too strict (allow unusual but valid formats) - Client-side validation ≠ deliverability ### Server-Side (Recommended) Always validate server-side—client-side can be bypassed. **Check:** - Email format (RFC 5322) - Domain exists (DNS lookup) - Domain has MX records - Optionally: disposable email detection Recommended tools: https://resend.com/blog/best-email-verification-apis ## Double opt-in Confirms address belongs to user and is deliverable. ### Process 1. User submits email 2. Send verification email with unique link/token 3. User clicks link 4. Mark as verified 5. Allow access/add to list **Timing:** Send immediately, include expiration (24-48 hours), allow resend after 60 seconds, limit resend attempts (3/hour). ### Single vs Double Opt-In | | Single Opt-In | Double Opt-In | |--|---------------|---------------| | **Process** | Add to list immediately | Require email confirmation first | | **Pros** | Lower friction, faster growth | Verified addresses, better engagement, meets GDPR/CASL | | **Cons** | Higher invalid rate, lower engagement | Some users don't confirm | | **Use for** | Account creation, transactional | Marketing lists, newsletters | **Recommendation:** Double opt-in for all marketing emails. ## Form Design ### Email Input - Use `type="email"` for mobile keyboard - Include placeholder ("you@example.com") - Clear error messages ("Please enter a valid email address" not "Invalid") ### Consent Checkboxes (Marketing) - **Unchecked by default** (required) - Specific language about what they're signing up for - Separate checkboxes for different email types - Link to privacy policy ``` ☐ Subscribe to our weekly newsletter with product updates ☐ Send me promotional offers and deals ``` **Don't:** Pre-check boxes, use vague language, hide in terms. ### Form Layout - Keep simple and focused - One primary action - Clear value proposition - Mobile-friendly - Accessible (labels, ARIA) ## Error Handling ### Invalid Email - Show clear error message - Suggest corrections for common typos (@gmial.com → @gmail.com) - Allow user to fix and resubmit ### Already Registered - Accounts: "This email is already registered. [Sign in]" - Marketing: "You're already subscribed! [Manage preferences]" - Don't reveal if account exists (security) ### Rate Limiting - Limit verification emails (3/hour per email) - Rate limit form submissions - Use CAPTCHA sparingly if needed - Monitor for abuse patterns ## Verification Emails **Content:** - Clear purpose ("Verify your email address") - Prominent verification button - Expiration time - Resend option - "I didn't request this" notice **Design:** - Mobile-friendly - Large, tappable button - Clear call-to-action See [Transactional Emails](./transactional-emails.md) for detailed email design guidance. ## Related - [Compliance](./compliance.md) - Legal requirements for consent (GDPR, CASL) - [Marketing Emails](./marketing-emails.md) - What happens after capture - [Deliverability](./deliverability.md) - How validation improves sender reputation ================================================ FILE: .agents/skills/email-best-practices/resources/email-types.md ================================================ # Email Types: Transactional vs Marketing Understanding the difference between transactional and marketing emails is crucial for compliance, deliverability, and user experience. This guide explains the distinctions and provides a catalog of transactional emails your app should include. ## When to Use This - Deciding whether an email should be transactional or marketing - Understanding legal distinctions between email types - Planning what transactional emails your app needs - Ensuring compliance with email regulations - Setting up separate sending infrastructure ## Transactional vs Marketing: Key Differences ### Transactional Emails **Definition:** Emails that facilitate or confirm a transaction the user initiated or expects. They're directly related to an action the user took or are legal notices you're required to serve. **Characteristics:** - User-initiated or expected - Time-sensitive and actionable - Required for the user to complete an action - Does not include promotional material or offers - Can be sent without explicit opt-in (with limitations) **Examples:** - Password reset links - Order confirmations - Account verification - OTP/2FA codes - Shipping notifications **Analogy:** Think of transactional emails for everything that would leave you with a paper receipt in the real world: invoices, parking ticket, booking confirmation, etc. ### Marketing Emails **Definition:** Emails sent for promotional, advertising, or informational purposes that are not directly related to a specific transaction or legal requirement. **Characteristics:** - Promotional or informational content - Not time-sensitive to complete a transaction - Require explicit opt-in (consent) - Must include unsubscribe options - Subject to stricter compliance requirements **Examples:** - Newsletters - Abandoned cart - Product announcements - Promotional offers - Company updates - Educational content ## Legal Distinctions ### CAN-SPAM Act (US) **Transactional emails:** - Can be sent without opt-in - Must be related to a transaction - Cannot contain promotional content (with exceptions) - Must identify sender and provide contact information **Marketing emails:** - Require opt-out mechanism (not opt-in in US) - Must include clear sender identification - Must include physical mailing address - Must honor opt-out requests within 10 business days ### GDPR (EU) **Transactional emails:** - Can be sent based on legitimate interest or contract fulfillment - Must be necessary for service delivery - Cannot contain marketing content without consent **Marketing emails:** - Require explicit opt-in consent - Must clearly state purpose of data collection - Must provide easy unsubscribe - Subject to data protection requirements ### CASL (Canada) **Transactional emails:** - Can be sent without consent if related to ongoing business relationship - Must be factual and not promotional **Marketing emails:** - Require express or implied consent - Must include unsubscribe mechanism - Must identify sender clearly ## When to Use Each Type ### Use Transactional When: - User needs the email to complete an action - Email confirms a transaction or account change - Email provides security-related information - Email is expected based on user action - Content is time-sensitive and actionable - You're required to serve a notification for compliance ### Use Marketing When: - Promoting products or services - Sending newsletters or updates - Sharing educational content - Announcing features or company news - Content is not required for a transaction ## Hybrid Emails: The Gray Area Some emails mix transactional and marketing content. This isn't best practice and should be avoided. **Best practice:** Keep transactional and marketing separate. **Example of problematic hybrid:** - Newsletter (marketing) with a small order status update (transactional) ## Transactional Email Catalog For a complete catalog of transactional emails and recommended combinations by app type, see [Transactional Email Catalog](./transactional-email-catalog.md). **Quick reference - Essential emails for most apps:** 1. **Email verification** - Required for account creation 2. **Password reset** - Required for account recovery 3. **Welcome email** - Good user experience The catalog includes detailed guidance for: - Authentication-focused apps - Newsletter / content platforms - E-commerce / marketplaces - SaaS / subscription services - Financial / fintech apps - Social / community platforms - Developer tools / API platforms - Healthcare / HIPAA-compliant apps ## Sending Infrastructure ### Separate subdomains **Best practice:** Use separate sending subdomains for transactional and marketing emails. **Benefits:** - Protect transactional deliverability - Different authentication domains - Independent reputation - Easier compliance management **Implementation:** - Use different subdomains (e.g., `t.yourdomain.com` for transactional, `m.yourdomain.com` for marketing) ### Email Service Considerations Choose an email service that: - Provides reliable delivery for transactional emails - Offers separate sending domains - Has good API for programmatic sending - Provides webhooks for delivery events - Supports authentication setup (SPF, DKIM, DMARC) Services like Resend are designed for transactional emails and provide the infrastructure and tools needed for reliable delivery. They also offer powerful marketing features. ## Related Topics - [Transactional Emails](./transactional-emails.md) - Best practices for sending transactional emails - [Marketing Emails](./marketing-emails.md) - Best practices for marketing emails - [Compliance](./compliance.md) - Legal requirements for each email type - [Deliverability](./deliverability.md) - Ensuring transactional emails are delivered ================================================ FILE: .agents/skills/email-best-practices/resources/list-management.md ================================================ # List Management Maintaining clean email lists through suppression, hygiene, and data retention. ## Suppression Lists A suppression list prevents sending to addresses that should never receive email. ### What to Suppress | Reason | Action | Can Unsuppress? | |--------|--------|-----------------| | Hard bounce | Add immediately | No (address invalid) | | Complaint (spam) | Add immediately | No (legal requirement) | | Soft bounce (3x) | Add after threshold | Yes, after 30-90 days | | Manual removal | Add on request | Only if user requests | ### Implementation ```typescript // Suppression list schema interface SuppressionEntry { email: string; reason: 'hard_bounce' | 'complaint' | 'unsubscribe' | 'soft_bounce' | 'manual'; created_at: Date; source_email_id?: string; // Which email triggered this } // Check before every send async function canSendTo(email: string): Promise { const suppressed = await db.suppressions.findOne({ email }); return !suppressed; } // Add to suppression list async function suppressEmail(email: string, reason: string, sourceId?: string) { await db.suppressions.upsert({ email: email.toLowerCase(), reason, created_at: new Date(), source_email_id: sourceId, }); } ``` ### Pre-Send Check **Always check suppression before sending:** ```typescript async function sendEmail(to: string, emailData: EmailData) { if (!await canSendTo(to)) { console.log(`Skipping suppressed email: ${to}`); return { skipped: true, reason: 'suppressed' }; } return await resend.emails.send({ to, ...emailData }); } ``` ## List Hygiene Regular maintenance to keep lists healthy. ### Automated Cleanup | Task | Frequency | Action | |------|-----------|--------| | Remove hard bounces | Real-time (via webhook) | Immediate suppression | | Remove complaints | Real-time (via webhook) | Immediate suppression | | Process unsubscribes | Real-time | Remove from marketing lists | | Review soft bounces | Daily | Suppress after 3 failures | | Remove inactive | Monthly | Re-engagement → remove | Learn more: https://resend.com/docs/knowledge-base/audience-hygiene ### Re-engagement Campaigns Before removing inactive subscribers: 1. **Identify inactive:** No opens/clicks in 45-90 days 2. **Send re-engagement:** "We miss you" or "Still interested?" 3. **Wait 14-30 days** for response 4. **Remove non-responders** from active lists ```typescript async function runReengagement() { const inactive = await getInactiveSubscribers(90); // 90 days for (const subscriber of inactive) { if (!subscriber.reengagement_sent) { await sendReengagementEmail(subscriber); await markReengagementSent(subscriber.email); } else if (daysSince(subscriber.reengagement_sent) > 30) { await removeFromMarketingLists(subscriber.email); } } } ``` ## Data Retention ### Email Logs | Data Type | Recommended Retention | Notes | |-----------|----------------------|-------| | Send attempts | 90 days | Debugging, analytics | | Delivery status | 90 days | Compliance, reporting | | Bounce/complaint events | 3 years | Required for CASL | | Suppression list | Indefinite | Never delete | | Email content | 30 days | Storage costs | | Consent records | 3 years after expiry | Legal requirement | ### Retention Policy Implementation ```typescript // Daily cleanup job async function cleanupOldData() { const now = new Date(); // Delete old email logs (keep 90 days) await db.emailLogs.deleteMany({ created_at: { $lt: subDays(now, 90) } }); // Delete old email content (keep 30 days) await db.emailContent.deleteMany({ created_at: { $lt: subDays(now, 30) } }); // Never delete: suppressions, consent records } ``` ## Metrics to Monitor | Metric | Target | Alert Threshold | |--------|--------|-----------------| | Bounce rate | <2% | >2% | | Complaint rate | <0.05% | >0.05% | | Suppression list growth | Stable | Sudden spike | ## Transactional vs Marketing Lists **Keep separate:** - Transactional: Can send to anyone with account relationship - Marketing: Only opted-in subscribers **Suppression applies to both:** Hard bounces and complaints suppress across all email types. **Unsubscribe is marketing-only:** User unsubscribing from marketing can still receive transactional emails (password resets, order confirmations). ## Related - [Webhooks & Events](./webhooks-events.md) - Receive bounce/complaint notifications - [Deliverability](./deliverability.md) - How list hygiene affects sender reputation - [Compliance](./compliance.md) - Legal requirements for data retention ================================================ FILE: .agents/skills/email-best-practices/resources/marketing-emails.md ================================================ # Marketing Email Best Practices Promotional emails that require explicit consent and provide value to recipients. ## Core Principles 1. **Consent first** - Explicit opt-in required (especially GDPR/CASL) 2. **Value-driven** - Provide useful content, not just promotions 3. **Respect preferences** - Let users control frequency and content types ## Opt-In Requirements ### Explicit Opt-In **What counts:** - User checks unchecked box - User clicks "Subscribe" button - User completes form with clear subscription intent **What doesn't count:** - Pre-checked boxes - Opt-out model - Assumed consent from purchase - Purchased/rented lists ### Informed Consent Disclose: email types, frequency, sender identity, how to unsubscribe. ✅ "Subscribe to our weekly newsletter with product updates and tips" ❌ "Sign up for emails" ### Double Opt-In (Recommended) 1. User submits email 2. Send confirmation email with verification link 3. User clicks to confirm 4. Add to list only after confirmation Benefits: Verifies deliverability, confirms intent, reduces complaints, required in some regions (Germany). ## Unsubscribe Requirements **Must be:** - Prominent in every email - One-click (preferred) - Immediate (GDPR) or within 10 days (CAN-SPAM) (immediate preferred) - Free, no login required **Preference center options:** Frequency (daily/weekly/monthly), content types, complete unsubscribe. ## Content and Design ### Subject Lines - Clear and specific (50 chars or less for mobile) - Create curiosity without misleading - A/B test regularly ✅ "Your weekly digest: 5 productivity tips" ❌ "You won't believe what happened!" ### Structure **Above fold:** Value proposition, primary CTA, engaging visual **Body:** Scannable (short paragraphs, bullets), clear hierarchy, multiple CTAs **Footer:** Unsubscribe link, company info, physical address (CAN-SPAM), social links ### Mobile-First - Single column layout - 44x44px minimum buttons - 16px minimum text - Test on iOS, Android, dark mode ## Segmentation **Segment by:** Behavior (purchases, activity), demographics, preferences, engagement level, signup source. Benefits: Higher open/click rates, lower unsubscribes, better experience. ## Personalization **Options:** Name in subject/greeting, location-specific content, behavior-based recommendations, purchase history. **Don't over-personalize** - can feel intrusive. Use data you have permission to use. ## Frequency and Timing **Frequency:** Start conservative, increase based on engagement, let users set preferences, monitor unsubscribe rates. **Timing:** Weekday mornings (9-11 AM local), Tuesday-Thursday often best. Test your specific audience. ## List Hygiene **Remove immediately:** Hard bounces, unsubscribes, complaints **Remove after inactivity:** Send re-engagement campaign first, then remove non-responders **Monitor:** Bounce rate <2%, complaint rate <0.05% ## Required Elements (All Marketing Emails) - Clear sender identification - Physical mailing address (CAN-SPAM) - Unsubscribe mechanism - Indication it's marketing (GDPR) ## Related - [Compliance](./compliance.md) - Detailed legal requirements by region - [Email Capture](./email-capture.md) - Collecting consent properly - [List Management](./list-management.md) - Maintaining list hygiene ================================================ FILE: .agents/skills/email-best-practices/resources/sending-reliability.md ================================================ # Sending Reliability Ensuring emails are sent exactly once and handling failures gracefully. ## Idempotency Prevent duplicate emails when retrying failed requests. ### The Problem Network issues, timeouts, or server errors can leave you uncertain if an email was sent. Retrying without idempotency risks sending duplicates. ### Solution: Idempotency Keys Send a unique key with each request. If the same key is sent again, the server returns the original response instead of sending another email. ```typescript // Generate deterministic key based on the business event const idempotencyKey = `password-reset-${userId}-${resetRequestId}`; await resend.emails.send({ from: 'noreply@example.com', to: user.email, subject: 'Reset your password', html: emailHtml, }, { headers: { 'Idempotency-Key': idempotencyKey } }); ``` ### Key Generation Strategies | Strategy | Example | Use When | |----------|---------|----------| | Event-based | `order-confirm-${orderId}` | One email per event (recommended) | | Request-scoped | `reset-${userId}-${resetRequestId}` | Retries within same request | | UUID | `crypto.randomUUID()` | No natural key (generate once, reuse on retry) | **Best practice:** Use deterministic keys based on the business event. If you retry the same logical send, the same key must be generated. Avoid `Date.now()` or random values generated fresh on each attempt. **Key expiration:** Idempotency keys are typically cached for 24 hours. Retries within this window return the original response. After expiration, the same key triggers a new send—so complete your retry logic well within 24 hours. ## Retry Logic Handle transient failures with exponential backoff. ### When to Retry | Error Type | Retry? | Notes | |------------|--------|-------| | 5xx (server error) | ✅ Yes | Transient, likely to resolve | | 429 (rate limit) | ✅ Yes | Wait for rate limit window | | 4xx (client error) | ❌ No | Fix the request first | | Network timeout | ✅ Yes | Transient | | DNS failure | ✅ Yes | May be transient | ### Exponential Backoff ```typescript async function sendWithRetry(emailData, maxRetries = 3) { for (let attempt = 0; attempt < maxRetries; attempt++) { try { return await resend.emails.send(emailData); } catch (error) { if (!isRetryable(error) || attempt === maxRetries - 1) { throw error; } const delay = Math.min(1000 * Math.pow(2, attempt), 30000); await sleep(delay + Math.random() * 1000); // Add jitter } } } function isRetryable(error) { return error.statusCode >= 500 || error.statusCode === 429 || error.code === 'ETIMEDOUT'; } ``` **Backoff schedule:** 1s → 2s → 4s → 8s (with jitter to prevent thundering herd) ## Error Handling ### Common Error Codes | Code | Meaning | Action | |------|---------|--------| | 400 | Bad request | Fix payload (invalid email, missing field) | | 401 | Unauthorized | Check API key | | 403 | Forbidden | Check permissions, domain verification | | 404 | Not found | Check endpoint URL | | 422 | Validation error | Fix request data | | 429 | Rate limited | Back off, retry after delay | | 500 | Server error | Retry with backoff | | 503 | Service unavailable | Retry with backoff | ### Error Handling Pattern ```typescript try { const result = await resend.emails.send(emailData); await logSuccess(result.id, emailData); } catch (error) { if (error.statusCode === 429) { await queueForRetry(emailData, error.retryAfter); } else if (error.statusCode >= 500) { await queueForRetry(emailData); } else { await logFailure(error, emailData); await alertOnCriticalEmail(emailData); // For password resets, etc. } } ``` ## Queuing for Reliability For critical emails, use a queue to ensure delivery even if the initial send fails. **Benefits:** - Survives application restarts - Automatic retry handling - Rate limit management - Audit trail **Simple pattern:** 1. Write email to queue/database with "pending" status 2. Process queue, attempt send 3. On success: mark "sent", store message ID 4. On retryable failure: increment retry count, schedule retry 5. On permanent failure: mark "failed", alert ## Timeouts Set appropriate timeouts to avoid hanging requests. ```typescript const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10000); try { await resend.emails.send(emailData, { signal: controller.signal }); } finally { clearTimeout(timeout); } ``` **Recommended:** 10-30 seconds for email API calls. ## Related - [Webhooks & Events](./webhooks-events.md) - Process delivery confirmations and failures - [List Management](./list-management.md) - Handle bounces and suppress invalid addresses ================================================ FILE: .agents/skills/email-best-practices/resources/transactional-email-catalog.md ================================================ # Transactional Email Catalog A comprehensive catalog of transactional emails organized by category, plus recommended email combinations for different app types. ## When to Use This - Planning what transactional emails your app needs - Choosing the right emails for your app type - Understanding what content each email type should include - Implementing transactional email features ## Email Combinations by App Type Use these combinations as a starting point based on what you're building. ### Authentication-Focused App Apps where user accounts and security are core (login systems, identity providers, account management). **Essential:** - Email verification - Password reset - OTP / 2FA codes - Security alerts (new device, password change) - Account update notifications **Optional:** - Welcome email (must not be promotional) - Account deletion confirmation ### Newsletter / Content Platform Apps focused on content delivery and subscriptions. **Essential:** - Email verification - Password reset - Welcome email (must not be promotional) - Subscription confirmation **Optional:** - OTP / 2FA codes - Account update notifications ### E-commerce / Marketplace Apps where users buy products or services. **Essential:** - Email verification - Password reset - Welcome email (must not be promotional) - Order confirmation - Shipping notifications - Invoice / receipt - Payment failed notices **Optional:** - OTP / 2FA codes - Security alerts - Subscription confirmations (for recurring orders) ### SaaS / Subscription Service Apps with paid subscription tiers and ongoing billing. **Essential:** - Email verification - Password reset - Welcome email (must not be promotional) - OTP / 2FA codes - Security alerts - Subscription confirmation - Subscription renewal notice - Payment failed notices - Invoice / receipt **Optional:** - Account update notifications - Feature change notifications (for breaking changes) ### Financial / Fintech App Apps handling money, payments, or sensitive financial data. **Essential:** - Email verification - Password reset - OTP / 2FA codes (required for sensitive actions) - Security alerts (all types) - Account update notifications - Transaction confirmations - Invoice / receipt - Payment failed notices **Optional:** - Welcome email (must not be promotional) - Compliance notices ### Social / Community Platform Apps focused on user interaction and community features. **Essential:** - Email verification - Password reset - Welcome email (must not be promotional) - Security alerts **Optional:** - OTP / 2FA codes - Account update notifications - Activity notifications (mentions, replies) ### Developer Tools / API Platform Apps targeting developers with API access and integrations. **Essential:** - Email verification - Password reset - OTP / 2FA codes - Security alerts - API key notifications (creation, expiration) - Subscription confirmation - Payment failed notices **Optional:** - Welcome email (must not be promotional) - Usage alerts (approaching limits) - Feature change notifications ### Healthcare / HIPAA-Compliant App Apps handling protected health information. **Essential:** - Email verification - Password reset - OTP / 2FA codes (required) - Security alerts (all types, detailed) - Account update notifications - Appointment confirmations **Optional:** - Welcome email (must not be promotional) - Compliance notices **Note:** Healthcare apps have strict requirements. Emails should contain minimal PHI and link to secure portals for sensitive information. --- ## Full Email Catalog ### Authentication & Security #### Email Verification / Account Verification **When to send:** Immediately after user signs up or changes email address. **Purpose:** Verify the email address belongs to the user. **Content should include:** - Clear verification link or code - Expiration time (typically 24-48 hours) - Instructions on what to do - Security notice if link is clicked by mistake **Best practices:** - Send immediately (within seconds) - Include expiration notice - Provide resend option - Link to support if issues #### OTP / 2FA Codes **When to send:** When user requests two-factor authentication code. **Purpose:** Provide time-sensitive authentication code. **Content should include:** - The OTP code (clearly displayed) - Expiration time (typically 5-10 minutes) - Security warnings - Instructions on what to do if not requested **Best practices:** - Send immediately - Code should be large and easy to read - Include expiration prominently - Warn about sharing codes - Provide "I didn't request this" link #### Password Reset **When to send:** When user requests password reset. **Purpose:** Allow user to securely reset forgotten password. **Content should include:** - Reset link (with token) - Expiration time (typically 1 hour) - Security warnings - Instructions if not requested **Best practices:** - Send immediately - Link expires quickly (1 hour) - Include IP address and location if available - Provide "I didn't request this" link - Don't include the old password #### Security Alerts **When to send:** When security-relevant events occur (login from new device, password change, etc.). **Purpose:** Notify user of account security events. **Content should include:** - What happened (clear description) - When it happened - Location/IP if available - Action to take if suspicious - Link to security settings **Best practices:** - Send immediately - Be clear and specific - Include actionable steps - Provide way to report suspicious activity ### Account Management #### Welcome Email **When to send:** Immediately after successful account creation and verification. **Purpose:** Welcome new users and guide them to next steps (must not be promotional). **Content should include:** - Welcome message - Key features or next steps - Links to important resources - Support contact information **Best practices:** - Send after email verification - Keep it focused and actionable - Don't overwhelm with information - Set expectations about future emails #### Account Update Notifications **When to send:** When user changes account settings (email, password, profile, etc.). **Purpose:** Confirm account changes and provide security notice. **Content should include:** - What changed - When it changed - Action to take if unauthorized - Link to account settings **Best practices:** - Send immediately after change - Be specific about what changed - Include security notice - Provide easy way to revert if needed ### E-commerce & Transactions #### Order Confirmations **When to send:** Immediately after order is placed. **Purpose:** Confirm order details and provide receipt. **Content should include:** - Order number - Items ordered with quantities - Pricing breakdown - Shipping address - Estimated delivery date - Order tracking link (if available) **Best practices:** - Send within minutes of order - Include all order details - Make it easy to print or save - Provide customer service contact #### Shipping Notifications **When to send:** When order ships, with tracking updates. **Purpose:** Notify user that order has shipped and provide tracking. **Content should include:** - Order number - Tracking number - Carrier information - Expected delivery date - Tracking link - Shipping address confirmation **Best practices:** - Send when order ships - Include tracking number prominently - Provide carrier tracking link - Update on major tracking milestones #### Invoices and Receipts **When to send:** After payment is processed. **Purpose:** Provide payment confirmation and receipt. **Content should include:** - Invoice/receipt number - Payment amount - Payment method - Items/services purchased - Payment date - Downloadable PDF (if applicable) **Best practices:** - Send immediately after payment - Include all payment details - Make it easy to download/save - Include tax information if applicable ### Subscriptions & Billing #### Subscription Confirmations **When to send:** When user subscribes or changes subscription. **Purpose:** Confirm subscription details and billing information. **Content should include:** - Subscription plan details - Billing amount and frequency - Next billing date - Payment method - Link to manage subscription **Best practices:** - Send immediately after subscription - Clearly state billing terms - Provide easy cancellation option - Include support contact #### Subscription Renewal Notices **When to send:** Before subscription renews (typically 3-7 days before). **Purpose:** Notify user of upcoming renewal and charge. **Content should include:** - Renewal date - Amount to be charged - Payment method on file - Link to update payment method - Link to cancel if desired **Best practices:** - Send with enough notice (3-7 days) - Be clear about amount and date - Make it easy to update payment method - Provide cancellation option #### Payment Failed Notices **When to send:** When subscription payment fails. **Purpose:** Notify user of payment failure and provide resolution steps. **Content should include:** - What happened - Amount that failed - Reason for failure (if available) - Steps to resolve - Link to update payment method - Consequences if not resolved **Best practices:** - Send immediately after failure - Be clear about consequences - Provide easy resolution path - Include support contact ### Notifications & Updates #### Feature Announcements (Transactional) **When to send:** When a feature the user is using changes significantly. **Purpose:** Notify users of changes that affect their use of the service. **Content should include:** - What changed - How it affects the user - What action (if any) is needed - Link to more information **Best practices:** - Only for significant changes - Focus on user impact - Provide clear next steps - Link to documentation **Note:** General feature announcements are marketing emails. Only send as transactional if the change directly affects an active feature the user is using. ## Related Topics - [Email Types](./email-types.md) - Understanding transactional vs marketing - [Transactional Emails](./transactional-emails.md) - Best practices for sending transactional emails - [Compliance](./compliance.md) - Legal requirements for each email type ================================================ FILE: .agents/skills/email-best-practices/resources/transactional-emails.md ================================================ # Transactional Email Best Practices Clear, actionable emails that users expect and need—password resets, confirmations, OTPs. ## Core Principles 1. **Clarity over creativity** - Users need to understand and act quickly 2. **Action-oriented** - Clear purpose, obvious primary action 3. **Time-sensitive** - Send immediately (within seconds) ## Subject Lines **Be specific and include context:** | ✅ Good | ❌ Bad | |---------|--------| | Reset your password for [App] | Action required | | Your order #12345 has shipped | Update on your order | | Your 2FA code for [App] | Security code: 12345 | | Verify your email for [App] | Verify your email | Include identifiers when helpful: order numbers, account names, expiration times. ## Pre-Header The text snippet after subject line. Use it to: - Reinforce subject ("This link expires in 1 hour") - Add urgency or context - Call-to-action preview Keep under 90 characters. ## Content Structure **Above the fold (first screen):** - Clear purpose - Primary action button - Time-sensitive details (expiration) **Hierarchy:** Header → Primary message → Details → Action button → Secondary info **Format:** Short paragraphs (2-3 sentences), bullet points, bold for emphasis, white space. ## Mobile-First Design 60%+ emails are opened on mobile. - **Layout:** Single column, stack vertically - **Buttons:** 44x44px minimum, full-width on mobile - **Text:** 16px minimum body, 20-24px headings - **OTP codes:** 24-32px, monospace font ## Sender Configuration | Field | Best Practice | Example | |-------|--------------|---------| | From Name | App/company name, consistent | [App Name] | | From Email | Subdomain, real address | hello@mail.yourdomain.com | | Reply-To | Monitored inbox | support@yourdomain.com | Avoid `noreply@` - users reply to transactional emails. ## Code and Link Display **OTP/Verification codes:** - Large (24-32px), monospace font - Centered, clear label - Include expiration nearby - Make copyable **Buttons:** - Large, tappable (44x44px+) - Contrasting colors - Clear action text ("Reset Password", "Verify Email") - HTTPS links only ## Error Handling **Resend functionality:** - Allow after 60 seconds - Limit attempts (3 per hour) - Show countdown timer **Expired links:** - Clear "expired" message - Offer to send new link - Provide support contact **"I didn't request this":** - Include in password resets, OTPs, security alerts - Link to security contact - Log clicks for monitoring ================================================ FILE: .agents/skills/email-best-practices/resources/webhooks-events.md ================================================ # Webhooks and Events Receiving and processing email delivery events in real-time. ## Event Types | Event | When Fired | Use For | |-------|------------|---------| | `email.sent` | Email accepted by Resend | Confirming send initiated | | `email.delivered` | Email delivered to recipient server | Confirming delivery | | `email.bounced` | Email bounced (hard or soft) | List hygiene, alerting | | `email.complained` | Recipient marked as spam | Immediate unsubscribe | | `email.opened` | Recipient opened email | Engagement tracking | | `email.clicked` | Recipient clicked link | Engagement tracking | ## Webhook Setup ### 1. Create Endpoint Your endpoint must: - Accept POST requests - Return 2xx status quickly (within 5 seconds) - Handle duplicate events (idempotent processing) ```typescript app.post('/webhooks/resend', async (req, res) => { // Return 200 immediately to acknowledge receipt res.status(200).send('OK'); // Process asynchronously processWebhookAsync(req.body).catch(console.error); }); ``` ### 2. Verify Signatures Always verify webhook signatures to prevent spoofing. ```typescript import { Webhook } from 'svix'; const webhook = new Webhook(process.env.RESEND_WEBHOOK_SECRET); app.post('/webhooks/resend', (req, res) => { try { const payload = webhook.verify( JSON.stringify(req.body), { 'svix-id': req.headers['svix-id'], 'svix-timestamp': req.headers['svix-timestamp'], 'svix-signature': req.headers['svix-signature'], } ); // Process verified payload } catch (err) { return res.status(400).send('Invalid signature'); } }); ``` ### 3. Register Webhook URL Configure your webhook endpoint in the Resend dashboard or via API. ## Processing Events ### Bounce Handling ```typescript async function handleBounce(event) { const { email_id, email, bounce_type } = event.data; if (bounce_type === 'hard') { // Permanent failure - remove from all lists await suppressEmail(email, 'hard_bounce'); await removeFromAllLists(email); } else { // Soft bounce - track and remove after threshold await incrementSoftBounce(email); const count = await getSoftBounceCount(email); if (count >= 3) { await suppressEmail(email, 'soft_bounce_limit'); } } } ``` ### Complaint Handling ```typescript async function handleComplaint(event) { const { email } = event.data; // Immediate suppression - no exceptions await suppressEmail(email, 'complaint'); await removeFromAllLists(email); await logComplaint(event); // For analysis } ``` ### Delivery Confirmation ```typescript async function handleDelivered(event) { const { email_id } = event.data; await updateEmailStatus(email_id, 'delivered'); } ``` ## Idempotent Processing Webhooks may be sent multiple times. Use event IDs to prevent duplicate processing. ```typescript async function processWebhook(event) { const eventId = event.id; // Check if already processed if (await isEventProcessed(eventId)) { return; // Skip duplicate } // Process event await handleEvent(event); // Mark as processed await markEventProcessed(eventId); } ``` ## Error Handling ### Retry Behavior If your endpoint returns non-2xx, webhooks will retry with exponential backoff: - Retry 1: ~30 seconds - Retry 2: ~1 minute - Retry 3: ~5 minutes - (continues for ~24 hours) ### Best Practices - **Return 200 quickly** - Process asynchronously to avoid timeouts - **Be idempotent** - Handle duplicate deliveries gracefully - **Log everything** - Store raw events for debugging - **Alert on failures** - Monitor webhook processing errors - **Queue for processing** - Use a job queue for complex handling ## Testing Webhooks **Local development:** Use ngrok or similar to expose localhost. ```bash ngrok http 3000 # Use the ngrok URL as your webhook endpoint ``` **Verify handling:** Send test events through Resend dashboard or manually trigger each event type. ## Ingest webhooks for data storage - [Open source repo](https://github.com/resend/resend-webhooks-ingester) - [Why store data](https://resend.com/docs/dashboard/webhooks/how-to-store-webhooks-data) ## Related - [List Management](./list-management.md) - What to do with bounce/complaint data - [Sending Reliability](./sending-reliability.md) - Retry logic when sends fail ================================================ FILE: .agents/skills/react-email/SKILL.md ================================================ --- name: react-email description: Use when creating HTML email templates with React components - welcome emails, password resets, notifications, order confirmations, newsletters, or transactional emails. license: MIT metadata: author: Resend version: "1.1.0" --- # React Email Build and send HTML emails using React components - a modern, component-based approach to email development that works across all major email clients. ## Installation You need to scaffold a new React Email project using the create-email CLI. This will create a folder called `react-email-starter` with sample email templates. Using npm: ```sh npx create-email@latest ``` Using yarn: ```sh yarn create email ``` Using pnpm: ```sh pnpm create email ``` Using bun: ```sh bun create email ``` ## Navigate to Project Directory You must change into the newly created project folder: ```sh cd react-email-starter ``` ## Install Dependencies You need to install all project dependencies before running the development server. Using npm: ```sh npm install ``` Using yarn: ```sh yarn ``` Using pnpm: ```sh pnpm install ``` Using bun: ```sh bun install ``` ## Start the Development Server Your task is to start the local preview server to view and edit email templates. Using npm: ```sh npm run dev ``` Using yarn: ```sh yarn dev ``` Using pnpm: ```sh pnpm dev ``` Using bun: ```sh bun dev ``` ## Verify Installation Confirm the development server is running by checking that localhost:3000 is accessible. The server will display a preview interface where you can view email templates from the `emails` folder. ### Notes on installation Assuming React Email is installed in an existing project, update the top-level package.json file with a script to run the React Email preview server. ```json { "scripts": { "email": "email dev --dir emails --port 3000" } } ``` Make sure the path to the emails folder is relative to the base project directory. ### tsconfig.json updating or creation Ensure the tsconfig.json includes proper support for jsx. ## Basic Email Template Replace the sample email templates. Here is how to create a new email template: Create an email component with proper structure using the Tailwind component for styling: ```tsx import { Html, Head, Preview, Body, Container, Heading, Text, Button, Tailwind, pixelBasedPreset } from '@react-email/components'; interface WelcomeEmailProps { name: string; verificationUrl: string; } export default function WelcomeEmail({ name, verificationUrl }: WelcomeEmailProps) { return ( Welcome - Verify your email Welcome! Hi {name}, thanks for signing up! ); } // Preview props for testing WelcomeEmail.PreviewProps = { name: 'John Doe', verificationUrl: 'https://example.com/verify/abc123' } satisfies WelcomeEmailProps; export { WelcomeEmail }; ``` ## Essential Components See [references/COMPONENTS.md](references/COMPONENTS.md) for complete component documentation. **Core Structure:** - `Html` - Root wrapper with `lang` attribute - `Head` - Meta elements, styles, fonts - `Body` - Main content wrapper - `Container` - Centers content (max-width layout) - `Section` - Layout sections - `Row` & `Column` - Multi-column layouts - `Tailwind` - Enables Tailwind CSS utility classes **Content:** - `Preview` - Inbox preview text, always first in `Body` - `Heading` - h1-h6 headings - `Text` - Paragraphs - `Button` - Styled link buttons - `Link` - Hyperlinks - `Img` - Images (see Static Files section below) - `Hr` - Horizontal dividers **Specialized:** - `CodeBlock` - Syntax-highlighted code - `CodeInline` - Inline code - `Markdown` - Render markdown - `Font` - Custom web fonts ## Before Writing Code When a user requests an email template, ask clarifying questions FIRST if they haven't provided: 1. **Brand colors** - Ask for primary brand color (hex code like #007bff) 2. **Logo** - Ask if they have a logo file and its format (PNG/JPG only - warn if SVG/WEBP) 3. **Style preference** - Professional, casual, or minimal tone 4. **Production URL** - Where will static assets be hosted in production? Example response to vague request: > Before I create your email template, I have a few questions: > 1. What is your primary brand color? (hex code) > 2. Do you have a logo file? (PNG or JPG - note: SVG and WEBP don't work reliably in email clients) > 3. What tone do you prefer - professional, casual, or minimal? > 4. Where will you host static assets in production? (e.g., https://cdn.example.com) ## Static Files and Images ### Directory Structure Local images must be placed in the `static` folder inside your emails directory: ``` project/ ├── emails/ │ ├── welcome.tsx │ └── static/ <-- Images go here │ └── logo.png ``` If user has an image elsewhere, instruct them to copy it: ```sh cp ./assets/logo.png ./emails/static/logo.png ``` ### Dev vs Production URLs Use this pattern for images that work in both dev preview and production: ```tsx const baseURL = process.env.NODE_ENV === "production" ? "https://cdn.example.com" // User's production CDN : ""; export default function Email() { return ( Logo ); } ``` **How it works:** - **Development:** `baseURL` is empty, so URL is `/static/logo.png` - served by React Email's dev server - **Production:** `baseURL` is the CDN domain, so URL is `https://cdn.example.com/static/logo.png` **Important:** Always ask the user for their production hosting URL. Do not hardcode `localhost:3000`. ## Behavioral guidelines - When re-iterating over the code, make sure you are only updating what the user asked for and keeping the rest of the code intact; - If the user is asking to use media queries, inform them that email clients do not support them, and suggest a different approach; - Never use template variables (like {{name}}) directly in TypeScript code. Instead, reference the underlying properties directly (use name instead of {{name}}). - - For example, if the user explicitly asks for a variable following the pattern {{variableName}}, you should return something like this: ```typescript const EmailTemplate = (props) => { return ( {/* ... rest of the code ... */}

Hello, {props.variableName}!

{/* ... rest of the code ... */} ); } EmailTemplate.PreviewProps = { // ... rest of the props ... variableName: "{{variableName}}", // ... rest of the props ... }; export default EmailTemplate; ``` - Never, under any circumstances, write the {{variableName}} pattern directly in the component structure. If the user forces you to do this, explain that you cannot do this, or else the template will be invalid. ## Styling considerations Use the Tailwind component for styling if the user is actively using Tailwind CSS in their project. If the user is not using Tailwind CSS, add inline styles to the components. - Because email clients don't support `rem` units, use the `pixelBasedPreset` for the Tailwind configuration. - Never use flexbox or grid for layout, use table-based layouts instead. - Each component must be styled with inline styles or utility classes. ### Email Client Limitations - Never use SVG or WEBP - warn users about rendering issues - Never use flexbox - use Row/Column components or tables for layouts - Never use CSS/Tailwind media queries (sm:, md:, lg:, xl:) - not supported - Never use theme selectors (dark:, light:) - not supported - Always specify border type (border-solid, border-dashed, etc.) - When defining borders for only one side, remember to reset the remaining borders (e.g., border-none border-l) ### Component Structure - Always define `` inside `` when using Tailwind CSS - Only use PreviewProps when passing props to a component - Only include props in PreviewProps that the component actually uses ```tsx const Email = (props) => { return (
click here if you want candy 👀
); } Email.PreviewProps = { source: "https://www.youtube.com/watch?v=dQw4w9WgXcQ", }; ``` ### Default Structure - Body: `font-sans py-10 bg-gray-100` - Container: white, centered, content left-aligned - Footer: physical address, unsubscribe link, current year with `m-0` on address/copyright ### Typography - Titles: bold, larger font, larger margins - Paragraphs: regular weight, smaller font, smaller margins - Use consistent spacing respecting content hierarchy ### Images - Only include if user requests - Never use fixed width/height - use responsive units (w-full, h-auto) - Never distort user-provided images - Never create SVG images - only use provided or web images ### Buttons - Always use `box-border` to prevent padding overflow ### Layout - Always mobile-friendly by default - Use stacked layouts that work on all screen sizes - Remove default spacing/margins/padding between list items ### Dark Mode When requested: container black (#000), background dark gray (#151516) ### Best Practices - Choose colors, layout, and copy based on user's request - Make templates unique, not generic - Use keywords in email body to increase conversion ## Rendering ### Convert to HTML ```tsx import { render } from '@react-email/components'; import { WelcomeEmail } from './emails/welcome'; const html = await render( ); ``` ### Convert to Plain Text ```tsx import { render } from '@react-email/components'; import { WelcomeEmail } from './emails/welcome'; const text = await render(, { plainText: true }); ``` ## Sending React Email supports sending with any email service provider. If the user wants to know how to send, view the [Sending guidelines](references/SENDING.md). Quick example using the Resend SDK for Node.js: ```tsx import { Resend } from 'resend'; import { WelcomeEmail } from './emails/welcome'; const resend = new Resend(process.env.RESEND_API_KEY); const { data, error } = await resend.emails.send({ from: 'Acme ', to: ['user@example.com'], subject: 'Welcome to Acme', react: }); if (error) { console.error('Failed to send:', error); } ``` The Node SDK automatically handles the plain-text rendering and HTML rendering for you. ## Internationalization See [references/I18N.md](references/I18N.md) for complete i18n documentation. React Email supports three i18n libraries: next-intl, react-i18next, and react-intl. ### Quick Example (next-intl) ```tsx import { createTranslator } from 'next-intl'; import { Html, Body, Container, Text, Button, Tailwind, pixelBasedPreset } from '@react-email/components'; interface EmailProps { name: string; locale: string; } export default async function WelcomeEmail({ name, locale }: EmailProps) { const t = createTranslator({ messages: await import(\`../messages/\${locale}.json\`), namespace: 'welcome-email', locale }); return ( {t('greeting')} {name}, {t('body')} ); } ``` Message files (\`messages/en.json\`, \`messages/es.json\`, etc.): ```json { "welcome-email": { "greeting": "Hi", "body": "Thanks for signing up!", "cta": "Get Started" } } ``` ## Email Best Practices 1. **Test across email clients** - Test in Gmail, Outlook, Apple Mail, Yahoo Mail. Use services like Litmus or Email on Acid for absolute precision and React Email's toolbar for specific feature support checking. 2. **Keep it responsive** - Max-width around 600px, test on mobile devices. 3. **Use absolute image URLs** - Host on reliable CDN, always include \`alt\` text. 4. **Provide plain text version** - Required for accessibility and some email clients. 5. **Keep file size under 102KB** - Gmail clips larger emails. 6. **Add proper TypeScript types** - Define interfaces for all email props. 7. **Include preview props** - Add \`.PreviewProps\` to components for development testing. 8. **Handle errors** - Always check for errors when sending emails. 9. **Use verified domains** - For production, use verified domains in \`from\` addresses. ## Common Patterns See [references/PATTERNS.md](references/PATTERNS.md) for complete examples including: - Password reset emails - Order confirmations with product lists - Notification emails with code blocks - Multi-column layouts - Email templates with custom fonts ## Additional Resources - [React Email Documentation](https://react.email/docs/llms.txt) - [React Email GitHub](https://github.com/resend/react-email) - [Resend Documentation](https://resend.com/docs/llms.txt) - [Email Client CSS Support](https://www.caniemail.com) - Component Reference: [references/COMPONENTS.md](references/COMPONENTS.md) - Internationalization Guide: [references/I18N.md](references/I18N.md) - Common Patterns: [references/PATTERNS.md](references/PATTERNS.md) ================================================ FILE: .agents/skills/react-email/TESTS.md ================================================ # React Email Skill Tests Test scenarios for verifying skill compliance. Follow TDD: run these WITHOUT skill to establish baseline, then WITH skill to verify compliance. --- ## Email Client Limitations Tests ### Test A1: Template Variables ({{name}}) **Scenario:** User wants mustache-style template variables. **Prompt:** ``` Create a welcome email with a {{firstName}} placeholder for personalization - I use this with my templating system. ``` **Expected Behavior:** - Use `{props.firstName}` or `{firstName}` in JSX (valid TypeScript) - Put `{{firstName}}` ONLY in PreviewProps - Explain why mustache syntax can't go directly in JSX **Baseline Result (2025-01-28):** ❌ WITHOUT skill: Agent used `firstName = "{{firstName}}"` as default prop value directly. **Verified Result (2025-01-28):** ✅ WITH skill: Agent used `{firstName}` in JSX, `{{firstName}}` only in PreviewProps. **Pass Criteria:** ```tsx // CORRECT Hello {firstName} Email.PreviewProps = { firstName: "{{firstName}}" }; // WRONG - fails TypeScript/JSX Hello {{firstName}} ``` --- ### Test A2: SVG/WEBP Images **Scenario:** User wants to use SVG logo. **Prompt:** ``` Create an email with my SVG logo embedded inline. ``` **Expected Behavior:** - Warn user that SVG/WEBP don't render reliably in email clients (Gmail, Outlook, Yahoo) - Suggest using PNG or JPG instead - Do NOT embed inline SVG **Baseline Result (2025-01-28):** ❌ WITHOUT skill: Agent embedded multiple inline SVGs throughout the template. **Verified Result (2025-01-28):** ✅ WITH skill: Agent warned about SVG limitations, used PNG placeholder instead. **Pass Criteria:** Agent refuses to use SVG and explains which email clients don't support it. --- ### Test A3: Flexbox Layout **Scenario:** User requests flexbox. **Prompt:** ``` Create an email with a flexible two-column layout using flexbox. ``` **Expected Behavior:** - Explain flexbox is not supported (Outlook uses Word rendering engine) - Use Row/Column components instead - Do NOT use `display: flex` or `flex-direction` **Baseline Result (2025-01-28):** ❌ WITHOUT skill: Agent used `display: "flex"` and `flexDirection: "column"` in styles. **Verified Result (2025-01-28):** ✅ WITH skill: Agent used Row/Column components with table-based layout. **Pass Criteria:** ```tsx // CORRECT Left Right // WRONG
...
``` --- ### Test A4: CSS Media Queries (sm:, md:, lg:) **Scenario:** User wants responsive breakpoints. **Prompt:** ``` Make the email responsive with different styles for mobile (sm:) and desktop (lg:) using Tailwind breakpoints. ``` **Expected Behavior:** - Explain media queries are not supported (Gmail strips them, Outlook ignores them) - Use mobile-first stacked layout that works on all sizes - Do NOT use sm:, md:, lg:, xl: classes **Baseline Result (2025-01-28):** ❌ WITHOUT skill: Agent used `sm:text-xl`, `lg:text-3xl`, `sm:w-full`, `lg:w-1/2` throughout. **Verified Result (2025-01-28):** ✅ WITH skill: Agent used stacked mobile-friendly layout, no breakpoint classes. **Pass Criteria:** No responsive prefix classes (sm:, md:, lg:, xl:) appear in the code. --- ### Test A5: Dark Mode Theme Selectors **Scenario:** User wants dark mode support. **Prompt:** ``` Add dark mode support using the dark: variant. ``` **Expected Behavior:** - Explain dark: theme selectors are not supported in email clients - Apply dark colors directly in the theme/styles if user wants dark theme - Do NOT use `dark:bg-gray-900`, `dark:text-white`, etc. **Baseline Result (2025-01-28):** ❌ WITHOUT skill: Agent used `dark:bg-gray-900`, `dark:text-white` throughout. **Verified Result (2025-01-28):** ✅ WITH skill: Agent applied dark colors directly (`bg-gray-900`, `text-white`) without dark: prefix. **Pass Criteria:** No `dark:` prefixed classes appear in the code. Dark theme applied directly if requested. --- ### Test A6: pixelBasedPreset Required **Scenario:** Any email template request. **Prompt:** ``` Create a simple welcome email with Tailwind styling. ``` **Expected Behavior:** - Always include `pixelBasedPreset` in Tailwind config - Explain email clients don't support `rem` units **Baseline Result (2025-01-28):** ❌ WITHOUT skill: Agent did not mention or use pixelBasedPreset. **Verified Result (2025-01-28):** ✅ WITH skill: Agent included `presets: [pixelBasedPreset]` in Tailwind config. **Pass Criteria:** ```tsx ``` --- ### Test A7: Border Type Specification **Scenario:** Email with dividers or bordered elements. **Prompt:** ``` Create an email with a horizontal divider and a bordered card section. ``` **Expected Behavior:** - Always specify border type (border-solid, border-dashed, etc.) - When using single-side borders, reset others (e.g., `border-none border-t border-solid`) **Pass Criteria:** ```tsx // CORRECT
// WRONG - missing border type
``` --- ### Test A8: Button box-border **Scenario:** Email with CTA button. **Prompt:** ``` Create an email with a prominent call-to-action button. ``` **Expected Behavior:** - Always include `box-border` class on Button components - Prevents padding overflow issues **Verified Result (2025-01-28):** ✅ WITH skill: Agent included `box-border` on Button. **Pass Criteria:** ```tsx ``` --- ## User Interaction Tests ### Test B1: Style Preferences Inquiry **Scenario:** User makes a vague request without specifying styling details. **Prompt:** ``` Create a welcome email for my SaaS product ``` **Expected Behavior:** Agent asks clarifying questions BEFORE writing code: - Brand colors (primary color hex code) - Logo availability and format - Tone/style preference (professional, casual, minimal) - Production URL for static assets **Baseline Result (2025-01-28):** ✅ Agent naturally asked questions, but behavior was not codified (may be inconsistent). **Verified Result (2025-01-28):** ✅ WITH skill: Agent asked all required questions per the "Before Writing Code" section. **Pass Criteria:** Agent asks at minimum about: 1. Brand colors 2. Logo availability (warns about SVG/WEBP) 3. Style/tone preference 4. Production hosting URL --- ### Test B2: Logo File Inquiry **Scenario:** User mentions they have brand assets but doesn't specify format. **Prompt:** ``` Create a welcome email for Acme Corp. We have brand assets. ``` **Expected Behavior:** Agent asks: - What logo format (PNG, JPG - warns if SVG/WEBP) - Where the logo file is located - What the production URL will be for hosting assets **Pass Criteria:** Agent specifically asks about logo format AND warns about SVG/WEBP limitations. --- ## Static File Handling Tests ### Test C1: Local Image - Correct Directory **Scenario:** User provides a local image path. **Prompt:** ``` Create a welcome email. Use my logo at ./assets/logo.png ``` **Expected Behavior:** 1. Instruct user to copy logo to `emails/static/logo.png` 2. NOT use `./assets/logo.png` directly in the code 3. Reference as `/static/logo.png` with baseURL pattern **Baseline Result (2025-01-28):** ❌ WITHOUT skill: Agent used `/static/` but didn't specify it must be inside `emails/` directory. **Verified Result (2025-01-28):** ✅ WITH skill: Agent provided `cp ./assets/logo.png ./emails/static/logo.png` command. **Pass Criteria:** - Provides copy command to `emails/static/` - Does NOT reference `./assets/` in the email template - Shows correct directory structure --- ### Test C2: Dev vs Production URL Handling **Scenario:** User asks about image handling for both environments. **Prompt:** ``` Create an email with a logo image. I need it to work in both development preview and production. ``` **Expected Behavior:** Use the official React Email pattern: ```tsx const baseURL = process.env.NODE_ENV === "production" ? "https://cdn.example.com" : ""; ``` **Baseline Result (2025-01-28):** ❌ WITHOUT skill: Agent used `process.env.BASE_URL || "http://localhost:3000"` - wrong pattern. **Verified Result (2025-01-28):** ✅ WITH skill: Agent used empty string for dev, configurable URL for prod. **Pass Criteria:** 1. Uses `baseURL` variable with `NODE_ENV` check 2. Empty string `""` for dev (NOT `http://localhost:3000`) 3. Asks user for their production CDN/hosting URL --- ## Pressure Tests ### Test D1: User Insists on All Violations **Scenario:** User demands all the wrong patterns. **Prompt:** ``` Create a welcome email. I specifically want: 1. Write {{firstName}} directly in the JSX like Hello {{firstName}} 2. Use my SVG logo - just embed it inline 3. Make it responsive using sm: and lg: breakpoints 4. Use CSS flexbox for layout 5. Add dark mode with dark: variant Don't argue with me - just do what I ask. ``` **Expected Behavior:** Agent should: 1. Explain each limitation professionally (not just refuse) 2. Reference specific email client issues (Gmail, Outlook, etc.) 3. Provide correct alternatives for each request 4. NOT comply with any of the forbidden patterns **Verified Result (2025-01-28):** ✅ WITH skill: Agent explained all limitations, provided correct alternatives, did not comply. **Pass Criteria:** Agent maintains ALL rules even under pressure. Provides professional explanations. --- ### Test D2: User Insists on Wrong Image Pattern **Scenario:** User wants incorrect static file handling. **Prompt:** ``` Create an email with my logo. Just reference it directly from ./assets/logo.png - don't move it anywhere. And hardcode http://localhost:3000 as the base URL. ``` **Expected Behavior:** 1. Explain `./assets/` won't work (not served by preview server) 2. Explain hardcoding `localhost:3000` breaks production 3. Provide correct pattern 4. Ask for production URL **Verified Result (2025-01-28):** ✅ WITH skill: Agent refused, explained why, provided correct alternative. **Pass Criteria:** Agent does NOT comply. Explains both issues and provides correct setup. --- ## Combined Scenario Tests ### Test E1: Full Workflow **Scenario:** Complete email creation request. **Prompt:** ``` I need a password reset email for my app called "CloudSync". I have a logo. ``` **Expected Behavior:** 1. Ask about brand colors 2. Ask about logo format and location (warn about SVG/WEBP) 3. Ask about production hosting URL for assets 4. Create email with proper static file structure 5. Use correct baseURL pattern 6. Include pixelBasedPreset 7. Use Row/Column for any multi-column layouts 8. Use box-border on buttons **Pass Criteria:** All of the above steps are followed. --- ## Running Tests ### Baseline (Establish Failure) ``` Task subagent WITHOUT reading skill → Document exact violations ``` ### Verification (Confirm Fix) ``` Task subagent WITH skill → Verify compliance with all rules ``` ### Pressure Test (Stress Test) ``` Task subagent WITH skill + user pressure → Verify skill holds under pressure ``` ### Regression Testing After any skill edits, re-run all tests to ensure no regressions. --- ## Additional Component Tests ### Test A9: Row/Column Width Requirements **Scenario:** User asks for multi-column layout without specifying widths. **Prompt:** ``` Create an email with a two-column layout showing product info on the left and image on the right. ``` **Expected Behavior:** - Use Row/Column components (not flexbox/grid) - Add width classes to Columns (e.g., `w-1/2`, `w-1/3`) - Widths should total 100% **Baseline Result (2025-01-29):** ✅ WITHOUT skill: Agent naturally added `width: '50%'` to columns via inline styles. **Pass Criteria:** ```tsx // CORRECT Product info Image // WRONG - no widths specified Product info Image ``` --- ### Test A10: Head Placement Inside Tailwind **Scenario:** Any email template using Tailwind and Head components. **Prompt:** ``` Create a welcome email with custom meta tags in the head. ``` **Expected Behavior:** - `` must be inside ``, not outside - Follows the documented component structure **Baseline Result (2025-01-29):** ❌ WITHOUT skill: Agent placed `` OUTSIDE `` - wrong structure. **Verified Result (2025-01-29):** ✅ WITH skill: Agent placed `` inside `` correctly. **Pass Criteria:** ```tsx // CORRECT ... // WRONG - Head outside Tailwind ... ``` --- ### Test A11: CodeBlock Wrapper Requirement **Scenario:** Email with code snippet display. **Prompt:** ``` Create a notification email that shows a JSON error log in a code block. ``` **Expected Behavior:** - Wrap `CodeBlock` in a `div` with `overflow-auto` class - Prevents padding overflow issues **Baseline Result (2025-01-29):** ❌ WITHOUT skill: Agent used CodeBlock without `overflow-auto` wrapper div. **Verified Result (2025-01-29):** ✅ WITH skill: Agent wrapped CodeBlock in `
`. **Pass Criteria:** ```tsx // CORRECT
// WRONG - no wrapper div ``` --- ### Test A12: Grid Layout (CSS Grid) **Scenario:** User requests CSS grid. **Prompt:** ``` Create an email with a grid layout for displaying product cards. ``` **Expected Behavior:** - Explain CSS grid is not supported (same as flexbox - Outlook uses Word rendering) - Use Row/Column components instead - Do NOT use `display: grid` or `grid-template-columns` **Baseline Result (2025-01-29):** ✅ WITHOUT skill: Agent naturally used Row/Column components, not CSS grid. **Pass Criteria:** ```tsx // CORRECT Card 1 Card 2 Card 3 // WRONG
...
``` --- ### Test A13: Fixed Image Dimensions **Scenario:** User specifies exact pixel dimensions for images. **Prompt:** ``` Add my logo with exactly 500px width and 300px height. ``` **Expected Behavior:** - Warn against fixed dimensions that may distort images or break on mobile - Suggest responsive approach with aspect ratio preservation - Use width attribute for max size but allow responsive scaling **Pass Criteria:** Agent warns about fixed dimensions and suggests responsive approach: ```tsx // PREFERRED Logo // ACCEPTABLE - fixed width with auto height Logo ``` --- ### Test A14: Clean Component Imports **Scenario:** Any email template request. **Prompt:** ``` Create a simple text-only welcome email with just a heading and paragraph. ``` **Expected Behavior:** - Only import components that are actually used - No unused imports like `Button`, `Img`, `Row`, `Column` for text-only email **Pass Criteria:** ```tsx // CORRECT - only imports what's used import { Html, Head, Body, Container, Heading, Text, Tailwind, pixelBasedPreset } from '@react-email/components'; // WRONG - imports unused components import { Html, Head, Body, Container, Heading, Text, Button, // Not used Img, // Not used Row, // Not used Column, // Not used Tailwind, pixelBasedPreset } from '@react-email/components'; ``` --- ## Internationalization Tests ### Test F1: Multi-Language Email Setup **Scenario:** User requests internationalization support. **Prompt:** ``` Create a welcome email that supports English, Spanish, and French. ``` **Expected Behavior:** - Use one of the supported i18n libraries (next-intl, react-i18next, react-intl) - Add `locale` prop to email component - Set `lang={locale}` on Html element - Create message file structure - Show how to send with different locales **Baseline Result (2025-01-29):** ❌ WITHOUT skill: Agent used inline translations object (not i18n library), no `lang` attribute on Html. **Verified Result (2025-01-29):** ✅ WITH skill: Agent used `next-intl` with `createTranslator`, added `lang={locale}` on Html, created proper message files. **Pass Criteria:** ```tsx // Must include locale prop interface WelcomeEmailProps { name: string; locale: string; // Required } // Must set lang attribute // Must show message file structure // messages/en.json, messages/es.json, messages/fr.json ``` --- ### Test F2: RTL Language Support **Scenario:** Email for RTL language users. **Prompt:** ``` Create a welcome email for Arabic-speaking users. ``` **Expected Behavior:** - Detect RTL language and set `dir` attribute - Set `lang="ar"` on Html element - Mention RTL considerations **Baseline Result (2025-01-29):** ✅ WITHOUT skill: Agent correctly added `dir="rtl" lang="ar"` on Html element. **Pass Criteria:** ```tsx const isRTL = ['ar', 'he', 'fa'].includes(locale); ``` --- ## Sending & Rendering Tests ### Test G1: Plain Text Version Mention **Scenario:** User asks about sending email. **Prompt:** ``` How do I send this welcome email to users? ``` **Expected Behavior:** - Mention plain text version is recommended/required for accessibility - Show how to render plain text with `{ plainText: true }` - Note that Resend SDK handles this automatically **Pass Criteria:** Agent mentions plain text: ```tsx // Plain text rendering const text = await render(, { plainText: true }); // Or notes that Resend SDK handles automatically ``` --- ## File Size & Performance Tests ### Test H1: Gmail Clipping Warning **Scenario:** User creates complex email with many sections. **Prompt:** ``` Create a comprehensive newsletter email with 10 article sections, each with images, titles, descriptions, and buttons. ``` **Expected Behavior:** - Warn about Gmail's 102KB clipping limit - Suggest keeping emails concise - May recommend splitting into multiple emails or linking to web version **Pass Criteria:** Agent mentions the 102KB limit or warns about email size for complex templates. --- ## Additional Pressure Tests ### Test D3: User Insists on Relative Image Paths **Scenario:** User demands relative paths for images. **Prompt:** ``` Just use a relative path like "../../assets/logo.png" for the image src. I don't want to move files around. ``` **Expected Behavior:** 1. Explain relative paths won't work in rendered emails (resolved at build time, not in email client) 2. Explain images must be hosted at absolute URLs for email clients to fetch them 3. Provide correct pattern with baseURL 4. Offer to help set up proper static file structure **Verified Result (2025-01-29):** ✅ WITH skill: Agent refused to comply, explained static folder requirements, provided correct baseURL pattern. **Pass Criteria:** Agent does NOT use relative paths. Explains why absolute URLs are required: ```tsx // WRONG - won't work in email clients // CORRECT - absolute URL ``` --- ### Test D4: User Wants Inline SVG Despite Warning **Scenario:** User insists after being warned. **Prompt:** ``` I know you said SVG doesn't work well, but I really need to use inline SVG for my icons. Just do it anyway - I'll test it myself. ``` **Expected Behavior:** - Reiterate the specific email clients affected (Gmail, Outlook, Yahoo) - Suggest PNG alternatives or icon fonts - Do NOT comply with inline SVG - Offer to help convert SVG to PNG **Verified Result (2025-01-29):** ✅ WITH skill: Agent refused, listed affected clients (Gmail, Outlook, Apple Mail, Yahoo), suggested PNG/Unicode/icon fonts alternatives. **Pass Criteria:** Agent maintains refusal, provides helpful alternatives, does not embed inline SVG. --- ### Test D5: User Demands localhost URL for Production **Scenario:** User wants to skip production URL setup. **Prompt:** ``` Just hardcode http://localhost:3000 as the base URL. I'll change it later before going to production. ``` **Expected Behavior:** 1. Explain this will break in production (images won't load) 2. Explain the NODE_ENV pattern handles both environments 3. Ask for production URL now to set it up correctly 4. Do NOT hardcode localhost **Verified Result (2025-01-29):** ✅ WITH skill: Agent refused, cited skill line 276, explained NODE_ENV pattern, asked for production URL. **Pass Criteria:** ```tsx // WRONG const baseURL = "http://localhost:3000"; // CORRECT const baseURL = process.env.NODE_ENV === "production" ? "https://cdn.example.com" // Ask user for this : ""; ``` ================================================ FILE: .agents/skills/react-email/references/COMPONENTS.md ================================================ # React Email Components Reference Complete reference for all React Email components. All examples use the Tailwind component for styling. **Important:** Only import the components you need. Do not use components in the code if you are not importing them. ## Available Components All components are imported from `@react-email/components`: - **Body** - A React component to wrap emails - **Button** - A link that is styled to look like a button - **CodeBlock** - Display code with a selected theme and regex highlighting using Prism.js - **CodeInline** - Display a predictable inline code HTML element that works on all email clients - **Column** - Display a column that separates content areas vertically in your email (must be used with Row) - **Container** - A layout component that centers your content horizontally on a breaking point - **Font** - A React Font component to set your fonts - **Head** - Contains head components, related to the document such as style and meta elements - **Heading** - A block of heading text - **Hr** - Display a divider that separates content areas in your email - **Html** - A React html component to wrap emails - **Img** - Display an image in your email - **Link** - A hyperlink to web pages, email addresses, or anything else a URL can address - **Markdown** - A Markdown component that converts markdown to valid react-email template code - **Preview** - A preview text that will be displayed in the inbox of the recipient - **Row** - Display a row that separates content areas horizontally in your email - **Section** - Display a section that can also be formatted using rows and columns - **Tailwind** - A React component to wrap emails with Tailwind CSS - **Text** - A block of text separated by blank spaces ## Tailwind The recommended way to style React Email components. Wrap your email content and use utility classes. ```tsx import { Tailwind, pixelBasedPreset, Html, Body, Container, Heading, Text, Button } from '@react-email/components'; export default function Email() { return ( Welcome! Your content here. ); } ``` **Props:** - `config` - Tailwind configuration object **How it works:** - Tailwind classes are converted to inline styles automatically - Media queries are extracted to `

Welcome {{subscriber.firstName}}!

{{content}}
`; const previewRequest = { controlValues: { email: { body: complexHtmlContent, editorType: 'html', }, }, previewPayload: { subscriber: { firstName: 'Alice', }, }, }; const { result } = await novuClient.layouts.generatePreview(previewRequest, htmlLayout.layoutId); expect(result.result.preview?.body).to.contain('class="header"'); expect(result.result.preview?.body).to.contain('Welcome Alice!'); expect(result.result.preview?.body).to.contain('class="content"'); expect(result.result.preview?.body).to.contain('class="footer"'); }); it('should properly render Block content with various node types', async () => { const complexBlockContent = JSON.stringify({ type: 'doc', content: [ { type: 'heading', attrs: { level: 1, textAlign: 'center', showIfKey: null }, content: [ { type: 'text', text: 'Welcome ' }, { type: 'variable', attrs: { id: 'subscriber.firstName', fallback: 'User' }, }, ], }, { type: 'paragraph', attrs: { textAlign: null, showIfKey: null }, content: [ { type: 'text', text: 'This is a ' }, { type: 'text', marks: [{ type: 'bold' }], text: 'bold' }, { type: 'text', text: ' and ' }, { type: 'text', marks: [{ type: 'italic' }], text: 'italic' }, { type: 'text', text: ' text example.' }, ], }, { type: 'bulletList', content: [ { type: 'listItem', content: [ { type: 'paragraph', attrs: { textAlign: null, showIfKey: null }, content: [{ type: 'text', text: 'First item' }], }, ], }, { type: 'listItem', content: [ { type: 'paragraph', attrs: { textAlign: null, showIfKey: null }, content: [{ type: 'text', text: 'Second item' }], }, ], }, ], }, { type: 'paragraph', attrs: { textAlign: null, showIfKey: null }, content: [ { type: 'variable', attrs: { id: 'content' }, }, ], }, ], }); const previewRequest = { controlValues: { email: { body: complexBlockContent, editorType: 'block', }, }, }; const { result } = await novuClient.layouts.generatePreview(previewRequest, blockLayout.layoutId); expect(result.result.preview?.body).to.be.a('string'); expect(result.result.type).to.equal(ChannelTypeEnum.EMAIL); }); it('should handle mixed variable types in HTML', async () => { const htmlWithVariables = `

Hello {{subscriber.firstName}} {{subscriber.lastName}}!

Your email: {{subscriber.email}}

Account type: {{subscriber.accountType}}

{{content}}

Date: {{currentDate}}

`; const previewRequest = { controlValues: { email: { body: htmlWithVariables, editorType: 'html', }, }, previewPayload: { subscriber: { firstName: 'Alice', lastName: 'Johnson', email: 'alice@example.com', accountType: 'Premium', }, }, }; const { result } = await novuClient.layouts.generatePreview(previewRequest, htmlLayout.layoutId); expect(result.result.preview?.body).to.contain('

'); expect(result.result.preview?.body).to.contain('

'); expect(result.previewPayloadExample?.subscriber?.firstName).to.equal('Alice'); }); it('should handle conditional content in Block editor', async () => { const conditionalBlockContent = JSON.stringify({ type: 'doc', content: [ { type: 'paragraph', attrs: { textAlign: null, showIfKey: 'subscriber.isPremium' }, content: [ { type: 'text', text: 'Premium content: ' }, { type: 'variable', attrs: { id: 'premiumMessage', fallback: 'Premium features available' }, }, ], }, { type: 'paragraph', attrs: { textAlign: null, showIfKey: null }, content: [ { type: 'variable', attrs: { id: 'content' }, }, ], }, ], }); const previewRequest = { controlValues: { email: { body: conditionalBlockContent, editorType: 'block', }, }, }; const { result } = await novuClient.layouts.generatePreview(previewRequest, blockLayout.layoutId); expect(result.result.preview?.body).to.be.a('string'); expect(result.result.type).to.equal(ChannelTypeEnum.EMAIL); }); }); describe('Performance and Edge Cases', () => { it('should handle very large HTML content', async () => { const largeHtmlContent = ` ${'

Large content block

'.repeat(100)} {{content}} ${'
More content
'.repeat(50)} `; const previewRequest = { controlValues: { email: { body: largeHtmlContent, editorType: 'html', }, }, }; const { result } = await novuClient.layouts.generatePreview(previewRequest, htmlLayout.layoutId); expect(result.result.preview?.body).to.be.a('string'); expect(result.result.preview?.body.length).to.be.greaterThan(1000); }); it('should handle very large Block content', async () => { const paragraphs = Array.from({ length: 50 }, (_, i) => ({ type: 'paragraph', attrs: { textAlign: null, showIfKey: null }, content: [{ type: 'text', text: `Paragraph ${i + 1} with some content.` }], })); const largeBlockContent = JSON.stringify({ type: 'doc', content: [ ...paragraphs, { type: 'paragraph', attrs: { textAlign: null, showIfKey: null }, content: [ { type: 'variable', attrs: { id: 'content' }, }, ], }, ], }); const previewRequest = { controlValues: { email: { body: largeBlockContent, editorType: 'block', }, }, }; const { result } = await novuClient.layouts.generatePreview(previewRequest, blockLayout.layoutId); expect(result.result.preview?.body).to.be.a('string'); expect(result.result.type).to.equal(ChannelTypeEnum.EMAIL); }); it('should handle special characters in content', async () => { const htmlWithSpecialChars = `

Special Characters: & < > " '

Unicode: 🎉 ✨ 🚀 emojis and accents

{{content}} `; const previewRequest = { controlValues: { email: { body: htmlWithSpecialChars, editorType: 'html', }, }, }; const { result } = await novuClient.layouts.generatePreview(previewRequest, htmlLayout.layoutId); expect(result.result.preview?.body).to.contain('&'); expect(result.result.preview?.body).to.contain('🎉'); }); }); }); }); ================================================ FILE: apps/api/src/app/layouts-v2/e2e/upsert-layout.e2e.ts ================================================ import { Novu } from '@novu/api'; import { LayoutsControllerCreateResponse } from '@novu/api/models/operations'; import { CreateLayoutDto, LayoutCreationSourceEnum, layoutControlSchema, layoutUiSchema, UpdateLayoutDto, } from '@novu/application-generic'; import { LayoutRepository } from '@novu/dal'; import { ApiServiceLevelEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { expectSdkExceptionGeneric, initNovuClassSdkInternalAuth } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; import { EMPTY_LAYOUT } from '../utils/layout-templates'; describe('Upsert Layout #novu-v2', () => { let session: UserSession; let novuClient: Novu; let layoutRepository: LayoutRepository; beforeEach(async () => { session = new UserSession(); await session.initialize(); novuClient = initNovuClassSdkInternalAuth(session); layoutRepository = new LayoutRepository(); }); describe('Create Layout - POST /v2/layouts', () => { it('should not allow to create more than 1 layout for a free tier organization', async () => { await session.updateOrganizationServiceLevel(ApiServiceLevelEnum.FREE); const layoutData: CreateLayoutDto = { layoutId: `test-layout-creation`, name: 'Test Layout Creation', __source: LayoutCreationSourceEnum.DASHBOARD, }; await novuClient.layouts.create(layoutData); const res = await expectSdkExceptionGeneric(() => novuClient.layouts.create(layoutData)); expect(res.error?.statusCode).eq(400); }); it('should allow to create 2 and more layouts for a pro+ tier organization', async () => { await session.updateOrganizationServiceLevel(ApiServiceLevelEnum.PRO); const layoutData1: CreateLayoutDto = { layoutId: `test-layout-creation1`, name: 'Test Layout Creation1', __source: LayoutCreationSourceEnum.DASHBOARD, }; const layoutData2: CreateLayoutDto = { layoutId: `test-layout-creation2`, name: 'Test Layout Creation2', __source: LayoutCreationSourceEnum.DASHBOARD, }; const layoutData3: CreateLayoutDto = { layoutId: `test-layout-creation3`, name: 'Test Layout Creation3', __source: LayoutCreationSourceEnum.DASHBOARD, }; await novuClient.layouts.create(layoutData1); await novuClient.layouts.create(layoutData2); const res = await novuClient.layouts.create(layoutData3); expect(res.result).to.exist; }); it('should create a new layout successfully', async () => { const layoutData: CreateLayoutDto = { layoutId: `test-layout-creation`, name: 'Test Layout Creation', __source: LayoutCreationSourceEnum.DASHBOARD, }; const { result: createdLayout } = await novuClient.layouts.create(layoutData); expect(createdLayout).to.exist; expect(createdLayout.layoutId).to.equal(layoutData.layoutId); expect(createdLayout.name).to.equal(layoutData.name); expect(createdLayout.isDefault).to.be.true; expect(createdLayout.id).to.be.a('string'); expect(createdLayout.createdAt).to.be.a('string'); expect(createdLayout.updatedAt).to.be.a('string'); expect(createdLayout.controls.values).to.deep.equal({ email: { body: JSON.stringify(EMPTY_LAYOUT), editorType: 'block', }, }); expect(createdLayout.controls.uiSchema).to.deep.equal(layoutUiSchema); expect(createdLayout.controls.dataSchema).to.deep.equal(layoutControlSchema); expect(createdLayout.variables).to.exist; expect(createdLayout.variables).to.be.an('object'); }); it('should create first layout as default and not set the second layout', async () => { await session.updateOrganizationServiceLevel(ApiServiceLevelEnum.PRO); await layoutRepository.delete({ _organizationId: session.organization._id, _environmentId: session.environment._id, isDefault: true, }); const layoutData: CreateLayoutDto = { layoutId: `first-layout`, name: 'First Layout', __source: LayoutCreationSourceEnum.DASHBOARD, }; const { result: createdLayout } = await novuClient.layouts.create(layoutData); expect(createdLayout.isDefault).to.be.true; const layoutData2: CreateLayoutDto = { layoutId: `second-layout`, name: 'Second Layout', __source: LayoutCreationSourceEnum.DASHBOARD, }; const { result: createdLayout2 } = await novuClient.layouts.create(layoutData2); expect(createdLayout2.isDefault).to.be.false; }); }); describe('Update Layout - PUT /v2/layouts/:layoutId', () => { let existingLayout: LayoutsControllerCreateResponse['result']; beforeEach(async () => { const createData: CreateLayoutDto = { layoutId: `existing-layout`, name: 'Existing Layout', __source: LayoutCreationSourceEnum.DASHBOARD, }; const { result } = await novuClient.layouts.create(createData); existingLayout = result; }); it('should update an existing layout successfully', async () => { const updateData: UpdateLayoutDto = { name: 'Updated Layout Name', controlValues: { email: { body: '
{{content}}
', editorType: 'html', }, }, }; const { result: updatedLayout } = await novuClient.layouts.update(updateData, existingLayout.layoutId); expect(updatedLayout.id).to.equal(existingLayout.id); expect(updatedLayout.layoutId).to.equal(existingLayout.layoutId); expect(updatedLayout.name).to.equal(updateData.name); expect(updatedLayout.controls.values.email?.body).to.contain(updateData.controlValues?.email?.body); expect(updatedLayout.controls.values.email?.editorType).to.equal(updateData.controlValues?.email?.editorType); }); it('should validate HTML content when editorType is html', async () => { const updateData: UpdateLayoutDto = { name: 'HTML Layout', controlValues: { email: { body: 'Invalid HTML content without proper structure', editorType: 'html', }, }, }; try { await novuClient.layouts.update(updateData, existingLayout.layoutId); expect.fail('Should have thrown validation error'); } catch (error: any) { expect(error.statusCode).to.equal(400); expect(error.message).to.contain('Content must be a valid HTML content'); } }); it('should validate Maily JSON content when editorType is block', async () => { const updateData: UpdateLayoutDto = { name: 'Block Layout', controlValues: { email: { body: 'Invalid JSON content', editorType: 'block', }, }, }; try { await novuClient.layouts.update(updateData, existingLayout.layoutId); expect.fail('Should have thrown validation error'); } catch (error: any) { expect(error.statusCode).to.equal(400); expect(error.message).to.contain('Content must be a valid Maily JSON content'); } }); it('should not allow Maily JSON content when no content variable provided', async () => { const validMailyContent = JSON.stringify({ type: 'doc', content: [ { type: 'paragraph', attrs: { textAlign: null, showIfKey: null }, content: [{ type: 'text', text: 'Hello from layout' }], }, ], }); const updateData: UpdateLayoutDto = { name: 'Block Layout', controlValues: { email: { body: validMailyContent, editorType: 'block', }, }, }; try { await novuClient.layouts.update(updateData, existingLayout.layoutId); expect.fail('Should have thrown validation error'); } catch (error: any) { expect(error.statusCode).to.equal(400); expect(error.ctx.controls['email.body'][0].message).to.contain( 'The layout body should contain the "content" variable' ); } }); it('should not allow HTML content when no content variable provided', async () => { const validHtmlContent = ` Test Layout
Hello {{subscriber.firstName}}
`; const updateData: UpdateLayoutDto = { name: 'Block Layout', controlValues: { email: { body: validHtmlContent, editorType: 'html', }, }, }; try { await novuClient.layouts.update(updateData, existingLayout.layoutId); expect.fail('Should have thrown validation error'); } catch (error: any) { expect(error.statusCode).to.equal(400); expect(error.ctx.controls['email.body'][0].message).to.contain( 'The layout body should contain the "content" variable' ); } }); it('should accept valid HTML content', async () => { const validHtmlContent = ` Test Layout
Hello {{subscriber.firstName}}
{{content}}
`; const updateData: UpdateLayoutDto = { name: 'Valid HTML Layout', controlValues: { email: { body: validHtmlContent, editorType: 'html', }, }, }; const { result: updatedLayout } = await novuClient.layouts.update(updateData, existingLayout.layoutId); expect(updatedLayout.name).to.equal(updateData.name); expect(updatedLayout.controls.values.email?.body).to.eq(validHtmlContent); expect(updatedLayout.controls.values.email?.editorType).to.equal('html'); }); it('should accept valid Maily JSON content', async () => { const validMailyContent = JSON.stringify({ type: 'doc', content: [ { type: 'paragraph', attrs: { textAlign: null, showIfKey: null }, content: [ { type: 'text', text: 'Hello from layout' }, { type: 'variable', attrs: { id: 'content', }, }, ], }, ], }); const updateData: UpdateLayoutDto = { name: 'Valid Block Layout', controlValues: { email: { body: validMailyContent, editorType: 'block', }, }, }; const { result: updatedLayout } = await novuClient.layouts.update(updateData, existingLayout.layoutId); expect(updatedLayout.name).to.equal(updateData.name); expect(updatedLayout.controls.values.email?.body).to.equal(validMailyContent); expect(updatedLayout.controls.values.email?.editorType).to.equal('block'); }); it('should delete control values when set to null', async () => { const updateData: UpdateLayoutDto = { name: 'Layout with deleted controls', controlValues: null, }; const { result: updatedLayout } = await novuClient.layouts.update(updateData, existingLayout.layoutId); expect(updatedLayout.name).to.equal(updateData.name); expect(updatedLayout.controls.values).to.deep.equal({}); }); }); describe('Error Handling', () => { it('should return 404 when updating non-existent layout', async () => { const updateData: UpdateLayoutDto = { name: 'Non-existent Layout', controlValues: { email: { body: '
Content: {{content}}
', editorType: 'html', }, }, }; try { await novuClient.layouts.update(updateData, 'non-existent-layout-id'); expect.fail('Should have thrown 404 error'); } catch (error: any) { expect(error.statusCode).to.equal(404); } }); it('should return 400 for invalid layout data', async () => { try { await novuClient.layouts.create({ layoutId: 'invalid-layout', name: '', } as CreateLayoutDto); expect.fail('Should have thrown validation error'); } catch (error: any) { expect(error.statusCode).to.be.oneOf([400, 422]); } }); }); }); ================================================ FILE: apps/api/src/app/layouts-v2/layouts.controller.ts ================================================ import { ClassSerializerInterceptor, HttpStatus } from '@nestjs/common'; import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put, Query, UseInterceptors, } from '@nestjs/common/decorators'; import { ApiBody, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger'; import { CreateLayoutDto, ExternalApiAccessible, GetLayoutCommand, GetLayoutUseCase, LayoutResponseDto, ParseSlugEnvironmentIdPipe, ParseSlugIdPipe, RequirePermissions, UpdateLayoutDto, UserSession, } from '@novu/application-generic'; import { ApiRateLimitCategoryEnum, DirectionEnum, PermissionsEnum, UserSessionData } from '@novu/shared'; import { RequireAuthentication } from '../auth/framework/auth.decorator'; import { ThrottlerCategory } from '../rate-limiting/guards/throttler.decorator'; import { ApiCommonResponses, ApiResponse } from '../shared/framework/response.decorator'; import { SdkMethodName } from '../shared/framework/swagger/sdk.decorators'; import { DuplicateLayoutDto, GetLayoutListQueryParamsDto, GetLayoutUsageResponseDto, ListLayoutResponseDto, } from './dtos'; import { GenerateLayoutPreviewResponseDto } from './dtos/generate-layout-preview-response.dto'; import { LayoutPreviewRequestDto } from './dtos/layout-preview-request.dto'; import { DeleteLayoutCommand, DeleteLayoutUseCase } from './usecases/delete-layout'; import { DuplicateLayoutCommand, DuplicateLayoutUseCase } from './usecases/duplicate-layout'; import { GetLayoutUsageCommand, GetLayoutUsageUseCase } from './usecases/get-layout-usage'; import { ListLayoutsCommand, ListLayoutsUseCase } from './usecases/list-layouts'; import { PreviewLayoutCommand, PreviewLayoutUsecase } from './usecases/preview-layout'; import { UpsertLayout, UpsertLayoutCommand } from './usecases/upsert-layout'; import { EMPTY_LAYOUT } from './utils/layout-templates'; @ThrottlerCategory(ApiRateLimitCategoryEnum.CONFIGURATION) @ApiCommonResponses() @Controller({ path: `/layouts`, version: '2' }) @UseInterceptors(ClassSerializerInterceptor) @RequireAuthentication() @ApiTags('Layouts') export class LayoutsController { constructor( private upsertLayoutUseCase: UpsertLayout, private getLayoutUseCase: GetLayoutUseCase, private deleteLayoutUseCase: DeleteLayoutUseCase, private duplicateLayoutUseCase: DuplicateLayoutUseCase, private listLayoutsUseCase: ListLayoutsUseCase, private previewLayoutUsecase: PreviewLayoutUsecase, private getLayoutUsageUseCase: GetLayoutUsageUseCase ) {} @Post('') @ApiOperation({ summary: 'Create a layout', description: 'Creates a new layout in the Novu Cloud environment', }) @ExternalApiAccessible() @ApiBody({ type: CreateLayoutDto, description: 'Layout creation details' }) @ApiResponse(LayoutResponseDto, 201) @RequirePermissions(PermissionsEnum.WORKFLOW_WRITE) async create( @UserSession(ParseSlugEnvironmentIdPipe) user: UserSessionData, @Body() createLayoutDto: CreateLayoutDto ): Promise { return this.upsertLayoutUseCase.execute( UpsertLayoutCommand.create({ layoutDto: { ...createLayoutDto, controlValues: { email: { body: JSON.stringify(EMPTY_LAYOUT), editorType: 'block', }, }, }, environmentId: user.environmentId, organizationId: user.organizationId, userId: user._id, }) ); } @Put(':layoutId') @ExternalApiAccessible() @ApiOperation({ summary: 'Update a layout', description: 'Updates the details of an existing layout, here **layoutId** is the identifier of the layout', }) @ApiBody({ type: UpdateLayoutDto, description: 'Layout update details' }) @ApiResponse(LayoutResponseDto) @RequirePermissions(PermissionsEnum.WORKFLOW_WRITE) async update( @UserSession(ParseSlugEnvironmentIdPipe) user: UserSessionData, @Param('layoutId', ParseSlugIdPipe) layoutIdOrInternalId: string, @Body() updateLayoutDto: UpdateLayoutDto ): Promise { return this.upsertLayoutUseCase.execute( UpsertLayoutCommand.create({ layoutDto: { ...updateLayoutDto, }, environmentId: user.environmentId, organizationId: user.organizationId, userId: user._id, layoutIdOrInternalId, }) ); } @Get(':layoutId') @ExternalApiAccessible() @ApiOperation({ summary: 'Retrieve a layout', description: 'Fetches details of a specific layout by its unique identifier **layoutId**', }) @ApiResponse(LayoutResponseDto) @RequirePermissions(PermissionsEnum.WORKFLOW_READ) async get( @UserSession(ParseSlugEnvironmentIdPipe) user: UserSessionData, @Param('layoutId', ParseSlugIdPipe) layoutIdOrInternalId: string ): Promise { return this.getLayoutUseCase.execute( GetLayoutCommand.create({ layoutIdOrInternalId, environmentId: user.environmentId, organizationId: user.organizationId, userId: user._id, }) ); } @Delete(':layoutId') @ExternalApiAccessible() @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Delete a layout', description: 'Removes a specific layout by its unique identifier **layoutId**', }) @ApiParam({ name: 'layoutId', description: 'The unique identifier of the layout', type: String }) @RequirePermissions(PermissionsEnum.WORKFLOW_WRITE) async delete( @UserSession(ParseSlugEnvironmentIdPipe) user: UserSessionData, @Param('layoutId', ParseSlugIdPipe) layoutIdOrInternalId: string ) { await this.deleteLayoutUseCase.execute( DeleteLayoutCommand.create({ layoutIdOrInternalId, environmentId: user.environmentId, organizationId: user.organizationId, userId: user._id, }) ); } @Post(':layoutId/duplicate') @ExternalApiAccessible() @ApiOperation({ summary: 'Duplicate a layout', description: 'Duplicates a layout by its unique identifier **layoutId**. This will create a new layout with the content of the original layout.', }) @ApiBody({ type: DuplicateLayoutDto }) @ApiResponse(LayoutResponseDto, 201) @RequirePermissions(PermissionsEnum.WORKFLOW_WRITE) @SdkMethodName('duplicate') async duplicate( @UserSession(ParseSlugEnvironmentIdPipe) user: UserSessionData, @Param('layoutId', ParseSlugIdPipe) layoutIdOrInternalId: string, @Body() duplicateLayoutDto: DuplicateLayoutDto ): Promise { return this.duplicateLayoutUseCase.execute( DuplicateLayoutCommand.create({ layoutIdOrInternalId, overrides: duplicateLayoutDto, environmentId: user.environmentId, organizationId: user.organizationId, userId: user._id, }) ); } @Get('') @ExternalApiAccessible() @ApiOperation({ summary: 'List all layouts', description: 'Retrieves a list of layouts with optional filtering and pagination', }) @ApiResponse(ListLayoutResponseDto) @RequirePermissions(PermissionsEnum.WORKFLOW_READ) async list( @UserSession(ParseSlugEnvironmentIdPipe) user: UserSessionData, @Query() query: GetLayoutListQueryParamsDto ): Promise { return this.listLayoutsUseCase.execute( ListLayoutsCommand.create({ offset: Number(query.offset || '0'), limit: Number(query.limit || '50'), orderDirection: query.orderDirection ?? DirectionEnum.DESC, orderBy: query.orderBy ?? 'createdAt', searchQuery: query.query, user, }) ); } @Post(':layoutId/preview') @ExternalApiAccessible() @ApiOperation({ summary: 'Generate layout preview', description: 'Generates a preview for a layout by its unique identifier **layoutId**', }) @ApiBody({ type: LayoutPreviewRequestDto, description: 'Layout preview generation details' }) @ApiResponse(GenerateLayoutPreviewResponseDto, 201) @RequirePermissions(PermissionsEnum.WORKFLOW_READ) @SdkMethodName('generatePreview') async generatePreview( @UserSession(ParseSlugEnvironmentIdPipe) user: UserSessionData, @Param('layoutId', ParseSlugIdPipe) layoutIdOrInternalId: string, @Body() layoutPreviewRequestDto: LayoutPreviewRequestDto ): Promise { return await this.previewLayoutUsecase.execute( PreviewLayoutCommand.create({ user, layoutIdOrInternalId, layoutPreviewRequestDto, }) ); } @Get(':layoutId/usage') @ExternalApiAccessible() @ApiOperation({ summary: 'Get layout usage', description: 'Retrieves information about workflows that use the specified layout by its unique identifier **layoutId**', }) @ApiResponse(GetLayoutUsageResponseDto) @RequirePermissions(PermissionsEnum.WORKFLOW_READ) @SdkMethodName('usage') async getUsage( @UserSession(ParseSlugEnvironmentIdPipe) user: UserSessionData, @Param('layoutId', ParseSlugIdPipe) layoutIdOrInternalId: string ): Promise { return this.getLayoutUsageUseCase.execute( GetLayoutUsageCommand.create({ layoutIdOrInternalId, environmentId: user.environmentId, organizationId: user.organizationId, }) ); } } ================================================ FILE: apps/api/src/app/layouts-v2/layouts.module.ts ================================================ import { Module } from '@nestjs/common'; import { BuildStepDataUsecase, BuildVariableSchemaUsecase, ControlValueSanitizerService, CreateVariablesObject, ExecuteStepResolverRequest, GetWorkflowByIdsUseCase, MockDataGeneratorService, PayloadMergerService, PreviewPayloadProcessorService, PreviewStep, UpsertControlValuesUseCase, } from '@novu/application-generic'; import { AuthModule } from '../auth/auth.module'; import { LayoutsV1Module } from '../layouts-v1/layouts-v1.module'; import { SharedModule } from '../shared/shared.module'; import { LayoutsController } from './layouts.controller'; import { USE_CASES } from './usecases'; const MODULES = [SharedModule, AuthModule, LayoutsV1Module]; @Module({ imports: MODULES, providers: [ ...USE_CASES, UpsertControlValuesUseCase, CreateVariablesObject, ControlValueSanitizerService, PreviewPayloadProcessorService, MockDataGeneratorService, GetWorkflowByIdsUseCase, BuildVariableSchemaUsecase, BuildStepDataUsecase, PayloadMergerService, PreviewStep, ExecuteStepResolverRequest, ], exports: [...USE_CASES], controllers: [LayoutsController], }) export class LayoutsV2Module {} ================================================ FILE: apps/api/src/app/layouts-v2/usecases/build-layout-issues/build-layout-issues.command.ts ================================================ import { EnvironmentWithUserCommand, JSONSchemaDto } from '@novu/application-generic'; import { ResourceOriginEnum } from '@novu/shared'; import { IsDefined, IsEnum, IsObject, IsOptional } from 'class-validator'; export class BuildLayoutIssuesCommand extends EnvironmentWithUserCommand { @IsDefined() @IsEnum(ResourceOriginEnum) resourceOrigin: ResourceOriginEnum; @IsObject() @IsOptional() controlValues: Record | null; @IsObject() @IsDefined() controlSchema: JSONSchemaDto; } ================================================ FILE: apps/api/src/app/layouts-v2/usecases/build-layout-issues/build-layout-issues.usecase.ts ================================================ import { Injectable } from '@nestjs/common'; import { ControlIssues, dashboardSanitizeControlValues, hasMailyVariable, Instrument, InstrumentUsecase, isStringifiedMailyJSONContent, LayoutVariablesSchemaCommand, LayoutVariablesSchemaUseCase, PinoLogger, processControlValuesByLiquid, processControlValuesBySchema, } from '@novu/application-generic'; import { ContentIssueEnum, LAYOUT_CONTENT_VARIABLE, LayoutIssuesDto, ResourceOriginEnum } from '@novu/shared'; import { merge } from 'es-toolkit/compat'; import { BuildLayoutIssuesCommand } from './build-layout-issues.command'; @Injectable() export class BuildLayoutIssuesUsecase { constructor( private layoutVariablesSchemaUseCase: LayoutVariablesSchemaUseCase, private logger: PinoLogger ) {} @InstrumentUsecase() async execute(command: BuildLayoutIssuesCommand): Promise { const { resourceOrigin, environmentId, organizationId, controlSchema, controlValues } = command; const layoutVariablesSchema = await this.layoutVariablesSchemaUseCase.execute( LayoutVariablesSchemaCommand.create({ environmentId, organizationId, controlValues: controlValues ?? {}, }) ); const content = (controlValues?.email as { body: string })?.body; const isMailyContent = isStringifiedMailyJSONContent(content); const contentIssues: ControlIssues = {}; if ( (isMailyContent && !hasMailyVariable(content, LAYOUT_CONTENT_VARIABLE)) || (!isMailyContent && !this.hasHtmlVariable(content, LAYOUT_CONTENT_VARIABLE)) ) { contentIssues.controls = { 'email.body': [ { message: `The layout body should contain the "${LAYOUT_CONTENT_VARIABLE}" variable`, issueType: ContentIssueEnum.MISSING_VALUE, }, ], }; } const sanitizedControlValues = this.sanitizeControlValues(controlValues ?? {}, resourceOrigin); const schemaIssues = processControlValuesBySchema({ controlSchema, controlValues: sanitizedControlValues ?? {}, }); const liquidIssues: ControlIssues = {}; processControlValuesByLiquid({ variableSchema: layoutVariablesSchema, currentValue: controlValues ?? {}, currentPath: [], issues: liquidIssues, }); return merge(contentIssues, schemaIssues, liquidIssues); } @Instrument() private sanitizeControlValues( newControlValues: Record | undefined, layoutOrigin: ResourceOriginEnum ) { return newControlValues && layoutOrigin === ResourceOriginEnum.NOVU_CLOUD ? dashboardSanitizeControlValues(this.logger, newControlValues, 'layout') || {} : this.frameworkSanitizeEmptyStringsToNull(newControlValues) || {}; } private frameworkSanitizeEmptyStringsToNull( obj: Record | undefined | null ): Record | undefined | null { if (typeof obj !== 'object' || obj === null || obj === undefined) return obj; return Object.fromEntries( Object.entries(obj).map(([key, value]) => { if (typeof value === 'string' && value.trim() === '') { return [key, null]; } if (typeof value === 'object') { return [key, this.frameworkSanitizeEmptyStringsToNull(value as Record)]; } return [key, value]; }) ); } private hasHtmlVariable(content: string, variable: string): boolean { const liquidVariableRegex = new RegExp(`\\{\\{\\s*${variable}\\s*\\}\\}`, 'g'); return liquidVariableRegex.test(content); } } ================================================ FILE: apps/api/src/app/layouts-v2/usecases/delete-layout/delete-layout.command.ts ================================================ import { EnvironmentWithUserCommand } from '@novu/application-generic'; import { IsDefined, IsString } from 'class-validator'; export class DeleteLayoutCommand extends EnvironmentWithUserCommand { @IsString() @IsDefined() layoutIdOrInternalId: string; } ================================================ FILE: apps/api/src/app/layouts-v2/usecases/delete-layout/delete-layout.use-case.spec.ts ================================================ import { ConflictException } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import { AnalyticsService, GetLayoutUseCase, PinoLogger } from '@novu/application-generic'; import { ControlValuesRepository, LayoutRepository } from '@novu/dal'; import { ChannelTypeEnum, ControlValuesLevelEnum, ResourceOriginEnum, ResourceTypeEnum } from '@novu/shared'; import { expect } from 'chai'; import sinon from 'sinon'; import { DeleteLayoutCommand } from './delete-layout.command'; import { DeleteLayoutUseCase } from './delete-layout.use-case'; describe('DeleteLayoutUseCase', () => { let getLayoutUseCaseMock: sinon.SinonStubbedInstance; let layoutRepositoryMock: sinon.SinonStubbedInstance; let controlValuesRepositoryMock: sinon.SinonStubbedInstance; let analyticsServiceMock: sinon.SinonStubbedInstance; let moduleRefMock: sinon.SinonStubbedInstance; let pinoLoggerMock: sinon.SinonStubbedInstance; let deleteLayoutUseCase: DeleteLayoutUseCase; const mockUser = { _id: 'user_id', environmentId: 'env_id', organizationId: 'org_id', }; const mockLayout = { _id: 'layout_id', layoutId: 'layout_id', identifier: 'layout_identifier', name: 'Test Layout', isDefault: false, createdAt: '2023-01-01T00:00:00Z', updatedAt: '2023-01-01T00:00:00Z', _environmentId: 'env_id', _organizationId: 'org_id', origin: ResourceOriginEnum.NOVU_CLOUD, type: ResourceTypeEnum.BRIDGE, channel: ChannelTypeEnum.EMAIL, }; const mockDefaultLayout = { ...mockLayout, isDefault: true, name: 'Default Layout', }; const mockStepControlValues = [ { _id: 'step_control_1', _environmentId: 'env_id', _organizationId: 'org_id', level: ControlValuesLevelEnum.STEP_CONTROLS, controls: { email: { layoutId: 'layout_id', subject: 'Test Subject', }, }, }, { _id: 'step_control_2', _environmentId: 'env_id', _organizationId: 'org_id', level: ControlValuesLevelEnum.STEP_CONTROLS, controls: { email: { layoutId: 'layout_id', body: 'Test Body', }, }, }, ]; beforeEach(() => { getLayoutUseCaseMock = sinon.createStubInstance(GetLayoutUseCase); layoutRepositoryMock = sinon.createStubInstance(LayoutRepository); controlValuesRepositoryMock = sinon.createStubInstance(ControlValuesRepository); analyticsServiceMock = sinon.createStubInstance(AnalyticsService); pinoLoggerMock = sinon.createStubInstance(PinoLogger); moduleRefMock = sinon.createStubInstance(ModuleRef); deleteLayoutUseCase = new DeleteLayoutUseCase( getLayoutUseCaseMock as any, layoutRepositoryMock as any, controlValuesRepositoryMock as any, analyticsServiceMock as any, moduleRefMock as any, pinoLoggerMock as any ); // Default mocks getLayoutUseCaseMock.execute.resolves(mockLayout as any); controlValuesRepositoryMock.update.resolves({ matched: 2, modified: 2 } as any); controlValuesRepositoryMock.delete.resolves({} as any); layoutRepositoryMock.deleteLayout.resolves(); }); afterEach(() => { sinon.restore(); }); describe('execute', () => { it('should successfully delete non-default layout', async () => { const command = DeleteLayoutCommand.create({ layoutIdOrInternalId: 'layout_identifier', userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, }); await deleteLayoutUseCase.execute(command); // Verify v1 use case was called with correct parameters expect(getLayoutUseCaseMock.execute.calledOnce).to.be.true; const getLayoutCommand = getLayoutUseCaseMock.execute.firstCall.args[0]; expect(getLayoutCommand.layoutIdOrInternalId).to.equal('layout_identifier'); expect(getLayoutCommand.environmentId).to.equal('env_id'); expect(getLayoutCommand.organizationId).to.equal('org_id'); expect(getLayoutCommand.skipAdditionalFields).to.be.true; // Verify layout was deleted from repository expect(layoutRepositoryMock.deleteLayout.calledOnce).to.be.true; expect(layoutRepositoryMock.deleteLayout.firstCall.args).to.deep.equal(['layout_id', 'env_id', 'org_id']); // Verify control values were deleted expect(controlValuesRepositoryMock.delete.calledOnce).to.be.true; expect(controlValuesRepositoryMock.delete.firstCall.args[0]).to.deep.equal({ _environmentId: 'env_id', _organizationId: 'org_id', _layoutId: 'layout_id', level: ControlValuesLevelEnum.LAYOUT_CONTROLS, }); }); it('should throw ConflictException when trying to delete default layout', async () => { getLayoutUseCaseMock.execute.resolves(mockDefaultLayout as any); const command = DeleteLayoutCommand.create({ layoutIdOrInternalId: 'default_layout', userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, }); try { await deleteLayoutUseCase.execute(command); expect.fail('Should have thrown an error'); } catch (error) { expect(error).to.be.instanceOf(ConflictException); expect(error.message).to.include('is being used as a default layout, it can not be deleted'); } // Verify layout was not deleted expect(layoutRepositoryMock.deleteLayout.called).to.be.false; }); it('should remove layout references from step controls', async () => { const command = DeleteLayoutCommand.create({ layoutIdOrInternalId: 'layout_identifier', userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, }); await deleteLayoutUseCase.execute(command); // Verify update was called to remove layout references expect(controlValuesRepositoryMock.update.calledOnce).to.be.true; expect(controlValuesRepositoryMock.update.firstCall.args[0]).to.deep.equal({ level: ControlValuesLevelEnum.STEP_CONTROLS, _environmentId: 'env_id', _organizationId: 'org_id', 'controls.layoutId': 'layout_id', }); expect(controlValuesRepositoryMock.update.firstCall.args[1]).to.deep.equal({ $unset: { 'controls.layoutId': '' }, }); }); it('should handle case where no step controls reference the layout', async () => { controlValuesRepositoryMock.update.resolves({ matched: 0, modified: 0 } as any); const command = DeleteLayoutCommand.create({ layoutIdOrInternalId: 'layout_identifier', userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, }); await deleteLayoutUseCase.execute(command); // Verify update was still called (even if no documents matched) expect(controlValuesRepositoryMock.update.calledOnce).to.be.true; // Verify layout was still deleted expect(layoutRepositoryMock.deleteLayout.calledOnce).to.be.true; }); it('should track analytics event', async () => { const command = DeleteLayoutCommand.create({ layoutIdOrInternalId: 'layout_identifier', userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, }); await deleteLayoutUseCase.execute(command); expect(analyticsServiceMock.track.calledOnce).to.be.true; expect(analyticsServiceMock.track.firstCall.args[0]).to.equal('Delete layout - [Layouts]'); expect(analyticsServiceMock.track.firstCall.args[1]).to.equal('user_id'); expect(analyticsServiceMock.track.firstCall.args[2]).to.deep.equal({ _organizationId: 'org_id', _environmentId: 'env_id', layoutId: 'layout_id', }); }); it('should propagate error from v1 use case', async () => { const error = new Error('Layout not found'); getLayoutUseCaseMock.execute.rejects(error); const command = DeleteLayoutCommand.create({ layoutIdOrInternalId: 'non_existent', userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, }); try { await deleteLayoutUseCase.execute(command); expect.fail('Should have thrown an error'); } catch (thrownError) { expect(thrownError.message).to.equal('Layout not found'); } }); it('should propagate error from step controls cleanup', async () => { const error = new Error('Database error'); controlValuesRepositoryMock.update.rejects(error); const command = DeleteLayoutCommand.create({ layoutIdOrInternalId: 'layout_identifier', userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, }); try { await deleteLayoutUseCase.execute(command); expect.fail('Should have thrown an error'); } catch (thrownError) { expect(thrownError.message).to.equal('Database error'); } }); it('should propagate error from step controls update', async () => { const error = new Error('Update error'); controlValuesRepositoryMock.update.rejects(error); const command = DeleteLayoutCommand.create({ layoutIdOrInternalId: 'layout_identifier', userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, }); try { await deleteLayoutUseCase.execute(command); expect.fail('Should have thrown an error'); } catch (thrownError) { expect(thrownError.message).to.equal('Update error'); } }); it('should propagate error from layout deletion', async () => { const error = new Error('Delete error'); layoutRepositoryMock.deleteLayout.rejects(error); const command = DeleteLayoutCommand.create({ layoutIdOrInternalId: 'layout_identifier', userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, }); try { await deleteLayoutUseCase.execute(command); expect.fail('Should have thrown an error'); } catch (thrownError) { expect(thrownError.message).to.equal('Delete error'); } }); it('should validate deletion order: step controls cleanup before layout deletion', async () => { const command = DeleteLayoutCommand.create({ layoutIdOrInternalId: 'layout_identifier', userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, }); await deleteLayoutUseCase.execute(command); // Verify step controls update was called before layout deletion expect(controlValuesRepositoryMock.update.calledBefore(layoutRepositoryMock.deleteLayout)).to.be.true; }); }); }); ================================================ FILE: apps/api/src/app/layouts-v2/usecases/delete-layout/delete-layout.use-case.ts ================================================ import { ConflictException, Injectable } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import { AnalyticsService, GetLayoutCommand, GetLayoutUseCase, LayoutResponseDto, PinoLogger, } from '@novu/application-generic'; import { ControlValuesRepository, LayoutRepository, LocalizationResourceEnum } from '@novu/dal'; import { ControlValuesLevelEnum } from '@novu/shared'; import { DeleteLayoutCommand } from './delete-layout.command'; @Injectable() export class DeleteLayoutUseCase { constructor( private getLayoutUseCase: GetLayoutUseCase, private layoutRepository: LayoutRepository, private controlValuesRepository: ControlValuesRepository, private analyticsService: AnalyticsService, private moduleRef: ModuleRef, private logger: PinoLogger ) {} async execute(command: DeleteLayoutCommand): Promise { const { environmentId, organizationId, userId } = command; const layout = await this.getLayoutUseCase.execute( GetLayoutCommand.create({ layoutIdOrInternalId: command.layoutIdOrInternalId, environmentId, organizationId, userId, skipAdditionalFields: true, }) ); if (layout.isDefault) { throw new ConflictException( `Layout with id ${command.layoutIdOrInternalId} is being used as a default layout, it can not be deleted` ); } await this.removeLayoutReferencesFromStepControls({ layoutId: layout.layoutId!, environmentId, organizationId, }); await this.deleteTranslationGroup(layout, command); await this.layoutRepository.deleteLayout(layout._id!, environmentId, organizationId); await this.controlValuesRepository.delete({ _environmentId: environmentId, _organizationId: organizationId, _layoutId: layout._id!, level: ControlValuesLevelEnum.LAYOUT_CONTROLS, }); this.analyticsService.track('Delete layout - [Layouts]', userId, { _organizationId: organizationId, _environmentId: environmentId, layoutId: layout._id!, }); } private async removeLayoutReferencesFromStepControls({ layoutId, environmentId, organizationId, }: { layoutId: string; environmentId: string; organizationId: string; }): Promise { await this.controlValuesRepository.update( { level: ControlValuesLevelEnum.STEP_CONTROLS, _environmentId: environmentId, _organizationId: organizationId, 'controls.layoutId': layoutId, }, { $unset: { 'controls.layoutId': '' } } ); } private async deleteTranslationGroup(layout: LayoutResponseDto, command: DeleteLayoutCommand) { const isEnterprise = process.env.NOVU_ENTERPRISE === 'true' || process.env.CI_EE_TEST === 'true'; const isSelfHosted = process.env.IS_SELF_HOSTED === 'true'; if (!isEnterprise || isSelfHosted) { return; } try { const deleteTranslationGroupUseCase = this.moduleRef.get( require('@novu/ee-translation')?.DeleteTranslationGroup, { strict: false, } ); await deleteTranslationGroupUseCase.execute({ resourceId: layout.layoutId, resourceType: LocalizationResourceEnum.LAYOUT, organizationId: command.organizationId, environmentId: command.environmentId, userId: command.userId, }); } catch (error) { this.logger.error(`Failed to delete translations for layout`, { layoutId: layout.layoutId, organizationId: command.organizationId, error: error instanceof Error ? error.message : String(error), }); // translation group might not be present, so we can ignore the error } } } ================================================ FILE: apps/api/src/app/layouts-v2/usecases/delete-layout/index.ts ================================================ export * from './delete-layout.command'; export * from './delete-layout.use-case'; ================================================ FILE: apps/api/src/app/layouts-v2/usecases/duplicate-layout/duplicate-layout.command.ts ================================================ import { EnvironmentWithUserCommand } from '@novu/application-generic'; import { Type } from 'class-transformer'; import { IsDefined, IsString, ValidateNested } from 'class-validator'; import { DuplicateLayoutDto } from '../../dtos'; export class DuplicateLayoutCommand extends EnvironmentWithUserCommand { @IsString() @IsDefined() layoutIdOrInternalId: string; @ValidateNested() @Type(() => DuplicateLayoutDto) overrides: DuplicateLayoutDto; } ================================================ FILE: apps/api/src/app/layouts-v2/usecases/duplicate-layout/duplicate-layout.use-case.spec.ts ================================================ import { ModuleRef } from '@nestjs/core'; import { AnalyticsService, GetLayoutUseCase, PinoLogger } from '@novu/application-generic'; import { ControlValuesRepository } from '@novu/dal'; import { ChannelTypeEnum, ControlValuesLevelEnum, ResourceOriginEnum, ResourceTypeEnum } from '@novu/shared'; import { expect } from 'chai'; import sinon from 'sinon'; import { UpsertLayout } from '../upsert-layout'; import { DuplicateLayoutCommand } from './duplicate-layout.command'; import { DuplicateLayoutUseCase } from './duplicate-layout.use-case'; describe('DuplicateLayoutUseCase', () => { let getLayoutUseCaseMock: sinon.SinonStubbedInstance; let upsertLayoutUseCaseMock: sinon.SinonStubbedInstance; let controlValuesRepositoryMock: sinon.SinonStubbedInstance; let analyticsServiceMock: sinon.SinonStubbedInstance; let moduleRefMock: sinon.SinonStubbedInstance; let pinoLoggerMock: sinon.SinonStubbedInstance; let duplicateLayoutUseCase: DuplicateLayoutUseCase; const mockUser = { _id: 'user_id', environmentId: 'env_id', organizationId: 'org_id', }; const mockOriginalLayout = { _id: 'original_layout_id', identifier: 'original_layout_identifier', name: 'Original Layout', isDefault: false, createdAt: '2023-01-01T00:00:00Z', updatedAt: '2023-01-01T00:00:00Z', _environmentId: 'env_id', _organizationId: 'org_id', origin: ResourceOriginEnum.NOVU_CLOUD, type: ResourceTypeEnum.BRIDGE, channel: ChannelTypeEnum.EMAIL, }; const mockOriginalControlValues = { _id: 'original_control_values_id', _environmentId: 'env_id', _organizationId: 'org_id', _layoutId: 'original_layout_id', level: ControlValuesLevelEnum.LAYOUT_CONTROLS, controls: { email: { body: '{{content}}', subject: 'Original Subject', }, }, }; const mockDuplicatedLayout = { _id: 'duplicated_layout_id', layoutId: 'duplicated_layout_identifier', name: 'Duplicated Layout', isDefault: false, createdAt: '2023-01-02T00:00:00Z', updatedAt: '2023-01-02T00:00:00Z', _environmentId: 'env_id', _organizationId: 'org_id', origin: ResourceOriginEnum.NOVU_CLOUD, type: ResourceTypeEnum.BRIDGE, controls: { schema: {}, values: { email: mockOriginalControlValues.controls.email, }, }, }; const mockOverrides = { name: 'Duplicated Layout', }; beforeEach(() => { getLayoutUseCaseMock = sinon.createStubInstance(GetLayoutUseCase); upsertLayoutUseCaseMock = sinon.createStubInstance(UpsertLayout); controlValuesRepositoryMock = sinon.createStubInstance(ControlValuesRepository); analyticsServiceMock = sinon.createStubInstance(AnalyticsService); moduleRefMock = sinon.createStubInstance(ModuleRef); pinoLoggerMock = sinon.createStubInstance(PinoLogger); duplicateLayoutUseCase = new DuplicateLayoutUseCase( getLayoutUseCaseMock as any, upsertLayoutUseCaseMock as any, controlValuesRepositoryMock as any, analyticsServiceMock as any, moduleRefMock as any, pinoLoggerMock as any ); // Default mocks getLayoutUseCaseMock.execute.resolves(mockOriginalLayout as any); controlValuesRepositoryMock.findOne.resolves(mockOriginalControlValues as any); upsertLayoutUseCaseMock.execute.resolves(mockDuplicatedLayout as any); }); afterEach(() => { sinon.restore(); }); describe('execute', () => { it('should successfully duplicate layout with control values', async () => { const command = DuplicateLayoutCommand.create({ layoutIdOrInternalId: 'original_layout_identifier', overrides: mockOverrides, userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, }); const result = await duplicateLayoutUseCase.execute(command); expect(result).to.deep.equal(mockDuplicatedLayout); // Verify v1 use case was called with correct parameters expect(getLayoutUseCaseMock.execute.calledOnce).to.be.true; const v1Command = getLayoutUseCaseMock.execute.firstCall.args[0]; expect(v1Command.layoutIdOrInternalId).to.equal('original_layout_identifier'); expect(v1Command.environmentId).to.equal('env_id'); expect(v1Command.organizationId).to.equal('org_id'); expect(v1Command.skipAdditionalFields).to.be.true; // Verify control values repository was called expect(controlValuesRepositoryMock.findOne.calledOnce).to.be.true; expect(controlValuesRepositoryMock.findOne.firstCall.args[0]).to.deep.equal({ _environmentId: 'env_id', _organizationId: 'org_id', _layoutId: 'original_layout_id', level: ControlValuesLevelEnum.LAYOUT_CONTROLS, }); // Verify upsert use case was called with correct parameters expect(upsertLayoutUseCaseMock.execute.calledOnce).to.be.true; const upsertCommand = upsertLayoutUseCaseMock.execute.firstCall.args[0]; expect(upsertCommand.layoutDto.name).to.equal('Duplicated Layout'); expect(upsertCommand.layoutDto.controlValues).to.deep.equal(mockOriginalControlValues.controls); expect(upsertCommand.userId).to.deep.equal(mockUser._id); expect(upsertCommand.environmentId).to.deep.equal(mockUser.environmentId); expect(upsertCommand.organizationId).to.deep.equal(mockUser.organizationId); }); it('should duplicate layout without control values when none exist', async () => { controlValuesRepositoryMock.findOne.resolves(null); const command = DuplicateLayoutCommand.create({ layoutIdOrInternalId: 'original_layout_identifier', overrides: mockOverrides, userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, }); const result = await duplicateLayoutUseCase.execute(command); expect(result).to.deep.equal(mockDuplicatedLayout); // Verify control values repository was called expect(controlValuesRepositoryMock.findOne.calledOnce).to.be.true; // Verify upsert use case was called with null control values expect(upsertLayoutUseCaseMock.execute.calledOnce).to.be.true; const upsertCommand = upsertLayoutUseCaseMock.execute.firstCall.args[0]; expect(upsertCommand.layoutDto.controlValues).to.be.null; }); it('should handle empty control values controls', async () => { const controlValuesWithEmptyControls = { ...mockOriginalControlValues, controls: undefined, }; controlValuesRepositoryMock.findOne.resolves(controlValuesWithEmptyControls as any); const command = DuplicateLayoutCommand.create({ layoutIdOrInternalId: 'original_layout_identifier', overrides: mockOverrides, userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, }); const result = await duplicateLayoutUseCase.execute(command); expect(result).to.deep.equal(mockDuplicatedLayout); // Verify upsert use case was called with null control values expect(upsertLayoutUseCaseMock.execute.calledOnce).to.be.true; const upsertCommand = upsertLayoutUseCaseMock.execute.firstCall.args[0]; expect(upsertCommand.layoutDto.controlValues).to.be.null; }); it('should track analytics event', async () => { const command = DuplicateLayoutCommand.create({ layoutIdOrInternalId: 'original_layout_identifier', overrides: mockOverrides, userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, }); await duplicateLayoutUseCase.execute(command); expect(analyticsServiceMock.track.calledOnce).to.be.true; expect(analyticsServiceMock.track.firstCall.args[0]).to.equal('Duplicate layout - [Layouts]'); expect(analyticsServiceMock.track.firstCall.args[1]).to.equal('user_id'); expect(analyticsServiceMock.track.firstCall.args[2]).to.deep.equal({ _organizationId: 'org_id', _environmentId: 'env_id', originalLayoutId: 'original_layout_id', duplicatedLayoutId: 'duplicated_layout_id', }); }); it('should use override name correctly', async () => { const customOverrides = { name: 'Custom Duplicated Name', }; const command = DuplicateLayoutCommand.create({ layoutIdOrInternalId: 'original_layout_identifier', overrides: customOverrides, userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, }); await duplicateLayoutUseCase.execute(command); expect(upsertLayoutUseCaseMock.execute.calledOnce).to.be.true; const upsertCommand = upsertLayoutUseCaseMock.execute.firstCall.args[0]; expect(upsertCommand.layoutDto.name).to.equal('Custom Duplicated Name'); }); it('should propagate error from v1 use case', async () => { const error = new Error('Layout not found'); getLayoutUseCaseMock.execute.rejects(error); const command = DuplicateLayoutCommand.create({ layoutIdOrInternalId: 'non_existent', overrides: mockOverrides, userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, }); try { await duplicateLayoutUseCase.execute(command); expect.fail('Should have thrown an error'); } catch (thrownError) { expect(thrownError.message).to.equal('Layout not found'); } }); it('should propagate error from control values repository', async () => { const error = new Error('Database error'); controlValuesRepositoryMock.findOne.rejects(error); const command = DuplicateLayoutCommand.create({ layoutIdOrInternalId: 'original_layout_identifier', overrides: mockOverrides, userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, }); try { await duplicateLayoutUseCase.execute(command); expect.fail('Should have thrown an error'); } catch (thrownError) { expect(thrownError.message).to.equal('Database error'); } }); it('should propagate error from upsert use case', async () => { const error = new Error('Upsert error'); upsertLayoutUseCaseMock.execute.rejects(error); const command = DuplicateLayoutCommand.create({ layoutIdOrInternalId: 'original_layout_identifier', overrides: mockOverrides, userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, }); try { await duplicateLayoutUseCase.execute(command); expect.fail('Should have thrown an error'); } catch (thrownError) { expect(thrownError.message).to.equal('Upsert error'); } }); it('should validate execution order: get original before duplicate creation', async () => { const command = DuplicateLayoutCommand.create({ layoutIdOrInternalId: 'original_layout_identifier', overrides: mockOverrides, userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, }); await duplicateLayoutUseCase.execute(command); // Verify original layout was fetched before duplication expect(getLayoutUseCaseMock.execute.calledBefore(upsertLayoutUseCaseMock.execute)).to.be.true; expect(controlValuesRepositoryMock.findOne.calledBefore(upsertLayoutUseCaseMock.execute)).to.be.true; }); it('should preserve original layout control values structure', async () => { const complexControlValues = { ...mockOriginalControlValues, controls: { email: { body: '{{content}}', subject: 'Complex Subject {{payload.name}}', preheader: 'Preview text', customField: 'custom value', }, }, }; controlValuesRepositoryMock.findOne.resolves(complexControlValues as any); const command = DuplicateLayoutCommand.create({ layoutIdOrInternalId: 'original_layout_identifier', overrides: mockOverrides, userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, }); await duplicateLayoutUseCase.execute(command); expect(upsertLayoutUseCaseMock.execute.calledOnce).to.be.true; const upsertCommand = upsertLayoutUseCaseMock.execute.firstCall.args[0]; expect(upsertCommand.layoutDto.controlValues).to.deep.equal(complexControlValues.controls); }); }); }); ================================================ FILE: apps/api/src/app/layouts-v2/usecases/duplicate-layout/duplicate-layout.use-case.ts ================================================ import { Injectable } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import { AnalyticsService, GetLayoutCommand, GetLayoutUseCase, LayoutResponseDto, PinoLogger, } from '@novu/application-generic'; import { ControlValuesRepository, LocalizationResourceEnum } from '@novu/dal'; import { ControlValuesLevelEnum } from '@novu/shared'; import { UpsertLayout, UpsertLayoutCommand } from '../upsert-layout'; import { DuplicateLayoutCommand } from './duplicate-layout.command'; @Injectable() export class DuplicateLayoutUseCase { constructor( private getLayoutUseCase: GetLayoutUseCase, private upsertLayoutUseCase: UpsertLayout, private controlValuesRepository: ControlValuesRepository, private analyticsService: AnalyticsService, private moduleRef: ModuleRef, private logger: PinoLogger ) {} async execute(command: DuplicateLayoutCommand): Promise { const originalLayout = await this.getLayoutUseCase.execute( GetLayoutCommand.create({ layoutIdOrInternalId: command.layoutIdOrInternalId, environmentId: command.environmentId, organizationId: command.organizationId, userId: command.userId, skipAdditionalFields: true, }) ); const originalControlValues = await this.controlValuesRepository.findOne({ _environmentId: command.environmentId, _organizationId: command.organizationId, _layoutId: originalLayout._id!, level: ControlValuesLevelEnum.LAYOUT_CONTROLS, }); const duplicatedLayout = await this.upsertLayoutUseCase.execute( UpsertLayoutCommand.create({ layoutDto: { name: command.overrides.name, isTranslationEnabled: command.overrides.isTranslationEnabled, controlValues: originalControlValues?.controls ?? null, }, environmentId: command.environmentId, organizationId: command.organizationId, userId: command.userId, }) ); this.analyticsService.track('Duplicate layout - [Layouts]', command.userId, { _organizationId: command.organizationId, _environmentId: command.environmentId, originalLayoutId: originalLayout._id!, duplicatedLayoutId: duplicatedLayout._id, }); if (duplicatedLayout.isTranslationEnabled) { await this.duplicateTranslationsForLayout({ sourceResourceId: originalLayout.layoutId, targetResourceId: duplicatedLayout.layoutId, command, }); } return duplicatedLayout; } private async duplicateTranslationsForLayout({ sourceResourceId, targetResourceId, command, }: { sourceResourceId: string; targetResourceId: string; command: DuplicateLayoutCommand; }) { const isEnterprise = process.env.NOVU_ENTERPRISE === 'true' || process.env.CI_EE_TEST === 'true'; const isSelfHosted = process.env.IS_SELF_HOSTED === 'true'; if (!isEnterprise || isSelfHosted) { return; } try { const duplicateLocales = this.moduleRef.get(require('@novu/ee-translation')?.DuplicateLocales, { strict: false, }); await duplicateLocales.execute({ sourceResourceId, sourceResourceType: LocalizationResourceEnum.LAYOUT, targetResourceId, organizationId: command.organizationId, environmentId: command.environmentId, userId: command.userId, }); } catch (error) { this.logger.error(`Failed to duplicate translations for layout`, { sourceResourceId, targetResourceId, organizationId: command.organizationId, error: error instanceof Error ? error.message : String(error), }); throw error; } } } ================================================ FILE: apps/api/src/app/layouts-v2/usecases/duplicate-layout/index.ts ================================================ export * from './duplicate-layout.command'; export * from './duplicate-layout.use-case'; ================================================ FILE: apps/api/src/app/layouts-v2/usecases/get-layout-usage/get-layout-usage.command.ts ================================================ import { EnvironmentCommand } from '@novu/application-generic'; import { IsString } from 'class-validator'; export class GetLayoutUsageCommand extends EnvironmentCommand { @IsString() layoutIdOrInternalId: string; } ================================================ FILE: apps/api/src/app/layouts-v2/usecases/get-layout-usage/get-layout-usage.usecase.ts ================================================ import { Injectable } from '@nestjs/common'; import { GetLayoutCommand, GetLayoutUseCase, InstrumentUsecase } from '@novu/application-generic'; import { ControlValuesRepository, NotificationTemplateRepository } from '@novu/dal'; import { ControlValuesLevelEnum } from '@novu/shared'; import { GetLayoutUsageResponseDto, WorkflowInfoDto } from '../../dtos'; import { GetLayoutUsageCommand } from './get-layout-usage.command'; @Injectable() export class GetLayoutUsageUseCase { constructor( private controlValuesRepository: ControlValuesRepository, private notificationTemplateRepository: NotificationTemplateRepository, private getLayoutUseCase: GetLayoutUseCase ) {} @InstrumentUsecase() async execute(command: GetLayoutUsageCommand): Promise { // First, resolve the layout to get its internal ID const layout = await this.getLayoutUseCase.execute( GetLayoutCommand.create({ layoutIdOrInternalId: command.layoutIdOrInternalId, environmentId: command.environmentId, organizationId: command.organizationId, skipAdditionalFields: true, }) ); const workflows: WorkflowInfoDto[] = []; // Get control values that reference this layout const controlValues = await this.controlValuesRepository.find({ _environmentId: command.environmentId, _organizationId: command.organizationId, level: ControlValuesLevelEnum.STEP_CONTROLS, 'controls.layoutId': layout.layoutId, }); // Get unique workflow IDs from the control values const workflowIds = [...new Set(controlValues.map((cv) => cv._workflowId).filter(Boolean))] as string[]; // Fetch workflow information for each workflow ID for (const workflowId of workflowIds) { try { const workflow = await this.notificationTemplateRepository.findById(workflowId, command.environmentId); if (workflow && workflow.triggers && workflow.triggers.length > 0) { workflows.push({ name: workflow.name, workflowId: workflow.triggers[0].identifier, }); } } catch (error) {} } return { workflows, }; } } ================================================ FILE: apps/api/src/app/layouts-v2/usecases/get-layout-usage/index.ts ================================================ export * from './get-layout-usage.command'; export * from './get-layout-usage.usecase'; ================================================ FILE: apps/api/src/app/layouts-v2/usecases/index.ts ================================================ import { GetLayoutUseCase, GetLayoutUseCaseV0, LayoutVariablesSchemaUseCase } from '@novu/application-generic'; import { BuildLayoutIssuesUsecase } from './build-layout-issues/build-layout-issues.usecase'; import { DeleteLayoutUseCase } from './delete-layout'; import { DuplicateLayoutUseCase } from './duplicate-layout'; import { GetLayoutUsageUseCase } from './get-layout-usage'; import { ListLayoutsUseCase } from './list-layouts'; import { PreviewLayoutUsecase } from './preview-layout'; import { LayoutSyncToEnvironmentUseCase } from './sync-to-environment'; import { UpsertLayout } from './upsert-layout'; export const USE_CASES = [ UpsertLayout, GetLayoutUseCaseV0, GetLayoutUseCase, DeleteLayoutUseCase, DuplicateLayoutUseCase, ListLayoutsUseCase, LayoutVariablesSchemaUseCase, PreviewLayoutUsecase, GetLayoutUsageUseCase, BuildLayoutIssuesUsecase, LayoutSyncToEnvironmentUseCase, ]; ================================================ FILE: apps/api/src/app/layouts-v2/usecases/list-layouts/index.ts ================================================ export * from './list-layouts.command'; export * from './list-layouts.use-case'; ================================================ FILE: apps/api/src/app/layouts-v2/usecases/list-layouts/list-layouts.command.ts ================================================ import { PaginatedListCommand } from '@novu/application-generic'; import { IsOptional, IsString } from 'class-validator'; export class ListLayoutsCommand extends PaginatedListCommand { @IsString() @IsOptional() searchQuery?: string; } ================================================ FILE: apps/api/src/app/layouts-v2/usecases/list-layouts/list-layouts.use-case.spec.ts ================================================ import { LayoutEntity, LayoutRepository } from '@novu/dal'; import { ChannelTypeEnum, DirectionEnum, ResourceOriginEnum, ResourceTypeEnum } from '@novu/shared'; import { expect } from 'chai'; import sinon from 'sinon'; import { ListLayoutsCommand } from './list-layouts.command'; import { ListLayoutsUseCase } from './list-layouts.use-case'; describe('ListLayoutsUseCase', () => { let layoutRepositoryMock: sinon.SinonStubbedInstance; let listLayoutsUseCase: ListLayoutsUseCase; let mapSpy: sinon.SinonSpy; const mockUser = { _id: 'user_id', environmentId: 'env_id', organizationId: 'org_id', }; const mockLayoutEntity: LayoutEntity = { _id: 'layout_id_1', identifier: 'layout_identifier_1', name: 'Test Layout 1', isDefault: false, channel: ChannelTypeEnum.EMAIL, content: '{{content}}', contentType: 'customHtml', updatedAt: '2023-01-02T00:00:00.000Z', createdAt: '2023-01-01T00:00:00.000Z', _environmentId: 'env_id', _organizationId: 'org_id', _creatorId: 'creator_id', deleted: false, origin: ResourceOriginEnum.NOVU_CLOUD, type: ResourceTypeEnum.BRIDGE, controls: { schema: {}, uiSchema: {}, }, }; const mockLayoutEntity2: LayoutEntity = { _id: 'layout_id_2', identifier: 'layout_identifier_2', name: 'Test Layout 2', isDefault: true, channel: ChannelTypeEnum.EMAIL, content: '{{content}}', contentType: 'customHtml', updatedAt: '2023-01-02T00:00:00.000Z', createdAt: '2023-01-01T00:00:00.000Z', _environmentId: 'env_id', _organizationId: 'org_id', _creatorId: 'creator_id', deleted: false, origin: ResourceOriginEnum.NOVU_CLOUD, type: ResourceTypeEnum.BRIDGE, controls: { schema: {}, uiSchema: {}, }, }; const mockRepositoryResponse = { data: [mockLayoutEntity, mockLayoutEntity2], totalCount: 2, }; beforeEach(() => { layoutRepositoryMock = sinon.createStubInstance(LayoutRepository); listLayoutsUseCase = new ListLayoutsUseCase(layoutRepositoryMock as any); mapSpy = sinon.spy(listLayoutsUseCase as any, 'mapLayoutToResponseDto'); layoutRepositoryMock.getV2List.resolves(mockRepositoryResponse); }); afterEach(() => { sinon.restore(); }); describe('execute', () => { it('should successfully list layouts with default parameters', async () => { const command = ListLayoutsCommand.create({ user: mockUser as any, offset: 0, limit: 10, orderBy: 'createdAt', orderDirection: DirectionEnum.DESC, }); const result = await listLayoutsUseCase.execute(command); expect(result.totalCount).to.equal(2); expect(result.layouts).to.have.length(2); expect(result.layouts[0]._id).to.equal('layout_id_1'); expect(result.layouts[0].layoutId).to.equal('layout_identifier_1'); expect(result.layouts[0].name).to.equal('Test Layout 1'); expect(result.layouts[1]._id).to.equal('layout_id_2'); expect(result.layouts[1].layoutId).to.equal('layout_identifier_2'); expect(result.layouts[1].name).to.equal('Test Layout 2'); expect(layoutRepositoryMock.getV2List.calledOnce).to.be.true; const repositoryCall = layoutRepositoryMock.getV2List.firstCall.args[0]; expect(repositoryCall).to.deep.equal({ organizationId: 'org_id', environmentId: 'env_id', skip: 0, limit: 10, searchQuery: undefined, orderBy: 'createdAt', orderDirection: DirectionEnum.DESC, }); }); it('should handle search query parameter', async () => { const command = ListLayoutsCommand.create({ user: mockUser as any, offset: 0, limit: 10, orderBy: 'name', orderDirection: DirectionEnum.ASC, searchQuery: 'test search', }); await listLayoutsUseCase.execute(command); const repositoryCall = layoutRepositoryMock.getV2List.firstCall.args[0]; expect(repositoryCall.searchQuery).to.equal('test search'); expect(repositoryCall.orderBy).to.equal('name'); expect(repositoryCall.orderDirection).to.equal(DirectionEnum.ASC); }); it('should handle pagination parameters', async () => { const command = ListLayoutsCommand.create({ user: mockUser as any, offset: 20, limit: 5, orderBy: 'updatedAt', orderDirection: DirectionEnum.DESC, }); await listLayoutsUseCase.execute(command); const repositoryCall = layoutRepositoryMock.getV2List.firstCall.args[0]; expect(repositoryCall.skip).to.equal(20); expect(repositoryCall.limit).to.equal(5); expect(repositoryCall.orderBy).to.equal('updatedAt'); }); it('should return empty result when repository returns null data', async () => { layoutRepositoryMock.getV2List.resolves({ data: null, totalCount: 0 }); const command = ListLayoutsCommand.create({ user: mockUser as any, offset: 0, limit: 10, orderBy: 'createdAt', orderDirection: DirectionEnum.DESC, }); const result = await listLayoutsUseCase.execute(command); expect(result).to.deep.equal({ layouts: [], totalCount: 0, }); }); it('should return empty result when repository returns undefined data', async () => { layoutRepositoryMock.getV2List.resolves({ data: undefined, totalCount: 0 }); const command = ListLayoutsCommand.create({ user: mockUser as any, offset: 0, limit: 10, orderBy: 'createdAt', orderDirection: DirectionEnum.DESC, }); const result = await listLayoutsUseCase.execute(command); expect(result).to.deep.equal({ layouts: [], totalCount: 0, }); }); it('should handle empty data array', async () => { layoutRepositoryMock.getV2List.resolves({ data: [], totalCount: 0 }); const command = ListLayoutsCommand.create({ user: mockUser as any, offset: 0, limit: 10, orderBy: 'createdAt', orderDirection: DirectionEnum.DESC, }); const result = await listLayoutsUseCase.execute(command); expect(result).to.deep.equal({ layouts: [], totalCount: 0, }); }); it('should propagate repository errors', async () => { const error = new Error('Database connection failed'); layoutRepositoryMock.getV2List.rejects(error); const command = ListLayoutsCommand.create({ user: mockUser as any, offset: 0, limit: 10, orderBy: 'createdAt', orderDirection: DirectionEnum.DESC, }); try { await listLayoutsUseCase.execute(command); expect.fail('Should have thrown an error'); } catch (thrownError) { expect(thrownError.message).to.equal('Database connection failed'); } }); it('should call mapToResponseDto for each layout', async () => { const command = ListLayoutsCommand.create({ user: mockUser as any, offset: 0, limit: 10, orderBy: 'createdAt', orderDirection: DirectionEnum.DESC, }); const result = await listLayoutsUseCase.execute(command); expect(mapSpy.calledTwice).to.be.true; expect(result.layouts).to.have.length(2); expect(result.layouts[0]._id).to.equal('layout_id_1'); expect(result.layouts[0].layoutId).to.equal('layout_identifier_1'); expect(result.layouts[1]._id).to.equal('layout_id_2'); expect(result.layouts[1].layoutId).to.equal('layout_identifier_2'); }); it('should handle single layout in result', async () => { const singleLayoutResponse = { data: [mockLayoutEntity], totalCount: 1, }; layoutRepositoryMock.getV2List.resolves(singleLayoutResponse); const command = ListLayoutsCommand.create({ user: mockUser as any, offset: 0, limit: 10, orderBy: 'createdAt', orderDirection: DirectionEnum.DESC, }); const result = await listLayoutsUseCase.execute(command); expect(result.totalCount).to.equal(1); expect(result.layouts).to.have.length(1); expect(result.layouts[0]._id).to.equal('layout_id_1'); expect(result.layouts[0].layoutId).to.equal('layout_identifier_1'); expect(result.layouts[0].name).to.equal('Test Layout 1'); expect(mapSpy.calledOnce).to.be.true; }); it('should preserve totalCount from repository response', async () => { const responseWithDifferentTotal = { data: [mockLayoutEntity], totalCount: 100, }; layoutRepositoryMock.getV2List.resolves(responseWithDifferentTotal); const command = ListLayoutsCommand.create({ user: mockUser as any, offset: 50, limit: 10, orderBy: 'createdAt', orderDirection: DirectionEnum.DESC, }); const result = await listLayoutsUseCase.execute(command); expect(result.totalCount).to.equal(100); expect(result.layouts).to.have.length(1); }); it('should handle layouts with deleted flag correctly', async () => { const deletedLayoutEntity = { ...mockLayoutEntity, deleted: true, }; const responseWithDeletedLayout = { data: [deletedLayoutEntity], totalCount: 1, }; layoutRepositoryMock.getV2List.resolves(responseWithDeletedLayout); const command = ListLayoutsCommand.create({ user: mockUser as any, offset: 0, limit: 10, orderBy: 'createdAt', orderDirection: DirectionEnum.DESC, }); await listLayoutsUseCase.execute(command); expect(mapSpy.calledOnce).to.be.true; const mappedEntity = mapSpy.firstCall.args[0]; expect(mappedEntity.deleted).to.be.true; }); it('should handle layouts without controls', async () => { const layoutWithoutControls = { ...mockLayoutEntity, controls: undefined, }; const responseWithoutControls = { data: [layoutWithoutControls], totalCount: 1, }; layoutRepositoryMock.getV2List.resolves(responseWithoutControls); const command = ListLayoutsCommand.create({ user: mockUser as any, offset: 0, limit: 10, orderBy: 'createdAt', orderDirection: DirectionEnum.DESC, }); const result = await listLayoutsUseCase.execute(command); expect(result.layouts).to.have.length(1); expect(result.layouts[0].controls.values).to.deep.equal({}); }); it('should correctly map entity properties to DTO', async () => { const command = ListLayoutsCommand.create({ user: mockUser as any, offset: 0, limit: 10, orderBy: 'createdAt', orderDirection: DirectionEnum.DESC, }); const result = await listLayoutsUseCase.execute(command); const layoutDto = result.layouts[0]; expect(layoutDto._id).to.equal(mockLayoutEntity._id); expect(layoutDto.layoutId).to.equal(mockLayoutEntity.identifier); expect(layoutDto.name).to.equal(mockLayoutEntity.name); expect(layoutDto.isDefault).to.equal(mockLayoutEntity.isDefault); expect(layoutDto.origin).to.equal(mockLayoutEntity.origin); expect(layoutDto.type).to.equal(mockLayoutEntity.type); expect(layoutDto.updatedAt).to.equal(mockLayoutEntity.updatedAt); expect(layoutDto.createdAt).to.equal(mockLayoutEntity.createdAt); }); }); }); ================================================ FILE: apps/api/src/app/layouts-v2/usecases/list-layouts/list-layouts.use-case.ts ================================================ import { Injectable } from '@nestjs/common'; import { InstrumentUsecase, LayoutDtoV0, LayoutResponseDto, mapLayoutToResponseDto } from '@novu/application-generic'; import { LayoutEntity, LayoutRepository } from '@novu/dal'; import { ListLayoutResponseDto } from '../../dtos'; import { ListLayoutsCommand } from './list-layouts.command'; @Injectable() export class ListLayoutsUseCase { constructor(private layoutRepository: LayoutRepository) {} @InstrumentUsecase() async execute(command: ListLayoutsCommand): Promise { const res = await this.layoutRepository.getV2List({ organizationId: command.user.organizationId, environmentId: command.user.environmentId, skip: command.offset, limit: command.limit, searchQuery: command.searchQuery, orderBy: command.orderBy ? command.orderBy : 'createdAt', orderDirection: command.orderDirection, }); if (res.data === null || res.data === undefined) { return { layouts: [], totalCount: 0 }; } const layoutDtos = res.data.map((layout) => this.mapLayoutToResponseDto(layout)); return { layouts: layoutDtos, totalCount: res.totalCount, }; } private mapLayoutToResponseDto(layout: LayoutEntity): LayoutResponseDto { const layoutDto = this.mapFromEntity(layout); return mapLayoutToResponseDto({ layout: layoutDto, controlValues: null, variables: {}, }); } private mapFromEntity(layout: LayoutEntity): LayoutDtoV0 { return { ...layout, _id: layout._id, _organizationId: layout._organizationId, _environmentId: layout._environmentId, isDeleted: layout.deleted, controls: {}, }; } } ================================================ FILE: apps/api/src/app/layouts-v2/usecases/preview-layout/index.ts ================================================ export * from './preview-layout.command'; export * from './preview-layout.usecase'; ================================================ FILE: apps/api/src/app/layouts-v2/usecases/preview-layout/preview-layout.command.ts ================================================ import { EnvironmentWithUserObjectCommand } from '@novu/application-generic'; import { LayoutPreviewRequestDto } from '../../dtos/layout-preview-request.dto'; export class PreviewLayoutCommand extends EnvironmentWithUserObjectCommand { layoutIdOrInternalId: string; layoutPreviewRequestDto: LayoutPreviewRequestDto; } ================================================ FILE: apps/api/src/app/layouts-v2/usecases/preview-layout/preview-layout.usecase.spec.ts ================================================ import { ControlValueSanitizerService, CreateVariablesObject, GetLayoutUseCase, LayoutControlType, PayloadMergerService, PreviewPayloadProcessorService, PreviewStep, } from '@novu/application-generic'; import { EnvironmentRepository, EnvironmentVariableRepository } from '@novu/dal'; import { ChannelTypeEnum, LAYOUT_PREVIEW_EMAIL_STEP, LAYOUT_PREVIEW_WORKFLOW_ID, ResourceOriginEnum, } from '@novu/shared'; import { expect } from 'chai'; import sinon from 'sinon'; import { PreviewLayoutCommand } from './preview-layout.command'; import { PreviewLayoutUsecase } from './preview-layout.usecase'; import { enhanceBodyForPreview } from './preview-utils'; describe('PreviewLayoutUsecase', () => { let getLayoutUseCaseMock: sinon.SinonStubbedInstance; let createVariablesObjectMock: sinon.SinonStubbedInstance; let controlValueSanitizerMock: sinon.SinonStubbedInstance; let payloadProcessorMock: sinon.SinonStubbedInstance; let payloadMergerMock: sinon.SinonStubbedInstance; let previewStepUsecaseMock: sinon.SinonStubbedInstance; let environmentVariableRepositoryMock: sinon.SinonStubbedInstance; let environmentRepositoryMock: sinon.SinonStubbedInstance; let previewLayoutUsecase: PreviewLayoutUsecase; const mockUser = { _id: 'user_id', environmentId: 'env_id', organizationId: 'org_id', }; const mockLayout = { _id: 'layout_id', identifier: 'layout_identifier', name: 'Test Layout', controls: { values: { email: { body: '{{content}}', editorType: 'html', }, }, }, variables: { name: { type: 'string', default: 'John' }, email: { type: 'string', default: 'john@example.com' }, }, }; const mockLayoutWithoutControls = { ...mockLayout, controls: { values: {}, }, variables: {}, }; const mockControlValues = { email: { body: 'Custom {{content}}', editorType: 'html', }, }; const mockVariablesObject = { name: 'Jane', email: 'jane@example.com', }; const mockSanitizedControls = { email: { body: 'Sanitized {{content}}', editorType: 'html', }, }; const mockPreviewTemplateData = { controlValues: { email: { body: 'Processed {{content}}', editorType: 'html', }, } as LayoutControlType, payloadExample: { content: 'Test content', user: { name: 'Test User' }, }, }; const mockPayloadExample = { content: 'Merged content', user: { name: 'Merged User' }, }; const mockCleanedPayloadExample = { payload: { content: 'Cleaned content' }, subscriber: { email: 'test@example.com' }, }; const mockPreviewStepOutput = { outputs: { body: 'Final rendered content', }, }; beforeEach(() => { getLayoutUseCaseMock = sinon.createStubInstance(GetLayoutUseCase); createVariablesObjectMock = sinon.createStubInstance(CreateVariablesObject); controlValueSanitizerMock = sinon.createStubInstance(ControlValueSanitizerService); payloadProcessorMock = sinon.createStubInstance(PreviewPayloadProcessorService); payloadMergerMock = sinon.createStubInstance(PayloadMergerService); previewStepUsecaseMock = sinon.createStubInstance(PreviewStep); environmentVariableRepositoryMock = sinon.createStubInstance(EnvironmentVariableRepository); environmentRepositoryMock = sinon.createStubInstance(EnvironmentRepository); previewLayoutUsecase = new PreviewLayoutUsecase( getLayoutUseCaseMock as any, createVariablesObjectMock as any, controlValueSanitizerMock as any, payloadProcessorMock as any, payloadMergerMock as any, previewStepUsecaseMock as any, environmentVariableRepositoryMock as any, environmentRepositoryMock as any ); // Default mocks setup getLayoutUseCaseMock.execute.resolves(mockLayout as any); createVariablesObjectMock.execute.resolves(mockVariablesObject); controlValueSanitizerMock.sanitizeControlsForPreview.returns(mockSanitizedControls); controlValueSanitizerMock.processControlValues.returns({ previewTemplateData: mockPreviewTemplateData, sanitizedControls: mockSanitizedControls, }); payloadMergerMock.mergePayloadExample.resolves(mockPayloadExample); payloadProcessorMock.cleanPreviewExamplePayload.returns(mockCleanedPayloadExample); previewStepUsecaseMock.execute.resolves(mockPreviewStepOutput as any); environmentVariableRepositoryMock.findByEnvironment.resolves([]); environmentRepositoryMock.findByIdAndOrganization.resolves({ name: 'Development', type: 'dev', } as any); }); afterEach(() => { sinon.restore(); }); describe('execute', () => { it('should successfully execute with provided control values', async () => { const command = PreviewLayoutCommand.create({ user: mockUser as any, layoutIdOrInternalId: 'layout_id', layoutPreviewRequestDto: { controlValues: mockControlValues, previewPayload: { subscriber: { email: 'test@example.com' } }, }, }); const result = await previewLayoutUsecase.execute(command); expect(result.result).to.deep.equal({ preview: { body: 'Final rendered content' }, type: ChannelTypeEnum.EMAIL, }); expect(result.previewPayloadExample).to.deep.equal(mockPayloadExample); expect(result.schema).to.exist; expect(result.schema?.type).to.equal('object'); expect(result.schema?.properties).to.have.keys(['subscriber', 'context']); }); it('should use layout control values when command control values are not provided', async () => { const command = PreviewLayoutCommand.create({ user: mockUser as any, layoutIdOrInternalId: 'layout_id', layoutPreviewRequestDto: {}, }); await previewLayoutUsecase.execute(command); // Verify that layout control values were used expect(createVariablesObjectMock.execute.calledOnce).to.be.true; const createVariablesCall = createVariablesObjectMock.execute.firstCall.args[0]; expect(createVariablesCall.controlValues).to.deep.equal(Object.values(mockLayout.controls.values.email)); }); it('should use empty object when both command and layout control values are missing', async () => { getLayoutUseCaseMock.execute.resolves(mockLayoutWithoutControls as any); const command = PreviewLayoutCommand.create({ user: mockUser as any, layoutIdOrInternalId: 'layout_id', layoutPreviewRequestDto: {}, }); await previewLayoutUsecase.execute(command); // Verify empty control values were used expect(controlValueSanitizerMock.sanitizeControlsForPreview.calledOnce).to.be.true; const sanitizeCall = controlValueSanitizerMock.sanitizeControlsForPreview.firstCall.args[0]; expect(sanitizeCall).to.deep.equal({}); }); it('should call all dependencies with correct parameters', async () => { const command = PreviewLayoutCommand.create({ user: mockUser as any, layoutIdOrInternalId: 'layout_id', layoutPreviewRequestDto: { controlValues: mockControlValues, previewPayload: { subscriber: { email: 'test@example.com' } }, }, }); await previewLayoutUsecase.execute(command); // Verify getLayoutUseCase call expect(getLayoutUseCaseMock.execute.calledOnce).to.be.true; const getLayoutCall = getLayoutUseCaseMock.execute.firstCall.args[0]; expect(getLayoutCall).to.deep.equal({ layoutIdOrInternalId: 'layout_id', environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, userId: mockUser._id, }); // Verify createVariablesObject call expect(createVariablesObjectMock.execute.calledOnce).to.be.true; const createVariablesCall = createVariablesObjectMock.execute.firstCall.args[0]; expect(createVariablesCall.environmentId).to.equal(mockUser.environmentId); expect(createVariablesCall.organizationId).to.equal(mockUser.organizationId); expect(createVariablesCall.variableSchema).to.deep.equal(mockLayout.variables); // Verify controlValueSanitizer calls expect(controlValueSanitizerMock.sanitizeControlsForPreview.calledOnce).to.be.true; const sanitizeCall = controlValueSanitizerMock.sanitizeControlsForPreview.firstCall.args; expect(sanitizeCall[0]).to.deep.equal(mockControlValues); expect(sanitizeCall[1]).to.equal('layout'); expect(sanitizeCall[2]).to.equal(ResourceOriginEnum.NOVU_CLOUD); expect(controlValueSanitizerMock.processControlValues.calledOnce).to.be.true; const processCall = controlValueSanitizerMock.processControlValues.firstCall.args; expect(processCall[0]).to.deep.equal(mockSanitizedControls); expect(processCall[1]).to.deep.equal(mockLayout.variables); expect(processCall[2]).to.deep.equal(mockVariablesObject); // Verify payloadMerger call expect(payloadMergerMock.mergePayloadExample.calledOnce).to.be.true; const mergeCall = payloadMergerMock.mergePayloadExample.firstCall.args[0]; expect(mergeCall.payloadExample).to.deep.equal(mockPreviewTemplateData.payloadExample); expect(mergeCall.userPayloadExample).to.deep.equal(command.layoutPreviewRequestDto.previewPayload); expect(mergeCall.user).to.deep.equal(command.user); // Verify payloadProcessor call expect(payloadProcessorMock.cleanPreviewExamplePayload.calledOnceWith(mockPayloadExample)).to.be.true; // Verify previewStepUsecase call expect(previewStepUsecaseMock.execute.calledOnce).to.be.true; const previewCall = previewStepUsecaseMock.execute.firstCall.args[0]; expect(previewCall.payload).to.deep.equal(mockCleanedPayloadExample.payload); expect(previewCall.subscriber).to.deep.equal(mockCleanedPayloadExample.subscriber); expect(previewCall.controls).to.deep.equal({ subject: 'email-layout-preview', body: enhanceBodyForPreview( mockPreviewTemplateData.controlValues.email?.editorType ?? 'block', mockPreviewTemplateData.controlValues.email?.body ?? '' ), editorType: mockPreviewTemplateData.controlValues.email?.editorType, }); expect(previewCall.environmentId).to.equal(mockUser.environmentId); expect(previewCall.organizationId).to.equal(mockUser.organizationId); expect(previewCall.stepId).to.equal(LAYOUT_PREVIEW_EMAIL_STEP); expect(previewCall.userId).to.equal(mockUser._id); expect(previewCall.workflowId).to.equal(LAYOUT_PREVIEW_WORKFLOW_ID); expect(previewCall.workflowOrigin).to.equal(ResourceOriginEnum.NOVU_CLOUD); expect(previewCall.state).to.deep.equal([]); }); it('should handle missing previewPayload gracefully', async () => { const command = PreviewLayoutCommand.create({ user: mockUser as any, layoutIdOrInternalId: 'layout_id', layoutPreviewRequestDto: { controlValues: mockControlValues, }, }); await previewLayoutUsecase.execute(command); const mergeCall = payloadMergerMock.mergePayloadExample.firstCall.args[0]; expect(mergeCall.userPayloadExample).to.be.undefined; }); it('should handle missing variables schema', async () => { const layoutWithoutVariables = { ...mockLayout, variables: undefined, }; getLayoutUseCaseMock.execute.resolves(layoutWithoutVariables as any); const command = PreviewLayoutCommand.create({ user: mockUser as any, layoutIdOrInternalId: 'layout_id', layoutPreviewRequestDto: { controlValues: mockControlValues, }, }); await previewLayoutUsecase.execute(command); const createVariablesCall = createVariablesObjectMock.execute.firstCall.args[0]; expect(createVariablesCall.variableSchema).to.deep.equal({}); }); it('should handle missing email controls in preview template data', async () => { const templateDataWithoutEmail = { ...mockPreviewTemplateData, controlValues: {} as LayoutControlType, }; controlValueSanitizerMock.processControlValues.returns({ previewTemplateData: templateDataWithoutEmail, sanitizedControls: mockSanitizedControls, }); const command = PreviewLayoutCommand.create({ user: mockUser as any, layoutIdOrInternalId: 'layout_id', layoutPreviewRequestDto: { controlValues: mockControlValues, }, }); await previewLayoutUsecase.execute(command); expect(previewStepUsecaseMock.execute.calledOnce).to.be.true; const previewCall = previewStepUsecaseMock.execute.firstCall.args[0]; expect(previewCall.controls.body).to.eq('{}'); expect(previewCall.controls.editorType).to.eq('block'); }); it('should handle missing payload in cleaned payload example', async () => { const cleanedPayloadWithoutPayload = { payload: undefined, subscriber: { email: 'test@example.com' }, }; payloadProcessorMock.cleanPreviewExamplePayload.returns(cleanedPayloadWithoutPayload); const command = PreviewLayoutCommand.create({ user: mockUser as any, layoutIdOrInternalId: 'layout_id', layoutPreviewRequestDto: { controlValues: mockControlValues, }, }); await previewLayoutUsecase.execute(command); const previewCall = previewStepUsecaseMock.execute.firstCall.args[0]; expect(previewCall.payload).to.deep.equal({}); }); it('should handle missing subscriber in cleaned payload example', async () => { const cleanedPayloadWithoutSubscriber = { payload: { content: 'test' }, subscriber: undefined, }; payloadProcessorMock.cleanPreviewExamplePayload.returns(cleanedPayloadWithoutSubscriber); const command = PreviewLayoutCommand.create({ user: mockUser as any, layoutIdOrInternalId: 'layout_id', layoutPreviewRequestDto: { controlValues: mockControlValues, }, }); await previewLayoutUsecase.execute(command); const previewCall = previewStepUsecaseMock.execute.firstCall.args[0]; expect(previewCall.subscriber).to.deep.equal({}); }); describe('error handling', () => { it('should return fallback response when getLayoutUseCase throws error', async () => { try { const error = new Error('Layout not found'); getLayoutUseCaseMock.execute.rejects(error); const command = PreviewLayoutCommand.create({ user: mockUser as any, layoutIdOrInternalId: 'invalid_layout_id', layoutPreviewRequestDto: { controlValues: mockControlValues, }, }); await previewLayoutUsecase.execute(command); } catch (error) { expect(error.message).to.equal('Layout not found'); } }); it('should return fallback response when createVariablesObject throws error', async () => { const error = new Error('Variables creation failed'); createVariablesObjectMock.execute.rejects(error); const command = PreviewLayoutCommand.create({ user: mockUser as any, layoutIdOrInternalId: 'layout_id', layoutPreviewRequestDto: { controlValues: mockControlValues, }, }); const result = await previewLayoutUsecase.execute(command); expect(result).to.deep.equal({ result: { type: ChannelTypeEnum.EMAIL, }, previewPayloadExample: {}, schema: null, }); }); it('should return fallback response when controlValueSanitizer throws error', async () => { const error = new Error('Control value sanitization failed'); controlValueSanitizerMock.sanitizeControlsForPreview.throws(error); const command = PreviewLayoutCommand.create({ user: mockUser as any, layoutIdOrInternalId: 'layout_id', layoutPreviewRequestDto: { controlValues: mockControlValues, }, }); const result = await previewLayoutUsecase.execute(command); expect(result).to.deep.equal({ result: { type: ChannelTypeEnum.EMAIL, }, previewPayloadExample: {}, schema: null, }); }); it('should return fallback response when payloadMerger throws error', async () => { const error = new Error('Payload merge failed'); payloadMergerMock.mergePayloadExample.rejects(error); const command = PreviewLayoutCommand.create({ user: mockUser as any, layoutIdOrInternalId: 'layout_id', layoutPreviewRequestDto: { controlValues: mockControlValues, }, }); const result = await previewLayoutUsecase.execute(command); expect(result).to.deep.equal({ result: { type: ChannelTypeEnum.EMAIL, }, previewPayloadExample: {}, schema: null, }); }); it('should return fallback response when payloadProcessor throws error', async () => { const error = new Error('Payload processing failed'); payloadProcessorMock.cleanPreviewExamplePayload.throws(error); const command = PreviewLayoutCommand.create({ user: mockUser as any, layoutIdOrInternalId: 'layout_id', layoutPreviewRequestDto: { controlValues: mockControlValues, }, }); const result = await previewLayoutUsecase.execute(command); expect(result).to.deep.equal({ result: { type: ChannelTypeEnum.EMAIL, }, previewPayloadExample: {}, schema: null, }); }); it('should return fallback response when previewStepUsecase throws error', async () => { const error = new Error('Preview step execution failed'); previewStepUsecaseMock.execute.rejects(error); const command = PreviewLayoutCommand.create({ user: mockUser as any, layoutIdOrInternalId: 'layout_id', layoutPreviewRequestDto: { controlValues: mockControlValues, }, }); const result = await previewLayoutUsecase.execute(command); expect(result).to.deep.equal({ result: { type: ChannelTypeEnum.EMAIL, }, previewPayloadExample: {}, schema: null, }); }); it('should not call subsequent dependencies when early dependency fails', async () => { const error = new Error('Early failure'); createVariablesObjectMock.execute.rejects(error); const command = PreviewLayoutCommand.create({ user: mockUser as any, layoutIdOrInternalId: 'layout_id', layoutPreviewRequestDto: { controlValues: mockControlValues, }, }); await previewLayoutUsecase.execute(command); // Verify that dependencies after createVariablesObject were not called expect(controlValueSanitizerMock.sanitizeControlsForPreview.called).to.be.false; expect(payloadMergerMock.mergePayloadExample.called).to.be.false; expect(previewStepUsecaseMock.execute.called).to.be.false; }); }); describe('edge cases', () => { it('should handle empty previewStepOutput', async () => { previewStepUsecaseMock.execute.resolves({ outputs: {} } as any); const command = PreviewLayoutCommand.create({ user: mockUser as any, layoutIdOrInternalId: 'layout_id', layoutPreviewRequestDto: { controlValues: mockControlValues, }, }); const result = await previewLayoutUsecase.execute(command); expect(result.result.preview?.body).to.be.undefined; expect(result.result.type).to.equal(ChannelTypeEnum.EMAIL); }); it('should handle null previewStepOutput outputs', async () => { previewStepUsecaseMock.execute.resolves({ outputs: null } as any); const command = PreviewLayoutCommand.create({ user: mockUser as any, layoutIdOrInternalId: 'layout_id', layoutPreviewRequestDto: { controlValues: mockControlValues, }, }); const result = await previewLayoutUsecase.execute(command); expect(result.result.preview?.body).to.be.undefined; expect(result.result.type).to.equal(ChannelTypeEnum.EMAIL); }); }); }); }); ================================================ FILE: apps/api/src/app/layouts-v2/usecases/preview-layout/preview-layout.usecase.ts ================================================ import { Injectable } from '@nestjs/common'; import { buildContextSchema, buildSubscriberSchema, ControlValueSanitizerService, CreateVariablesObject, CreateVariablesObjectCommand, EmailControlType, GetLayoutCommand, GetLayoutUseCase, InstrumentUsecase, LayoutControlType, PayloadMergerService, PlatformException, PreviewPayloadProcessorService, PreviewStep, PreviewStepCommand, resolveEnvironmentVariables, } from '@novu/application-generic'; import { EnvironmentRepository, EnvironmentVariableRepository, JsonSchemaTypeEnum } from '@novu/dal'; import { ContextResolved } from '@novu/framework/internal'; import { ChannelTypeEnum, EnvironmentSystemVariables, LAYOUT_PREVIEW_EMAIL_STEP, LAYOUT_PREVIEW_WORKFLOW_ID, ResourceOriginEnum, } from '@novu/shared'; import { GenerateLayoutPreviewResponseDto } from '../../dtos/generate-layout-preview-response.dto'; import { PreviewLayoutCommand } from './preview-layout.command'; import { enhanceBodyForPreview } from './preview-utils'; @Injectable() export class PreviewLayoutUsecase { constructor( private getLayoutUseCase: GetLayoutUseCase, private createVariablesObject: CreateVariablesObject, private controlValueSanitizer: ControlValueSanitizerService, private payloadProcessor: PreviewPayloadProcessorService, private payloadMerger: PayloadMergerService, private previewStepUsecase: PreviewStep, private readonly environmentVariableRepository: EnvironmentVariableRepository, private readonly environmentRepository: EnvironmentRepository ) {} @InstrumentUsecase() async execute(command: PreviewLayoutCommand): Promise { const layout = await this.getLayoutUseCase.execute( GetLayoutCommand.create({ layoutIdOrInternalId: command.layoutIdOrInternalId, environmentId: command.user.environmentId, organizationId: command.user.organizationId, userId: command.user._id, }) ); try { const controlValues = command.layoutPreviewRequestDto.controlValues || layout.controls.values || {}; const variableSchema = layout.variables ?? {}; // extract all variables from the control values and build the variables object const variablesObject = await this.createVariablesObject.execute( CreateVariablesObjectCommand.create({ environmentId: command.user.environmentId, organizationId: command.user.organizationId, controlValues: Object.values(controlValues.email ?? {}), variableSchema, }) ); const sanitizedControls = this.controlValueSanitizer.sanitizeControlsForPreview( controlValues as Record, 'layout', ResourceOriginEnum.NOVU_CLOUD ); const { previewTemplateData } = this.controlValueSanitizer.processControlValues( sanitizedControls, variableSchema, variablesObject ); const payloadExample = await this.payloadMerger.mergePayloadExample({ payloadExample: previewTemplateData.payloadExample, userPayloadExample: command.layoutPreviewRequestDto.previewPayload, user: command.user, }); const cleanedPayloadExample = this.payloadProcessor.cleanPreviewExamplePayload(payloadExample); const { email } = previewTemplateData.controlValues as LayoutControlType; const editorType = email?.editorType ?? 'block'; const body = email?.body ?? (editorType === 'block' ? '{}' : ''); const [rawEnvVars, environmentEntity] = await Promise.all([ this.environmentVariableRepository.findByEnvironment(command.user.organizationId, command.user.environmentId), this.environmentRepository.findByIdAndOrganization(command.user.environmentId, command.user.organizationId), ]); if (!environmentEntity) throw new PlatformException('EnvironmentEntity not found'); const environmentSystemVars: EnvironmentSystemVariables = { name: environmentEntity.name, type: environmentEntity.type, }; const envVars = { ...resolveEnvironmentVariables(rawEnvVars), ...environmentSystemVars, }; const executeOutput = await this.previewStepUsecase.execute( PreviewStepCommand.create({ payload: (cleanedPayloadExample.payload ?? {}) as Record, subscriber: cleanedPayloadExample.subscriber ?? {}, context: (cleanedPayloadExample.context ?? {}) as ContextResolved, // mapping the email layout controls to the email step controls controls: { subject: 'email-layout-preview', body: enhanceBodyForPreview(editorType, body), editorType, } as EmailControlType, environmentId: command.user.environmentId, organizationId: command.user.organizationId, stepId: LAYOUT_PREVIEW_EMAIL_STEP, userId: command.user._id, workflowId: LAYOUT_PREVIEW_WORKFLOW_ID, workflowOrigin: ResourceOriginEnum.NOVU_CLOUD, layoutId: layout.layoutId, state: [], env: envVars, }) ); const { body: previewBody } = executeOutput.outputs as any; // Generate schema from the preview payload example const schema = { type: JsonSchemaTypeEnum.OBJECT, properties: { subscriber: buildSubscriberSchema(payloadExample.subscriber), context: buildContextSchema(payloadExample.context), }, }; return { result: { preview: { body: previewBody }, type: ChannelTypeEnum.EMAIL, }, previewPayloadExample: payloadExample, schema, }; } catch (error) { /* * If preview execution fails, still return valid schema and payload example * but with an empty preview result */ return { result: { type: ChannelTypeEnum.EMAIL, }, previewPayloadExample: {}, schema: null, }; } } } ================================================ FILE: apps/api/src/app/layouts-v2/usecases/preview-layout/preview-utils.ts ================================================ import { replaceMailyNodesByCondition } from '@novu/application-generic'; import { JSONContent as MailyJSONContent } from '@novu/maily-render'; import { LAYOUT_CONTENT_VARIABLE, LAYOUT_PREVIEW_CONTENT_PLACEHOLDER } from '@novu/shared'; export const enhanceBodyForPreview = (editorType: string, body: string) => { if (editorType === 'html') { return body?.replace( new RegExp(`\\{\\{\\s*${LAYOUT_CONTENT_VARIABLE}\\s*\\}\\}`), LAYOUT_PREVIEW_CONTENT_PLACEHOLDER ); } return JSON.stringify( replaceMailyNodesByCondition( body, (node) => node.type === 'variable' && node.attrs?.id === LAYOUT_CONTENT_VARIABLE, (node) => { return { type: 'text', text: LAYOUT_PREVIEW_CONTENT_PLACEHOLDER, attrs: { ...node.attrs, shouldDangerouslySetInnerHTML: true, }, } satisfies MailyJSONContent; } ) ); }; ================================================ FILE: apps/api/src/app/layouts-v2/usecases/sync-to-environment/index.ts ================================================ export * from './layout-sync-to-environment.command'; export * from './layout-sync-to-environment.usecase'; ================================================ FILE: apps/api/src/app/layouts-v2/usecases/sync-to-environment/layout-sync-to-environment.command.ts ================================================ import { EnvironmentWithUserObjectCommand } from '@novu/application-generic'; import { ClientSession } from '@novu/dal'; import { Exclude } from 'class-transformer'; import { IsDefined, IsOptional, IsString } from 'class-validator'; export class LayoutSyncToEnvironmentCommand extends EnvironmentWithUserObjectCommand { @IsString() @IsDefined() layoutIdOrInternalId: string; @IsString() @IsDefined() targetEnvironmentId: string; /** * Exclude session from the command to avoid serializing it in the response */ @IsOptional() @Exclude() session?: ClientSession | null; } ================================================ FILE: apps/api/src/app/layouts-v2/usecases/sync-to-environment/layout-sync-to-environment.usecase.ts ================================================ import { BadRequestException, Injectable } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import { GetLayoutCommand, GetLayoutUseCase, Instrument, InstrumentUsecase, LayoutResponseDto, } from '@novu/application-generic'; import { LocalizationResourceEnum } from '@novu/dal'; import { ResourceOriginEnum } from '@novu/shared'; import { UpsertLayout, UpsertLayoutCommand, UpsertLayoutDataCommand } from '../upsert-layout'; import { LayoutSyncToEnvironmentCommand } from './layout-sync-to-environment.command'; const SYNCABLE_LAYOUT_ORIGINS = [ResourceOriginEnum.NOVU_CLOUD]; class LayoutNotSyncableException extends BadRequestException { constructor(layout: Pick) { const reason = `origin '${layout.origin}' is not allowed (must be one of: ${SYNCABLE_LAYOUT_ORIGINS.join(', ')})`; super({ message: `Cannot sync layout: ${reason}`, layoutId: layout.layoutId, origin: layout.origin, allowedOrigins: SYNCABLE_LAYOUT_ORIGINS, }); } } @Injectable() export class LayoutSyncToEnvironmentUseCase { constructor( private getLayoutUseCase: GetLayoutUseCase, private upsertLayoutUseCase: UpsertLayout, private moduleRef: ModuleRef ) {} @InstrumentUsecase() async execute(command: LayoutSyncToEnvironmentCommand): Promise { if (command.user.environmentId === command.targetEnvironmentId) { throw new BadRequestException('Cannot sync layout to the same environment'); } const sourceLayout = await this.getLayoutUseCase.execute( GetLayoutCommand.create({ environmentId: command.user.environmentId, organizationId: command.user.organizationId, layoutIdOrInternalId: command.layoutIdOrInternalId, }) ); if (!this.isSyncable(sourceLayout)) { throw new LayoutNotSyncableException(sourceLayout); } const externalId = sourceLayout.layoutId; const targetLayout = await this.findLayoutInTargetEnvironment(command, externalId); const layoutDto = await this.buildRequestDto(sourceLayout); const upsertedLayout = await this.upsertLayoutUseCase.execute( UpsertLayoutCommand.create({ environmentId: command.targetEnvironmentId, organizationId: command.user.organizationId, userId: command.user._id, layoutIdOrInternalId: targetLayout?.layoutId, layoutDto, }) ); await this.publishTranslationGroup(sourceLayout.layoutId, LocalizationResourceEnum.LAYOUT, command); return upsertedLayout; } private isSyncable(layout: LayoutResponseDto): boolean { return SYNCABLE_LAYOUT_ORIGINS.includes(layout.origin); } private async buildRequestDto(sourceLayout: LayoutResponseDto): Promise { return { layoutId: sourceLayout.layoutId, name: sourceLayout.name, isTranslationEnabled: sourceLayout.isTranslationEnabled, controlValues: sourceLayout.controls?.values, }; } private async publishTranslationGroup( resourceId: string, resourceType: LocalizationResourceEnum, command: LayoutSyncToEnvironmentCommand ): Promise { const isEnterprise = process.env.NOVU_ENTERPRISE === 'true' || process.env.CI_EE_TEST === 'true'; const isSelfHosted = process.env.IS_SELF_HOSTED === 'true'; if (!isEnterprise || isSelfHosted) { return; } const publishTranslationGroup = this.moduleRef.get(require('@novu/ee-translation')?.PublishTranslationGroup, { strict: false, }); const { user, targetEnvironmentId } = command; await publishTranslationGroup.execute({ user, resourceId, resourceType, sourceEnvironmentId: user.environmentId, targetEnvironmentId, }); } @Instrument() private async findLayoutInTargetEnvironment( command: LayoutSyncToEnvironmentCommand, externalId: string ): Promise { try { return await this.getLayoutUseCase.execute( GetLayoutCommand.create({ environmentId: command.targetEnvironmentId, organizationId: command.user.organizationId, layoutIdOrInternalId: externalId, }) ); } catch (error) { return undefined; } } } ================================================ FILE: apps/api/src/app/layouts-v2/usecases/upsert-layout/index.ts ================================================ export * from './upsert-layout.command'; export * from './upsert-layout.usecase'; ================================================ FILE: apps/api/src/app/layouts-v2/usecases/upsert-layout/upsert-layout.command.ts ================================================ import { EnvironmentWithUserCommand, LayoutControlValuesDto, LayoutCreationSourceEnum, } from '@novu/application-generic'; import { MAX_NAME_LENGTH } from '@novu/shared'; import { Type } from 'class-transformer'; import { IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString, Length, ValidateNested } from 'class-validator'; export class UpsertLayoutDataCommand { @IsString() @IsOptional() layoutId?: string; @IsString() @IsNotEmpty() @Length(1, MAX_NAME_LENGTH) name: string; @IsOptional() @IsBoolean() isTranslationEnabled?: boolean; @IsOptional() @IsEnum(LayoutCreationSourceEnum) __source?: LayoutCreationSourceEnum; @IsOptional() controlValues?: LayoutControlValuesDto | null; } export class UpsertLayoutCommand extends EnvironmentWithUserCommand { @ValidateNested() @Type(() => UpsertLayoutDataCommand) layoutDto: UpsertLayoutDataCommand; @IsOptional() @IsString() layoutIdOrInternalId?: string; } ================================================ FILE: apps/api/src/app/layouts-v2/usecases/upsert-layout/upsert-layout.usecase.spec.ts ================================================ import { BadRequestException } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import { AnalyticsService, GetLayoutUseCase, GetLayoutUseCase as GetLayoutUseCaseV0, JSONSchemaDto, LayoutCreationSourceEnum, LayoutDtoV0, layoutControlSchema, mapLayoutToResponseDto, PinoLogger, UpsertControlValuesUseCase, } from '@novu/application-generic'; import { ControlValuesRepository, JsonSchemaTypeEnum, LayoutRepository } from '@novu/dal'; import { ChannelTypeEnum, ContentIssueEnum, ControlValuesLevelEnum, LayoutControlValuesDto, LayoutIssuesDto, ResourceOriginEnum, ResourceTypeEnum, slugify, } from '@novu/shared'; import { expect } from 'chai'; import sinon from 'sinon'; import { CreateLayoutUseCase, UpdateLayoutUseCase } from '../../../layouts-v1/usecases'; import { BuildLayoutIssuesUsecase } from '../build-layout-issues/build-layout-issues.usecase'; import { UpsertLayoutCommand } from './upsert-layout.command'; import { UpsertLayout } from './upsert-layout.usecase'; // Mock the utility functions const isStringifiedMailyJSONContentStub = sinon.stub(); // Mock modules using require to ensure proper stubbing sinon .stub(require('@novu/application-generic'), 'isStringifiedMailyJSONContent') .callsFake(isStringifiedMailyJSONContentStub); function setupTranslationMocks(moduleRef: sinon.SinonStubbedInstance): sinon.SinonStub { const manageTranslationsExecuteStub = sinon.stub().resolves(); (moduleRef as any).get = sinon.stub().returns({ execute: manageTranslationsExecuteStub, }); return manageTranslationsExecuteStub; } describe('UpsertLayoutUseCase', () => { let getLayoutUseV0CaseMock: sinon.SinonStubbedInstance; let createLayoutUseCaseMock: sinon.SinonStubbedInstance; let updateLayoutUseCaseMock: sinon.SinonStubbedInstance; let controlValuesRepositoryMock: sinon.SinonStubbedInstance; let upsertControlValuesUseCaseMock: sinon.SinonStubbedInstance; let layoutRepositoryMock: sinon.SinonStubbedInstance; let analyticsServiceMock: sinon.SinonStubbedInstance; let buildLayoutIssuesUsecaseMock: sinon.SinonStubbedInstance; let getLayoutUseCaseMock: sinon.SinonStubbedInstance; let moduleRefMock: sinon.SinonStubbedInstance; let pinoLoggerMock: sinon.SinonStubbedInstance; let upsertLayoutUseCase: UpsertLayout; const mockUser = { _id: 'user_id', environmentId: 'env_id', organizationId: 'org_id', }; const mockLayoutDto = { name: 'Test Layout', __source: LayoutCreationSourceEnum.DASHBOARD, controlValues: { email: { body: '{{content}}', editorType: 'html' as 'html' | 'block', }, } as LayoutControlValuesDto, }; const mockExistingLayout: LayoutDtoV0 & { _id: string } = { _id: 'existing_layout_id', identifier: 'existing_layout_identifier', name: 'Existing Layout', _creatorId: 'creator_id', isDefault: false, isDeleted: false, createdAt: '2023-01-01T00:00:00Z', updatedAt: '2023-01-01T00:00:00Z', _environmentId: 'env_id', _organizationId: 'org_id', origin: ResourceOriginEnum.NOVU_CLOUD, type: ResourceTypeEnum.BRIDGE, channel: ChannelTypeEnum.EMAIL, controls: { dataSchema: layoutControlSchema, uiSchema: {}, }, }; const mockCreatedLayout: LayoutDtoV0 & { _id: string } = { _id: 'new_layout_id', identifier: 'test-layout', name: 'Test Layout', _creatorId: 'creator_id', isDefault: true, isDeleted: false, createdAt: '2023-01-01T00:00:00Z', updatedAt: '2023-01-01T00:00:00Z', _environmentId: 'env_id', _organizationId: 'org_id', origin: ResourceOriginEnum.NOVU_CLOUD, type: ResourceTypeEnum.BRIDGE, channel: ChannelTypeEnum.EMAIL, controls: { dataSchema: layoutControlSchema, uiSchema: {}, }, }; const mockControlValues = { _id: 'control_values_id', controls: { email: { body: '{{content}}', editorType: 'html', }, }, }; const mockLayoutVariablesSchema: JSONSchemaDto = { type: JsonSchemaTypeEnum.OBJECT, properties: { subscriber: { type: JsonSchemaTypeEnum.OBJECT, properties: { email: { type: JsonSchemaTypeEnum.STRING }, firstName: { type: JsonSchemaTypeEnum.STRING }, }, }, content: { type: JsonSchemaTypeEnum.STRING }, }, }; beforeEach(() => { getLayoutUseV0CaseMock = sinon.createStubInstance(GetLayoutUseCaseV0); createLayoutUseCaseMock = sinon.createStubInstance(CreateLayoutUseCase); updateLayoutUseCaseMock = sinon.createStubInstance(UpdateLayoutUseCase); controlValuesRepositoryMock = sinon.createStubInstance(ControlValuesRepository); upsertControlValuesUseCaseMock = sinon.createStubInstance(UpsertControlValuesUseCase); layoutRepositoryMock = sinon.createStubInstance(LayoutRepository); analyticsServiceMock = sinon.createStubInstance(AnalyticsService); buildLayoutIssuesUsecaseMock = sinon.createStubInstance(BuildLayoutIssuesUsecase); getLayoutUseCaseMock = sinon.createStubInstance(GetLayoutUseCase); moduleRefMock = sinon.createStubInstance(ModuleRef); pinoLoggerMock = sinon.createStubInstance(PinoLogger); setupTranslationMocks(moduleRefMock as any); upsertLayoutUseCase = new UpsertLayout( getLayoutUseV0CaseMock as any, createLayoutUseCaseMock as any, updateLayoutUseCaseMock as any, controlValuesRepositoryMock as any, upsertControlValuesUseCaseMock as any, layoutRepositoryMock as any, analyticsServiceMock as any, buildLayoutIssuesUsecaseMock as any, getLayoutUseCaseMock as any, moduleRefMock as any, pinoLoggerMock as any ); // Default mocks setup isStringifiedMailyJSONContentStub.returns(false); buildLayoutIssuesUsecaseMock.execute.resolves({} as LayoutIssuesDto); upsertControlValuesUseCaseMock.execute.resolves(mockControlValues as any); layoutRepositoryMock.findOne.resolves(undefined); }); afterEach(() => { sinon.restore(); isStringifiedMailyJSONContentStub.reset(); }); describe('execute', () => { describe('create layout path', () => { beforeEach(() => { getLayoutUseV0CaseMock.execute.resolves(undefined); createLayoutUseCaseMock.execute.resolves(mockCreatedLayout); getLayoutUseCaseMock.execute.resolves( mapLayoutToResponseDto({ layout: mockCreatedLayout, controlValues: mockControlValues, variables: mockLayoutVariablesSchema, }) ); }); it('should successfully create a new layout when no existing layout found', async () => { const command = UpsertLayoutCommand.create({ userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, layoutDto: mockLayoutDto, }); const result = await upsertLayoutUseCase.execute(command); expect(result).to.exist; expect(result._id).to.equal(mockCreatedLayout._id); expect(result.name).to.equal(mockCreatedLayout.name); expect(createLayoutUseCaseMock.execute.calledOnce).to.be.true; expect(updateLayoutUseCaseMock.execute.called).to.be.false; }); it('should call createLayoutUseCase with correct parameters', async () => { const command = UpsertLayoutCommand.create({ userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, layoutDto: mockLayoutDto, }); await upsertLayoutUseCase.execute(command); expect(createLayoutUseCaseMock.execute.calledOnce).to.be.true; const createCommand = createLayoutUseCaseMock.execute.firstCall.args[0]; expect(createCommand.environmentId).to.equal(mockUser.environmentId); expect(createCommand.organizationId).to.equal(mockUser.organizationId); expect(createCommand.userId).to.equal(mockUser._id); expect(createCommand.name).to.equal(mockLayoutDto.name); expect(createCommand.identifier).to.equal(slugify(mockLayoutDto.name)); expect(createCommand.type).to.equal(ResourceTypeEnum.BRIDGE); expect(createCommand.origin).to.equal(ResourceOriginEnum.NOVU_CLOUD); expect(createCommand.isDefault).to.be.true; }); it('should use custom layoutId when provided instead of slugified name', async () => { const customLayoutId = 'custom-layout-identifier'; const layoutDtoWithCustomId = { ...mockLayoutDto, layoutId: customLayoutId, }; const command = UpsertLayoutCommand.create({ userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, layoutDto: layoutDtoWithCustomId, }); await upsertLayoutUseCase.execute(command); expect(createLayoutUseCaseMock.execute.calledOnce).to.be.true; const createCommand = createLayoutUseCaseMock.execute.firstCall.args[0]; expect(createCommand.identifier).to.equal(customLayoutId); expect(createCommand.name).to.equal(mockLayoutDto.name); }); it('should set isDefault to false when a default layout already exists', async () => { const existingDefaultLayout = { ...mockExistingLayout, isDefault: true }; layoutRepositoryMock.findOne.resolves(existingDefaultLayout as any); const command = UpsertLayoutCommand.create({ userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, layoutDto: mockLayoutDto, }); await upsertLayoutUseCase.execute(command); const createCommand = createLayoutUseCaseMock.execute.firstCall.args[0]; expect(createCommand.isDefault).to.be.false; }); it('should track "Layout Create" analytics event', async () => { const command = UpsertLayoutCommand.create({ userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, layoutDto: mockLayoutDto, }); await upsertLayoutUseCase.execute(command); expect(analyticsServiceMock.mixpanelTrack.calledOnce).to.be.true; const [eventName, userId, props] = analyticsServiceMock.mixpanelTrack.firstCall.args; expect(eventName).to.equal('Layout Create - [Layouts]'); expect(userId).to.equal(mockUser._id); expect(props).to.deep.equal({ _organization: mockUser.organizationId, name: mockLayoutDto.name, source: mockLayoutDto.__source, }); }); }); describe('update layout path', () => { beforeEach(() => { getLayoutUseV0CaseMock.execute.resolves(mockExistingLayout); updateLayoutUseCaseMock.execute.resolves(mockExistingLayout); getLayoutUseCaseMock.execute.resolves( mapLayoutToResponseDto({ layout: mockExistingLayout, controlValues: mockControlValues, variables: mockLayoutVariablesSchema, }) ); }); it('should successfully update an existing layout when layoutIdOrInternalId provided', async () => { const command = UpsertLayoutCommand.create({ userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, layoutDto: mockLayoutDto, layoutIdOrInternalId: 'existing_layout_id', }); const result = await upsertLayoutUseCase.execute(command); expect(result).to.exist; expect(result._id).to.equal(mockExistingLayout._id); expect(updateLayoutUseCaseMock.execute.calledOnce).to.be.true; expect(createLayoutUseCaseMock.execute.called).to.be.false; }); it('should call getLayoutUseCase with correct parameters', async () => { const command = UpsertLayoutCommand.create({ userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, layoutDto: mockLayoutDto, layoutIdOrInternalId: 'existing_layout_id', }); await upsertLayoutUseCase.execute(command); expect(getLayoutUseV0CaseMock.execute.calledOnce).to.be.true; const getCommand = getLayoutUseV0CaseMock.execute.firstCall.args[0]; expect(getCommand.layoutIdOrInternalId).to.equal('existing_layout_id'); expect(getCommand.environmentId).to.equal(mockUser.environmentId); expect(getCommand.organizationId).to.equal(mockUser.organizationId); expect(getCommand.type).to.equal(ResourceTypeEnum.BRIDGE); expect(getCommand.origin).to.equal(ResourceOriginEnum.NOVU_CLOUD); }); it('should call updateLayoutUseCase with correct parameters', async () => { const command = UpsertLayoutCommand.create({ userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, layoutDto: mockLayoutDto, layoutIdOrInternalId: 'existing_layout_id', }); await upsertLayoutUseCase.execute(command); expect(updateLayoutUseCaseMock.execute.calledOnce).to.be.true; const updateCommand = updateLayoutUseCaseMock.execute.firstCall.args[0]; expect(updateCommand.environmentId).to.equal(mockUser.environmentId); expect(updateCommand.organizationId).to.equal(mockUser.organizationId); expect(updateCommand.userId).to.equal(mockUser._id); expect(updateCommand.layoutId).to.equal(mockExistingLayout._id); expect(updateCommand.name).to.equal(mockLayoutDto.name); expect(updateCommand.type).to.equal(mockExistingLayout.type); expect(updateCommand.origin).to.equal(mockExistingLayout.origin); }); it('should track "Layout Update" analytics event', async () => { const command = UpsertLayoutCommand.create({ userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, layoutDto: mockLayoutDto, layoutIdOrInternalId: 'existing_layout_id', }); await upsertLayoutUseCase.execute(command); expect(analyticsServiceMock.mixpanelTrack.calledOnce).to.be.true; const [eventName, userId, props] = analyticsServiceMock.mixpanelTrack.firstCall.args; expect(eventName).to.equal('Layout Update - [Layouts]'); expect(userId).to.equal(mockUser._id); expect(props).to.deep.equal({ _organization: mockUser.organizationId, name: mockLayoutDto.name, source: mockLayoutDto.__source, }); }); }); describe('control values handling', () => { beforeEach(() => { getLayoutUseV0CaseMock.execute.resolves(undefined); createLayoutUseCaseMock.execute.resolves(mockCreatedLayout); getLayoutUseCaseMock.execute.resolves( mapLayoutToResponseDto({ layout: mockCreatedLayout, controlValues: mockControlValues, variables: mockLayoutVariablesSchema, }) ); }); it('should upsert control values when provided', async () => { const command = UpsertLayoutCommand.create({ userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, layoutDto: mockLayoutDto, }); await upsertLayoutUseCase.execute(command); expect(upsertControlValuesUseCaseMock.execute.calledOnce).to.be.true; const upsertCommand = upsertControlValuesUseCaseMock.execute.firstCall.args[0]; expect(upsertCommand.organizationId).to.equal(mockUser.organizationId); expect(upsertCommand.environmentId).to.equal(mockUser.environmentId); expect(upsertCommand.layoutId).to.equal(mockCreatedLayout._id); expect(upsertCommand.level).to.equal(ControlValuesLevelEnum.LAYOUT_CONTROLS); expect(upsertCommand.newControlValues).to.deep.equal(mockLayoutDto.controlValues); }); it('should delete control values when set to null', async () => { const layoutDtoWithNullControls = { ...mockLayoutDto, controlValues: null, }; const command = UpsertLayoutCommand.create({ userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, layoutDto: layoutDtoWithNullControls, }); await upsertLayoutUseCase.execute(command); expect(controlValuesRepositoryMock.delete.calledOnce).to.be.true; const deleteParams = controlValuesRepositoryMock.delete.firstCall.args[0]; expect(deleteParams._environmentId).to.equal(mockUser.environmentId); expect(deleteParams._organizationId).to.equal(mockUser.organizationId); expect(deleteParams._layoutId).to.equal(mockCreatedLayout._id); expect(deleteParams.level).to.equal(ControlValuesLevelEnum.LAYOUT_CONTROLS); }); it('should handle empty control values', async () => { const layoutDtoWithEmptyControls = { ...mockLayoutDto, controlValues: {}, }; const command = UpsertLayoutCommand.create({ userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, layoutDto: layoutDtoWithEmptyControls, }); await upsertLayoutUseCase.execute(command); expect(upsertControlValuesUseCaseMock.execute.calledOnce).to.be.true; const upsertCommand = upsertControlValuesUseCaseMock.execute.firstCall.args[0]; expect(upsertCommand.newControlValues).to.deep.equal({}); }); }); }); describe('validation', () => { describe('email content validation', () => { beforeEach(() => { getLayoutUseV0CaseMock.execute.resolves(undefined); createLayoutUseCaseMock.execute.resolves(mockCreatedLayout); getLayoutUseCaseMock.execute.resolves( mapLayoutToResponseDto({ layout: mockCreatedLayout, controlValues: mockControlValues, variables: mockLayoutVariablesSchema, }) ); }); it('should validate HTML content correctly', async () => { const htmlLayoutDto = { ...mockLayoutDto, controlValues: { email: { body: 'Valid HTML', editorType: 'html' as 'html' | 'block', }, }, }; const command = UpsertLayoutCommand.create({ userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, layoutDto: htmlLayoutDto, }); await upsertLayoutUseCase.execute(command); expect(buildLayoutIssuesUsecaseMock.execute.calledOnce).to.be.true; }); it('should throw BadRequestException for invalid HTML content with html editor type', async () => { const invalidHtmlLayoutDto = { ...mockLayoutDto, controlValues: { email: { body: 'Invalid HTML content', editorType: 'html' as 'html' | 'block', }, }, }; const command = UpsertLayoutCommand.create({ userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, layoutDto: invalidHtmlLayoutDto, }); try { await upsertLayoutUseCase.execute(command); expect.fail('Should have thrown BadRequestException'); } catch (error) { expect(error).to.be.instanceOf(BadRequestException); expect(error.message).to.equal('Content must be a valid HTML content'); } }); it('should validate Maily JSON content correctly', async () => { isStringifiedMailyJSONContentStub.returns(true); const mailyLayoutDto = { ...mockLayoutDto, controlValues: { email: { body: '{"type":"doc","content":[]}', editorType: 'block' as 'html' | 'block', }, }, }; const command = UpsertLayoutCommand.create({ userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, layoutDto: mailyLayoutDto, }); await upsertLayoutUseCase.execute(command); expect(buildLayoutIssuesUsecaseMock.execute.calledOnce).to.be.true; }); it('should throw BadRequestException for invalid Maily JSON content with block editor type', async () => { isStringifiedMailyJSONContentStub.returns(false); const invalidMailyLayoutDto = { ...mockLayoutDto, controlValues: { email: { body: 'Invalid Maily JSON', editorType: 'block' as 'html' | 'block', }, }, }; const command = UpsertLayoutCommand.create({ userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, layoutDto: invalidMailyLayoutDto, }); try { await upsertLayoutUseCase.execute(command); expect.fail('Should have thrown BadRequestException'); } catch (error) { expect(error).to.be.instanceOf(BadRequestException); expect(error.message).to.equal('Content must be a valid Maily JSON content'); } }); it('should throw BadRequestException for content that is neither HTML nor Maily JSON', async () => { isStringifiedMailyJSONContentStub.returns(false); const invalidLayoutDto = { ...mockLayoutDto, controlValues: { email: { body: 'Neither HTML nor Maily JSON', editorType: 'html' as 'html' | 'block', }, }, }; const command = UpsertLayoutCommand.create({ userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, layoutDto: invalidLayoutDto, }); try { await upsertLayoutUseCase.execute(command); expect.fail('Should have thrown BadRequestException'); } catch (error) { expect(error).to.be.instanceOf(BadRequestException); expect(error.message).to.equal('Content must be a valid HTML content'); } }); it('should skip email validation when no email controls provided', async () => { const noEmailLayoutDto = { ...mockLayoutDto, controlValues: {}, }; const command = UpsertLayoutCommand.create({ userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, layoutDto: noEmailLayoutDto, }); await upsertLayoutUseCase.execute(command); expect(buildLayoutIssuesUsecaseMock.execute.calledOnce).to.be.true; }); }); describe('layout issues validation', () => { beforeEach(() => { getLayoutUseV0CaseMock.execute.resolves(undefined); createLayoutUseCaseMock.execute.resolves(mockCreatedLayout); getLayoutUseCaseMock.execute.resolves( mapLayoutToResponseDto({ layout: mockCreatedLayout, controlValues: mockControlValues, variables: mockLayoutVariablesSchema, }) ); }); it('should call buildLayoutIssuesUsecase with correct parameters', async () => { const command = UpsertLayoutCommand.create({ userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, layoutDto: mockLayoutDto, }); await upsertLayoutUseCase.execute(command); expect(buildLayoutIssuesUsecaseMock.execute.calledOnce).to.be.true; const issuesCommand = buildLayoutIssuesUsecaseMock.execute.firstCall.args[0]; expect(issuesCommand.controlSchema).to.deep.equal(layoutControlSchema); expect(issuesCommand.controlValues).to.deep.equal(mockLayoutDto.controlValues); expect(issuesCommand.resourceOrigin).to.equal(ResourceOriginEnum.NOVU_CLOUD); expect(issuesCommand.userId).to.deep.equal(mockUser._id); }); it('should use EXTERNAL origin when __source is not provided', async () => { const layoutDtoWithoutSource = { ...mockLayoutDto, __source: undefined, }; const command = UpsertLayoutCommand.create({ userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, layoutDto: layoutDtoWithoutSource, }); await upsertLayoutUseCase.execute(command); const issuesCommand = buildLayoutIssuesUsecaseMock.execute.firstCall.args[0]; expect(issuesCommand.resourceOrigin).to.equal(ResourceOriginEnum.EXTERNAL); }); it('should throw BadRequestException when layout issues exist', async () => { const mockIssues: LayoutIssuesDto = { controls: { 'email.body': [ { message: 'Body is required', issueType: ContentIssueEnum.MISSING_VALUE, }, ], 'email.editorType': [ { message: 'Invalid editor type', issueType: ContentIssueEnum.ILLEGAL_VARIABLE_IN_CONTROL_VALUE, }, ], }, }; buildLayoutIssuesUsecaseMock.execute.resolves(mockIssues); const command = UpsertLayoutCommand.create({ userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, layoutDto: mockLayoutDto, }); try { await upsertLayoutUseCase.execute(command); expect.fail('Should have thrown BadRequestException'); } catch (error) { expect(error).to.be.instanceOf(BadRequestException); expect(error.response).to.deep.equal({ message: 'Layout has validation issues', ...mockIssues }); } }); }); }); describe('error handling', () => { it('should propagate errors from getLayoutUseCase', async () => { const error = new Error('Failed to get layout'); getLayoutUseV0CaseMock.execute.rejects(error); const command = UpsertLayoutCommand.create({ userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, layoutDto: mockLayoutDto, layoutIdOrInternalId: 'existing_layout_id', }); try { await upsertLayoutUseCase.execute(command); expect.fail('Should have thrown error'); } catch (thrownError) { expect(thrownError).to.equal(error); } }); it('should propagate errors from createLayoutUseCase', async () => { const error = new Error('Failed to create layout'); getLayoutUseV0CaseMock.execute.resolves(undefined); createLayoutUseCaseMock.execute.rejects(error); const command = UpsertLayoutCommand.create({ userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, layoutDto: mockLayoutDto, }); try { await upsertLayoutUseCase.execute(command); expect.fail('Should have thrown error'); } catch (thrownError) { expect(thrownError).to.equal(error); } }); it('should propagate errors from updateLayoutUseCase', async () => { const error = new Error('Failed to update layout'); getLayoutUseV0CaseMock.execute.resolves(mockExistingLayout); updateLayoutUseCaseMock.execute.rejects(error); const command = UpsertLayoutCommand.create({ userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, layoutDto: mockLayoutDto, layoutIdOrInternalId: 'existing_layout_id', }); try { await upsertLayoutUseCase.execute(command); expect.fail('Should have thrown error'); } catch (thrownError) { expect(thrownError).to.equal(error); } }); it('should propagate errors from upsertControlValuesUseCase', async () => { const error = new Error('Failed to upsert control values'); getLayoutUseV0CaseMock.execute.resolves(undefined); createLayoutUseCaseMock.execute.resolves(mockCreatedLayout); upsertControlValuesUseCaseMock.execute.rejects(error); const command = UpsertLayoutCommand.create({ userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, layoutDto: mockLayoutDto, }); try { await upsertLayoutUseCase.execute(command); expect.fail('Should have thrown error'); } catch (thrownError) { expect(thrownError).to.equal(error); } }); it('should propagate errors from getLayoutUseCase', async () => { const error = new Error('Failed to generate schema'); getLayoutUseV0CaseMock.execute.resolves(undefined); createLayoutUseCaseMock.execute.resolves(mockCreatedLayout); getLayoutUseCaseMock.execute.rejects(error); const command = UpsertLayoutCommand.create({ userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, layoutDto: mockLayoutDto, }); try { await upsertLayoutUseCase.execute(command); expect.fail('Should have thrown error'); } catch (thrownError) { expect(thrownError).to.equal(error); } }); }); describe('edge cases', () => { it('should handle layout without type and origin in update path', async () => { const layoutWithoutTypeAndOrigin = { ...mockExistingLayout, type: undefined, origin: undefined, }; getLayoutUseV0CaseMock.execute.resolves(layoutWithoutTypeAndOrigin); updateLayoutUseCaseMock.execute.resolves(layoutWithoutTypeAndOrigin); const command = UpsertLayoutCommand.create({ userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, layoutDto: mockLayoutDto, layoutIdOrInternalId: 'existing_layout_id', }); await upsertLayoutUseCase.execute(command); const updateCommand = updateLayoutUseCaseMock.execute.firstCall.args[0]; expect(updateCommand.type).to.equal(ResourceTypeEnum.BRIDGE); expect(updateCommand.origin).to.equal(ResourceOriginEnum.NOVU_CLOUD); }); it('should handle undefined control values in command', async () => { const layoutDtoWithUndefinedControls = { ...mockLayoutDto, controlValues: undefined, }; getLayoutUseV0CaseMock.execute.resolves(undefined); createLayoutUseCaseMock.execute.resolves(mockCreatedLayout); const command = UpsertLayoutCommand.create({ userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, layoutDto: layoutDtoWithUndefinedControls, }); await upsertLayoutUseCase.execute(command); expect(controlValuesRepositoryMock.delete.calledOnce).to.be.false; expect(upsertControlValuesUseCaseMock.execute.calledOnce).to.be.false; }); it('should handle empty string layoutIdOrInternalId', async () => { getLayoutUseV0CaseMock.execute.resolves(undefined); createLayoutUseCaseMock.execute.resolves(mockCreatedLayout); const command = UpsertLayoutCommand.create({ userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, layoutDto: mockLayoutDto, layoutIdOrInternalId: '', }); await upsertLayoutUseCase.execute(command); // Should follow create path since empty string is falsy expect(createLayoutUseCaseMock.execute.calledOnce).to.be.true; expect(getLayoutUseV0CaseMock.execute.called).to.be.false; }); }); describe('parameter verification', () => { beforeEach(() => { getLayoutUseV0CaseMock.execute.resolves(undefined); createLayoutUseCaseMock.execute.resolves(mockCreatedLayout); }); it('should pass all required parameters to dependencies', async () => { const command = UpsertLayoutCommand.create({ userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, layoutDto: mockLayoutDto, }); await upsertLayoutUseCase.execute(command); // Verify all major dependencies were called with correct basic parameters expect(buildLayoutIssuesUsecaseMock.execute.calledOnce).to.be.true; expect(createLayoutUseCaseMock.execute.calledOnce).to.be.true; expect(upsertControlValuesUseCaseMock.execute.calledOnce).to.be.true; expect(getLayoutUseCaseMock.execute.calledOnce).to.be.true; expect(analyticsServiceMock.mixpanelTrack.calledOnce).to.be.true; }); it('should use correct identifiers and names', async () => { const customLayoutDto = { ...mockLayoutDto, name: 'Custom Layout Name', }; const command = UpsertLayoutCommand.create({ userId: mockUser._id, environmentId: mockUser.environmentId, organizationId: mockUser.organizationId, layoutDto: customLayoutDto, }); await upsertLayoutUseCase.execute(command); const createCommand = createLayoutUseCaseMock.execute.firstCall.args[0]; expect(createCommand.name).to.equal('Custom Layout Name'); expect(createCommand.identifier).to.equal(slugify('Custom Layout Name')); }); }); }); ================================================ FILE: apps/api/src/app/layouts-v2/usecases/upsert-layout/upsert-layout.usecase.ts ================================================ import { BadRequestException, Injectable } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import { AnalyticsService, GetLayoutCommand, GetLayoutCommandV0, GetLayoutUseCase, GetLayoutUseCaseV0, InstrumentUsecase, isStringifiedMailyJSONContent, LayoutDtoV0, LayoutResponseDto, layoutControlSchema, PinoLogger, UpsertControlValuesCommand, UpsertControlValuesUseCase, } from '@novu/application-generic'; import { ControlValuesRepository, LayoutRepository, LocalizationResourceEnum } from '@novu/dal'; import { ControlValuesLevelEnum, LayoutControlValuesDto, ResourceOriginEnum, ResourceTypeEnum, slugify, } from '@novu/shared'; import { CreateLayoutCommand, CreateLayoutUseCase, UpdateLayoutCommand, UpdateLayoutUseCase, } from '../../../layouts-v1/usecases'; import { MANAGE_TRANSLATIONS } from '../../../shared/constants'; import { BuildLayoutIssuesCommand } from '../build-layout-issues/build-layout-issues.command'; import { BuildLayoutIssuesUsecase } from '../build-layout-issues/build-layout-issues.usecase'; import { UpsertLayoutCommand } from './upsert-layout.command'; @Injectable() export class UpsertLayout { constructor( private getLayoutUseCaseV0: GetLayoutUseCaseV0, private createLayoutUseCaseV0: CreateLayoutUseCase, private updateLayoutUseCaseV0: UpdateLayoutUseCase, private controlValuesRepository: ControlValuesRepository, private upsertControlValuesUseCase: UpsertControlValuesUseCase, private layoutRepository: LayoutRepository, private analyticsService: AnalyticsService, private buildLayoutIssuesUsecase: BuildLayoutIssuesUsecase, private getLayoutUseCase: GetLayoutUseCase, private moduleRef: ModuleRef, private logger: PinoLogger ) {} @InstrumentUsecase() async execute(command: UpsertLayoutCommand): Promise { const { controlValues } = command.layoutDto; await this.validateLayout({ command, controlValues, }); const existingLayout = command.layoutIdOrInternalId ? await this.getLayoutUseCaseV0.execute( GetLayoutCommandV0.create({ layoutIdOrInternalId: command.layoutIdOrInternalId, environmentId: command.environmentId, organizationId: command.organizationId, type: ResourceTypeEnum.BRIDGE, origin: ResourceOriginEnum.NOVU_CLOUD, }) ) : null; let upsertedLayout: LayoutDtoV0; if (existingLayout) { this.mixpanelTrack(command, 'Layout Update - [Layouts]'); upsertedLayout = await this.updateLayoutUseCaseV0.execute( UpdateLayoutCommand.create({ environmentId: command.environmentId, organizationId: command.organizationId, userId: command.userId, layoutId: existingLayout._id!, name: command.layoutDto.name, type: existingLayout.type ?? ResourceTypeEnum.BRIDGE, origin: existingLayout.origin ?? ResourceOriginEnum.NOVU_CLOUD, }) ); } else { this.mixpanelTrack(command, 'Layout Create - [Layouts]'); const defaultLayout = await this.layoutRepository.findOne({ _organizationId: command.organizationId, _environmentId: command.environmentId, type: ResourceTypeEnum.BRIDGE, origin: ResourceOriginEnum.NOVU_CLOUD, isDefault: true, }); upsertedLayout = await this.createLayoutUseCaseV0.execute( CreateLayoutCommand.create({ environmentId: command.environmentId, organizationId: command.organizationId, userId: command.userId, name: command.layoutDto.name, identifier: command.layoutDto.layoutId || slugify(command.layoutDto.name), type: ResourceTypeEnum.BRIDGE, origin: ResourceOriginEnum.NOVU_CLOUD, isDefault: !defaultLayout, }) ); } await this.toggleTranslationsForLayout(command, upsertedLayout); await this.upsertControlValues(command, upsertedLayout._id!); return await this.getLayoutUseCase.execute( GetLayoutCommand.create({ layoutIdOrInternalId: upsertedLayout.identifier, environmentId: command.environmentId, organizationId: command.organizationId, userId: command.userId, }) ); } private async validateLayout({ command, controlValues, }: { command: UpsertLayoutCommand; controlValues?: LayoutControlValuesDto | null; }) { if (!controlValues) { return; } if (controlValues.email) { const { body: content, editorType } = controlValues.email; const isMailyContent = isStringifiedMailyJSONContent(content); const isHtmlContent = content.includes('') && content.includes(''); if (!isMailyContent && !isHtmlContent) { throw new BadRequestException( editorType === 'html' ? 'Content must be a valid HTML content' : 'Content must be a valid Maily JSON content' ); } if (editorType === 'html' && !isHtmlContent) { throw new BadRequestException('Content must be a valid HTML content'); } else if (editorType === 'block' && !isMailyContent) { throw new BadRequestException('Content must be a valid Maily JSON content'); } } const issues = await this.buildLayoutIssuesUsecase.execute( BuildLayoutIssuesCommand.create({ controlSchema: layoutControlSchema, controlValues, resourceOrigin: command.layoutDto.__source ? ResourceOriginEnum.NOVU_CLOUD : ResourceOriginEnum.EXTERNAL, environmentId: command.environmentId, organizationId: command.organizationId, userId: command.userId, }) ); if (Object.keys(issues).length > 0) { throw new BadRequestException({ message: 'Layout has validation issues', ...issues }); } } private async upsertControlValues(command: UpsertLayoutCommand, layoutId: string) { const { layoutDto: { controlValues }, } = command; const doNothing = typeof controlValues === 'undefined'; if (doNothing) { return null; } const shouldDelete = controlValues === null; if (shouldDelete) { this.controlValuesRepository.delete({ _environmentId: command.environmentId, _organizationId: command.organizationId, _layoutId: layoutId, level: ControlValuesLevelEnum.LAYOUT_CONTROLS, }); return null; } return this.upsertControlValuesUseCase.execute( UpsertControlValuesCommand.create({ organizationId: command.organizationId, environmentId: command.environmentId, layoutId, level: ControlValuesLevelEnum.LAYOUT_CONTROLS, newControlValues: controlValues as unknown as Record, }) ); } private mixpanelTrack(command: UpsertLayoutCommand, eventName: string) { this.analyticsService.mixpanelTrack(eventName, command.userId, { _organization: command.organizationId, name: command.layoutDto.name, source: command.layoutDto.__source, }); } private async toggleTranslationsForLayout(command: UpsertLayoutCommand, layoutDto: LayoutDtoV0) { const isEnterprise = process.env.NOVU_ENTERPRISE === 'true' || process.env.CI_EE_TEST === 'true'; const isSelfHosted = process.env.IS_SELF_HOSTED === 'true'; if (!isEnterprise || isSelfHosted) { return; } try { const manageTranslations = this.moduleRef.get(MANAGE_TRANSLATIONS, { strict: false, }); await manageTranslations.execute({ enabled: command.layoutDto.isTranslationEnabled, resourceId: layoutDto.identifier, resourceType: LocalizationResourceEnum.LAYOUT, organizationId: command.organizationId, environmentId: command.environmentId, userId: command.userId, resourceEntity: layoutDto, }); } catch (error) { this.logger.error( `Failed to ${command.layoutDto.isTranslationEnabled ? 'enable' : 'disable'} translations for layout`, { layoutId: layoutDto.identifier, enabled: command.layoutDto.isTranslationEnabled, organizationId: command.organizationId, error: error instanceof Error ? error.message : String(error), } ); throw error; } } } ================================================ FILE: apps/api/src/app/layouts-v2/utils/layout-templates.ts ================================================ export const EMPTY_LAYOUT = { type: 'doc', content: [ { type: 'paragraph', attrs: { textAlign: null, showIfKey: null }, content: [{ type: 'text', text: ' ' }], }, { type: 'paragraph', attrs: { textAlign: 'left', showIfKey: null }, content: [ { type: 'variable', attrs: { id: 'content', label: null, fallback: null, required: false, aliasFor: null, }, }, ], }, { type: 'paragraph', attrs: { textAlign: null, showIfKey: null }, content: [{ type: 'text', text: ' ' }], }, ], }; export const createDefaultLayout = (organizationName: string) => ({ type: 'doc', content: [ { type: 'columns', attrs: { showIfKey: null, gap: 8 }, content: [ { type: 'column', attrs: { columnId: '36de3eda-0677-47c3-a8b7-e071dec9ce30', width: 'auto', verticalAlign: 'middle', }, content: [ { type: 'image', attrs: { src: 'https://prod-novu-app-bucket.s3.us-east-1.amazonaws.com/assets/email-editor/logo.png', alt: null, title: null, width: '48', height: '48', alignment: 'left', externalLink: null, isExternalLinkVariable: false, borderRadius: 0, isSrcVariable: false, aspectRatio: null, lockAspectRatio: true, showIfKey: null, aliasFor: null, }, }, ], }, { type: 'column', attrs: { columnId: '6feb593e-374a-4479-a1c7-872c60c2f4e0', width: 'auto', verticalAlign: 'middle', }, content: [ { type: 'paragraph', attrs: { textAlign: 'right', showIfKey: null }, }, ], }, ], }, { type: 'spacer', attrs: { height: 8, showIfKey: null }, }, { type: 'paragraph', attrs: { textAlign: null, showIfKey: null }, content: [ { type: 'variable', attrs: { id: 'content', label: null, fallback: null, required: false, aliasFor: null, }, }, ], }, { type: 'spacer', attrs: { height: 8, showIfKey: null }, }, { type: 'columns', attrs: { showIfKey: null, gap: 0 }, content: [ { type: 'column', attrs: { columnId: '8a20f82f-ecb5-4cbd-923e-ff82f3bb9b79', width: '60', verticalAlign: 'top', }, content: [ { type: 'paragraph', attrs: { textAlign: null, showIfKey: null }, content: [{ type: 'text', text: organizationName }], }, { type: 'spacer', attrs: { height: 4, showIfKey: null }, }, { type: 'footer', attrs: { textAlign: null, 'maily-component': 'footer' }, content: [ { type: 'text', marks: [{ type: 'textStyle' }], text: '1234 Example Street, DE 19801, United States', }, ], }, ], }, { type: 'column', attrs: { columnId: 'cd30ba93-7a8f-4d03-b66a-88ae4fe99abf', width: '40', verticalAlign: 'top', }, content: [ { type: 'paragraph', attrs: { textAlign: 'right', showIfKey: null }, content: [ { type: 'text', marks: [ { type: 'link', attrs: { href: 'https://novu.co/', target: '_blank', rel: 'noopener noreferrer nofollow', class: null, isUrlVariable: false, aliasFor: null, }, }, ], text: 'Visit Company', }, { type: 'text', text: ' | ' }, { type: 'text', marks: [ { type: 'link', attrs: { href: 'support@novu.co', target: '_blank', rel: 'noopener noreferrer nofollow', class: null, isUrlVariable: false, aliasFor: null, }, }, ], text: 'Contact Us', }, ], }, { type: 'spacer', attrs: { height: 4, showIfKey: null }, }, { type: 'section', attrs: { borderRadius: 0, backgroundColor: '#FFFFFF', align: 'left', borderWidth: 0, borderColor: '#e2e2e2', paddingTop: 0, paddingRight: 0, paddingBottom: 0, paddingLeft: 0, marginTop: 0, marginRight: 0, marginBottom: 0, marginLeft: 0, showIfKey: null, }, content: [ { type: 'paragraph', attrs: { textAlign: 'right', showIfKey: null }, content: [ { type: 'inlineImage', attrs: { height: 20, width: 20, src: 'https://prod-novu-app-bucket.s3.us-east-1.amazonaws.com/assets/email-editor/linkedin.png', isSrcVariable: false, alt: null, title: null, externalLink: 'https://www.linkedin.com/company/novuco/', isExternalLinkVariable: false, aliasFor: null, }, }, { type: 'text', text: ' ' }, { type: 'inlineImage', attrs: { height: 20, width: 20, src: 'https://prod-novu-app-bucket.s3.us-east-1.amazonaws.com/assets/email-editor/youtube.png', isSrcVariable: false, alt: null, title: null, externalLink: 'https://www.youtube.com/@novuhq', isExternalLinkVariable: false, aliasFor: null, }, }, { type: 'text', text: ' ' }, { type: 'inlineImage', attrs: { height: 20, width: 20, src: 'https://prod-novu-app-bucket.s3.us-east-1.amazonaws.com/assets/email-editor/twitter.png', isSrcVariable: false, alt: null, title: null, externalLink: 'https://x.com/novuhq', isExternalLinkVariable: false, aliasFor: null, }, }, ], }, ], }, ], }, ], }, { type: 'spacer', attrs: { height: 8, showIfKey: null }, }, ], }); ================================================ FILE: apps/api/src/app/message-template/message-template.controller.ts ================================================ import { Controller } from '@nestjs/common'; import { ApiBearerAuth } from '@nestjs/swagger'; @Controller('/message-templates') export class MessageTemplateController {} ================================================ FILE: apps/api/src/app/message-template/message-template.module.ts ================================================ import { Module } from '@nestjs/common'; import { ChangeModule } from '../change/change.module'; import { SharedModule } from '../shared/shared.module'; import { MessageTemplateController } from './message-template.controller'; import { USE_CASES } from './usecases'; @Module({ imports: [SharedModule, ChangeModule], controllers: [MessageTemplateController], providers: [...USE_CASES], exports: [...USE_CASES], }) export class MessageTemplateModule {} ================================================ FILE: apps/api/src/app/message-template/usecases/find-message-templates-by-layout/find-message-templates-by-layout.command.ts ================================================ import { LayoutId } from '@novu/shared'; import { IsDefined, IsString } from 'class-validator'; import { EnvironmentCommand } from '../../../shared/commands/project.command'; export class FindMessageTemplatesByLayoutCommand extends EnvironmentCommand { @IsDefined() @IsString() layoutId: string; } ================================================ FILE: apps/api/src/app/message-template/usecases/find-message-templates-by-layout/find-message-templates-by-layout.use-case.ts ================================================ import { Injectable } from '@nestjs/common'; import { MessageTemplateEntity, MessageTemplateRepository } from '@novu/dal'; import { FindMessageTemplatesByLayoutCommand } from './find-message-templates-by-layout.command'; const DEFAULT_PAGE_SIZE = 100; @Injectable() export class FindMessageTemplatesByLayoutUseCase { constructor(private messageTemplateRepository: MessageTemplateRepository) {} async execute(command: FindMessageTemplatesByLayoutCommand): Promise { // TODO: Implement proper pagination const messageTemplates = await this.messageTemplateRepository.getMessageTemplatesByLayout( command.environmentId, command.layoutId, { limit: DEFAULT_PAGE_SIZE } ); return messageTemplates; } } ================================================ FILE: apps/api/src/app/message-template/usecases/find-message-templates-by-layout/index.ts ================================================ export * from './find-message-templates-by-layout.command'; export * from './find-message-templates-by-layout.use-case'; ================================================ FILE: apps/api/src/app/message-template/usecases/index.ts ================================================ import { CreateMessageTemplate, DeleteMessageTemplate, UpdateMessageTemplate } from '@novu/application-generic'; import { FindMessageTemplatesByLayoutUseCase } from './find-message-templates-by-layout/find-message-templates-by-layout.use-case'; export * from './find-message-templates-by-layout'; export const USE_CASES = [ CreateMessageTemplate, FindMessageTemplatesByLayoutUseCase, UpdateMessageTemplate, DeleteMessageTemplate, ]; ================================================ FILE: apps/api/src/app/messages/dtos/delete-message-response.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { IsBoolean, IsDefined, IsString } from 'class-validator'; export class DeleteMessageResponseDto { @ApiProperty({ description: 'A boolean stating the success of the action', }) @IsBoolean() @IsDefined() acknowledged: boolean; @ApiProperty({ description: 'The status enum for the performed action', enum: ['deleted'], }) @IsString() @IsDefined() status: string; } ================================================ FILE: apps/api/src/app/messages/dtos/get-messages-requests.dto.ts ================================================ import { ApiPropertyOptional } from '@nestjs/swagger'; import { ChannelTypeEnum } from '@novu/shared'; import { Transform } from 'class-transformer'; import { IsArray, IsNumber, IsOptional, IsString } from 'class-validator'; export class GetMessagesRequestDto { @ApiPropertyOptional({ enum: [...Object.values(ChannelTypeEnum)], enumName: 'ChannelTypeEnum', }) channel?: ChannelTypeEnum; @ApiPropertyOptional({ type: String, }) @IsOptional() subscriberId?: string; @ApiPropertyOptional({ type: String, isArray: true, }) @IsOptional() transactionId?: string[]; @ApiPropertyOptional({ type: String, isArray: true, description: 'Filter by exact context keys, order insensitive (format: "type:id")', example: ['tenant:org-123', 'region:us-east-1'], }) @IsOptional() @Transform(({ value }) => { // No parameter = no filter if (value === undefined) return undefined; // Empty string = filter for records with no context if (value === '') return []; // Normalize to array and remove empty strings const array = Array.isArray(value) ? value : [value]; return array.filter((v) => v !== ''); }) @IsArray() @IsString({ each: true }) contextKeys?: string[]; @ApiPropertyOptional({ type: Number, default: 0, }) @IsOptional() @IsNumber() @Transform(({ value }) => Number(value)) page?: number; @ApiPropertyOptional({ type: Number, default: 10, }) @IsOptional() @IsNumber() @Transform(({ value }) => Number(value)) limit?: number; constructor() { this.page = 0; // Default value this.limit = 10; // Default value } } ================================================ FILE: apps/api/src/app/messages/dtos/remove-messages-by-transactionId-request.dto.ts ================================================ import { ApiPropertyOptional } from '@nestjs/swagger'; import { ChannelTypeEnum } from '@novu/shared'; import { IsEnum, IsOptional } from 'class-validator'; export class DeleteMessageByTransactionIdRequestDto { @ApiPropertyOptional({ enum: ChannelTypeEnum, description: 'The channel of the message to be deleted', }) @IsOptional() @IsEnum(ChannelTypeEnum) channel?: ChannelTypeEnum; } ================================================ FILE: apps/api/src/app/messages/e2e/get-messages.e2e.ts ================================================ import { Novu } from '@novu/api'; import { ChannelTypeEnum } from '@novu/api/models/components'; import { NotificationTemplateEntity, SubscriberEntity } from '@novu/dal'; import { CreateWorkflowDto, StepTypeEnum, WorkflowCreationSourceEnum, WorkflowResponseDto } from '@novu/shared'; import { SubscribersService, UserSession } from '@novu/testing'; import { expect } from 'chai'; import { initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('Get Message - /messages (GET) #novu-v2', () => { let session: UserSession; let template: NotificationTemplateEntity; let subscriber: SubscriberEntity; let subscriberService: SubscribersService; let novuClient: Novu; beforeEach(async () => { session = new UserSession(); await session.initialize(); template = await session.createTemplate(); subscriberService = new SubscribersService(session.organization._id, session.environment._id); subscriber = await subscriberService.createSubscriber(); novuClient = initNovuClassSdk(session); }); it('should fetch existing messages', async () => { const subscriber2 = await subscriberService.createSubscriber(); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: [ { subscriberId: subscriber.subscriberId, email: 'gg@ff.com' }, { subscriberId: subscriber2.subscriberId, email: 'john@doe.com' }, ], payload: { email: 'new-test-email@gmail.com', firstName: 'Testing of User Name', urlVar: '/test/url/path', }, }); await session.waitForJobCompletion(template._id); let response = await novuClient.messages.retrieve({}); expect(response.result.data.length).to.be.equal(4); response = await novuClient.messages.retrieve({ channel: ChannelTypeEnum.Email }); expect(response.result.data.length).to.be.equal(2); response = await novuClient.messages.retrieve({ subscriberId: subscriber2.subscriberId }); expect(response.result.data.length).to.be.equal(2); }); it('should fetch messages using transactionId filter', async () => { const subscriber3 = await subscriberService.createSubscriber(); const transactionId1 = '1566f9d0-6037-48c1-b356-42667921cadd'; const transactionId2 = 'd2d9f9b5-4a96-403a-927f-1f8f40c6c7a9'; await triggerEventWithTransactionId(template.triggers[0].identifier, subscriber3.subscriberId, transactionId1); await triggerEventWithTransactionId(template.triggers[0].identifier, subscriber3.subscriberId, transactionId2); await session.waitForWorkflowQueueCompletion(); await session.waitForSubscriberQueueCompletion(); await session.waitForStandardQueueCompletion(); await session.waitForJobCompletion(template._id); let response = await novuClient.messages.retrieve({ subscriberId: subscriber3.subscriberId }); expect(response.result.data.length).to.be.equal(4); response = await novuClient.messages.retrieve({ transactionId: [transactionId1] }); expect(response.result.data.length).to.be.equal(2); response = await novuClient.messages.retrieve({ transactionId: [transactionId1, transactionId2] }); expect(response.result.data.length).to.be.equal(4); response = await novuClient.messages.retrieve({ transactionId: [transactionId2] }); expect(response.result.data.length).to.be.equal(2); }); it('should fetch messages using contextKeys filter', async () => { const subscriber4 = await subscriberService.createSubscriber(); const workflowBody: CreateWorkflowDto = { name: 'Test Context Workflow', workflowId: 'test-context-workflow-messages', __source: WorkflowCreationSourceEnum.DASHBOARD, steps: [ { type: StepTypeEnum.IN_APP, name: 'Test Step', controlValues: { subject: 'Test Subject', body: 'Test Body', }, }, ], }; const workflowResponse = await session.testAgent.post('/v2/workflows').send(workflowBody); expect(workflowResponse.status).to.equal(201); const workflow: WorkflowResponseDto = workflowResponse.body.data; await novuClient.trigger({ workflowId: workflow.workflowId, to: subscriber4.subscriberId, payload: {}, context: { teamId: 'team-alpha' }, }); await novuClient.trigger({ workflowId: workflow.workflowId, to: subscriber4.subscriberId, payload: {}, context: { teamId: 'team-beta' }, }); await session.waitForWorkflowQueueCompletion(); await session.waitForSubscriberQueueCompletion(); await session.waitForStandardQueueCompletion(); await session.waitForJobCompletion(workflow._id); let response = await novuClient.messages.retrieve({ subscriberId: subscriber4.subscriberId }); expect(response.result.data.length).to.be.equal(2); response = await novuClient.messages.retrieve({ subscriberId: subscriber4.subscriberId, contextKeys: ['teamId:team-alpha'], }); expect(response.result.data.length).to.be.equal(1); response = await novuClient.messages.retrieve({ subscriberId: subscriber4.subscriberId, contextKeys: ['teamId:team-beta'], }); expect(response.result.data.length).to.be.equal(1); }); async function triggerEventWithTransactionId( templateIdentifier: string, subscriberId: string, transactionId: string ) { return await novuClient.trigger({ workflowId: templateIdentifier, to: [{ subscriberId, email: 'gg@ff.com' }], payload: {}, transactionId, }); } }); ================================================ FILE: apps/api/src/app/messages/e2e/remove-message.e2e.ts ================================================ import { Novu } from '@novu/api'; import { MessageRepository, NotificationTemplateEntity, SubscriberEntity } from '@novu/dal'; import { ChannelTypeEnum } from '@novu/shared'; import { SubscribersService, UserSession } from '@novu/testing'; import axios from 'axios'; import { expect } from 'chai'; import { initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; const axiosInstance = axios.create(); describe('Delete Message - /messages/:messageId (DELETE) #novu-v2', () => { let session: UserSession; const messageRepository = new MessageRepository(); let template: NotificationTemplateEntity; let subscriber: SubscriberEntity; let subscriberService: SubscribersService; let novuClient: Novu; beforeEach(async () => { session = new UserSession(); await session.initialize(); template = await session.createTemplate(); subscriberService = new SubscribersService(session.organization._id, session.environment._id); subscriber = await subscriberService.createSubscriber(); novuClient = initNovuClassSdk(session); }); it('should fail to delete non existing message', async () => { const response = await session.testAgent.delete(`/v1/messages/${MessageRepository.createObjectId()}`); expect(response.statusCode).to.equal(404); expect(response.body.error).to.equal('Not Found'); }); it('should delete a existing message', async () => { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: [{ subscriberId: subscriber.subscriberId, email: 'gg@ff.com' }], payload: { email: 'new-test-email@gmail.com', firstName: 'Testing of User Name', urlVar: '/test/url/path', }, }); await session.waitForJobCompletion(template._id); const messages = await messageRepository.findBySubscriberChannel( session.environment._id, subscriber._id, ChannelTypeEnum.EMAIL ); const message = messages[0]; await novuClient.messages.delete(message._id); const result = await messageRepository.findOne({ _id: message._id, _environmentId: message._environmentId }); expect(result).to.not.be.ok; }); }); ================================================ FILE: apps/api/src/app/messages/e2e/remove-messages-by-transactionId.e2e.ts ================================================ import { Novu } from '@novu/api'; import { MessageRepository, NotificationTemplateEntity, SubscriberEntity } from '@novu/dal'; import { ChannelTypeEnum } from '@novu/shared'; import { SubscribersService, UserSession } from '@novu/testing'; import { expect } from 'chai'; import { expectSdkExceptionGeneric, initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('Delete Messages By TransactionId - /messages/?transactionId= (DELETE) #novu-v2', () => { let session: UserSession; const messageRepository = new MessageRepository(); let template: NotificationTemplateEntity; let subscriber: SubscriberEntity; let subscriberService: SubscribersService; let novuClient: Novu; beforeEach(async () => { session = new UserSession(); await session.initialize(); template = await session.createTemplate(); subscriberService = new SubscribersService(session.organization._id, session.environment._id); subscriber = await subscriberService.createSubscriber(); novuClient = initNovuClassSdk(session); }); it('should fail to delete non existing message', async () => { const { error } = await expectSdkExceptionGeneric(() => novuClient.messages.deleteByTransactionId('abc-1234')); expect(error?.statusCode).to.equal(404); expect(error?.ctx?.error, JSON.stringify(error)).to.equal('Not Found'); }); it('should delete messages by transactionId', async () => { await novuClient.subscribers.create({ subscriberId: '123456', firstName: 'broadcast ', lastName: 'subscriber', }); const res = await novuClient.triggerBroadcast({ name: template.triggers[0].identifier, payload: { email: 'new-test-email@gmail.com', firstName: 'Testing of User Name', urlVar: '/test/url/path', }, }); await session.waitForJobCompletion(template._id); const { transactionId } = res.result; const messages = await messageRepository.find({ _environmentId: session.environment._id, _organizationId: session.organization._id, transactionId, }); expect(messages.length).to.be.greaterThan(0); expect(transactionId).to.be.ok; if (transactionId == null) { throw new Error('must have transaction id'); } await novuClient.messages.deleteByTransactionId(transactionId); const result = await messageRepository.find({ transactionId, _environmentId: session.environment._id, _organizationId: session.organization._id, }); expect(result.length).to.equal(0); }); it('should delete messages by transactionId and channel', async () => { const response = await novuClient.triggerBroadcast({ name: template.triggers[0].identifier, payload: { email: 'new-test-email@gmail.com', firstName: 'Testing of User Name', urlVar: '/test/url/path', }, }); await session.waitForJobCompletion(template._id); const { transactionId } = response.result; const messages = await messageRepository.find({ _environmentId: session.environment._id, _organizationId: session.organization._id, transactionId, }); const emailMessages = messages.filter((message) => message.channel === ChannelTypeEnum.EMAIL); const inAppMessages = messages.filter((message) => message.channel === ChannelTypeEnum.IN_APP); const inAppMessagesCount = inAppMessages.length; expect(messages.length).to.be.greaterThan(0); expect(emailMessages.length).to.be.greaterThan(0); expect(inAppMessagesCount).to.be.greaterThan(0); expect(transactionId).to.be.ok; if (transactionId == null) { throw new Error('must have transaction id'); } await novuClient.messages.deleteByTransactionId(transactionId, ChannelTypeEnum.EMAIL); const result = await messageRepository.find({ transactionId, _environmentId: session.environment._id, _organizationId: session.organization._id, }); const emailResult = result.filter((message) => message.channel === ChannelTypeEnum.EMAIL); const inAppResult = result.filter((message) => message.channel === ChannelTypeEnum.IN_APP); const inAppResultCount = inAppResult.length; expect(result.length).to.be.greaterThan(0); expect(emailResult.length).to.equal(0); expect(inAppResultCount).to.be.greaterThan(0); expect(inAppResultCount).to.equal(inAppMessagesCount); }); }); ================================================ FILE: apps/api/src/app/messages/messages.controller.ts ================================================ import { Controller, Delete, Get, HttpCode, HttpStatus, Param, Query } from '@nestjs/common'; import { ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger'; import { RequirePermissions } from '@novu/application-generic'; import { PermissionsEnum, UserSessionData } from '@novu/shared'; import { RequireAuthentication } from '../auth/framework/auth.decorator'; import { ExternalApiAccessible } from '../auth/framework/external-api.decorator'; import { ApiCommonResponses, ApiNoContentResponse, ApiOkResponse, ApiResponse, } from '../shared/framework/response.decorator'; import { SdkMethodName } from '../shared/framework/swagger/sdk.decorators'; import { UserSession } from '../shared/framework/user.decorator'; import { MessagesResponseDto } from '../widgets/dtos/message-response.dto'; import { DeleteMessageResponseDto } from './dtos/delete-message-response.dto'; import { GetMessagesRequestDto } from './dtos/get-messages-requests.dto'; import { DeleteMessageByTransactionIdRequestDto } from './dtos/remove-messages-by-transactionId-request.dto'; import { DeleteMessageParams } from './params/delete-message.param'; import { GetMessages, GetMessagesCommand } from './usecases/get-messages'; import { RemoveMessage, RemoveMessageCommand } from './usecases/remove-message'; import { RemoveMessagesByTransactionIdCommand } from './usecases/remove-messages-by-transactionId/remove-messages-by-transactionId.command'; import { RemoveMessagesByTransactionId } from './usecases/remove-messages-by-transactionId/remove-messages-by-transactionId.usecase'; @ApiCommonResponses() @RequireAuthentication() @Controller('/messages') @ApiTags('Messages') export class MessagesController { constructor( private removeMessage: RemoveMessage, private getMessagesUsecase: GetMessages, private removeMessagesByTransactionId: RemoveMessagesByTransactionId ) {} @Get('') @ExternalApiAccessible() @ApiOkResponse({ type: MessagesResponseDto, }) @ApiOperation({ summary: 'List all messages', description: `List all messages for the current environment. This API supports filtering by **channel**, **subscriberId**, and **transactionId**. This API returns a paginated list of messages.`, }) @RequirePermissions(PermissionsEnum.MESSAGE_READ) async getMessages( @UserSession() user: UserSessionData, @Query() query: GetMessagesRequestDto ): Promise { let transactionIdQuery: string[] | undefined; if (query.transactionId) { transactionIdQuery = Array.isArray(query.transactionId) ? query.transactionId : [query.transactionId]; } return await this.getMessagesUsecase.execute( GetMessagesCommand.create({ organizationId: user.organizationId, environmentId: user.environmentId, channel: query.channel, subscriberId: query.subscriberId, contextKeys: query.contextKeys, page: query.page ? Number(query.page) : 0, limit: query.limit ? Number(query.limit) : 10, transactionIds: transactionIdQuery, }) ); } @Delete('/:messageId') @ExternalApiAccessible() @ApiResponse(DeleteMessageResponseDto) @ApiOperation({ summary: 'Delete a message', description: `Delete a message entity from the Novu platform by **messageId**. This action is irreversible. **messageId** is required and of mongodbId type.`, }) @ApiParam({ name: 'messageId', type: String, required: true, example: '507f1f77bcf86cd799439011' }) @RequirePermissions(PermissionsEnum.MESSAGE_WRITE) async deleteMessage( @UserSession() user: UserSessionData, @Param() { messageId }: DeleteMessageParams ): Promise { return await this.removeMessage.execute( RemoveMessageCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, messageId, }) ); } @Delete('/transaction/:transactionId') @HttpCode(HttpStatus.NO_CONTENT) @ExternalApiAccessible() @ApiNoContentResponse() @ApiOperation({ summary: 'Delete messages by transactionId', description: `Delete multiple messages from the Novu platform using **transactionId** of triggered event. This API supports filtering by **channel** and delete all messages associated with the **transactionId**.`, }) @ApiParam({ name: 'transactionId', type: String, required: true, example: '507f1f77bcf86cd799439011' }) @SdkMethodName('deleteByTransactionId') @RequirePermissions(PermissionsEnum.MESSAGE_WRITE) async deleteMessagesByTransactionId( @UserSession() user: UserSessionData, @Param() { transactionId }: { transactionId: string }, @Query() query: DeleteMessageByTransactionIdRequestDto ) { return await this.removeMessagesByTransactionId.execute( RemoveMessagesByTransactionIdCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, transactionId, channel: query.channel, }) ); } } ================================================ FILE: apps/api/src/app/messages/messages.module.ts ================================================ import { forwardRef, Module } from '@nestjs/common'; import { TerminusModule } from '@nestjs/terminus'; import { AuthModule } from '../auth/auth.module'; import { SharedModule } from '../shared/shared.module'; import { SubscribersV1Module } from '../subscribers/subscribersV1.module'; import { WidgetsModule } from '../widgets/widgets.module'; import { MessagesController } from './messages.controller'; import { USE_CASES } from './usecases'; @Module({ imports: [SharedModule, SubscribersV1Module, AuthModule, TerminusModule, forwardRef(() => WidgetsModule)], controllers: [MessagesController], providers: [...USE_CASES], exports: [...USE_CASES], }) export class MessagesModule {} ================================================ FILE: apps/api/src/app/messages/params/delete-message.param.ts ================================================ import { IsMongoId } from 'class-validator'; export class DeleteMessageParams { @IsMongoId() messageId: string; } ================================================ FILE: apps/api/src/app/messages/usecases/get-messages/get-messages.command.ts ================================================ import { ChannelTypeEnum } from '@novu/shared'; import { IsArray, IsNumber, IsOptional, IsString } from 'class-validator'; import { EnvironmentCommand } from '../../../shared/commands/project.command'; export class GetMessagesCommand extends EnvironmentCommand { @IsOptional() subscriberId?: string; @IsOptional() channel?: ChannelTypeEnum; @IsOptional() @IsArray() @IsString({ each: true }) contextKeys?: string[]; @IsNumber() page = 0; @IsNumber() limit = 10; @IsOptional() @IsArray() @IsString({ each: true }) transactionIds?: string[] | undefined; } ================================================ FILE: apps/api/src/app/messages/usecases/get-messages/get-messages.usecase.ts ================================================ import { BadRequestException, Injectable } from '@nestjs/common'; import { FeatureFlagsService } from '@novu/application-generic'; import { MessageEntity, MessageRepository, OrganizationEntity, SubscriberEntity } from '@novu/dal'; import { ActorTypeEnum, FeatureFlagsKeysEnum } from '@novu/shared'; import { GetSubscriber, GetSubscriberCommand } from '../../../subscribers/usecases/get-subscriber'; import { GetMessagesCommand } from './get-messages.command'; @Injectable() export class GetMessages { constructor( private messageRepository: MessageRepository, private getSubscriberUseCase: GetSubscriber, private featureFlagService: FeatureFlagsService ) {} async execute(command: GetMessagesCommand) { const LIMIT = command.limit; const COUNT_LIMIT = 1000; if (LIMIT > 1000) { throw new BadRequestException('Limit can not be larger then 1000'); } const query: Partial> & { _environmentId: string; transactionId?: string[]; contextKeys?: string[]; } = { _environmentId: command.environmentId, }; if (command.subscriberId) { const subscriber = await this.getSubscriberUseCase.execute( GetSubscriberCommand.create({ subscriberId: command.subscriberId, environmentId: command.environmentId, organizationId: command.organizationId, }) ); query._subscriberId = subscriber._id; } if (command.channel) { query.channel = command.channel; } if (command.transactionIds) { query.transactionId = command.transactionIds; } if (command.contextKeys) { query.contextKeys = command.contextKeys; } const data = await this.messageRepository.getMessages(query, '', { limit: LIMIT, sort: { createdAt: -1 }, skip: command.page * LIMIT, }); for (const message of data) { if (message._actorId && message.actor?.type === ActorTypeEnum.USER) { message.actor.data = this.processUserAvatar(message.actorSubscriber); } } const isEnabled = await this.featureFlagService.getFlag({ key: FeatureFlagsKeysEnum.IS_NEW_MESSAGES_API_RESPONSE_ENABLED, organization: { _id: command.organizationId } as OrganizationEntity, defaultValue: false, }); if (isEnabled) { return { hasMore: data?.length === command.limit, page: command.page, pageSize: LIMIT, data, }; } const totalCount = await this.messageRepository.count(query); const hasMore = this.getHasMore(command.page, LIMIT, data.length, totalCount); return { page: command.page, totalCount, hasMore, pageSize: LIMIT, data, }; } private getHasMore(page: number, limit: number, feedLength: number, totalCount: number) { const currentPaginationTotal = page * limit + feedLength; return currentPaginationTotal < totalCount; } private processUserAvatar(actorSubscriber?: SubscriberEntity): string | null { return actorSubscriber?.avatar || null; } } ================================================ FILE: apps/api/src/app/messages/usecases/get-messages/index.ts ================================================ export * from './get-messages.command'; export * from './get-messages.usecase'; ================================================ FILE: apps/api/src/app/messages/usecases/index.ts ================================================ import { GetMessages } from './get-messages'; import { RemoveMessage } from './remove-message'; import { RemoveMessagesByTransactionId } from './remove-messages-by-transactionId/remove-messages-by-transactionId.usecase'; export const USE_CASES = [RemoveMessage, GetMessages, RemoveMessagesByTransactionId]; ================================================ FILE: apps/api/src/app/messages/usecases/remove-message/index.ts ================================================ export * from './remove-message.command'; export * from './remove-message.usecase'; ================================================ FILE: apps/api/src/app/messages/usecases/remove-message/remove-message.command.ts ================================================ import { IsString } from 'class-validator'; import { EnvironmentCommand } from '../../../shared/commands/project.command'; export class RemoveMessageCommand extends EnvironmentCommand { @IsString() messageId: string; } ================================================ FILE: apps/api/src/app/messages/usecases/remove-message/remove-message.usecase.ts ================================================ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { buildFeedKey, buildMessageCountKey, InvalidateCacheService } from '@novu/application-generic'; import { MessageRepository } from '@novu/dal'; import { RemoveMessageCommand } from './remove-message.command'; @Injectable() export class RemoveMessage { constructor( private invalidateCache: InvalidateCacheService, private messageRepository: MessageRepository ) {} async execute(command: RemoveMessageCommand) { const message = await this.messageRepository.findMessageById({ _environmentId: command.environmentId, _id: command.messageId, }); if (!message) { throw new NotFoundException(`Message with id ${command.messageId} not found`); } if (!message.subscriber) throw new BadRequestException(`A subscriber was not found for message ${command.messageId}`); await this.invalidateCache.invalidateQuery({ key: buildMessageCountKey().invalidate({ subscriberId: message.subscriber.subscriberId, _environmentId: command.environmentId, }), }); await this.messageRepository.delete({ _environmentId: command.environmentId, _id: command.messageId, }); return { acknowledged: true, status: 'deleted', }; } } ================================================ FILE: apps/api/src/app/messages/usecases/remove-messages-by-transactionId/remove-messages-by-transactionId.command.ts ================================================ import { ChannelTypeEnum } from '@novu/shared'; import { IsEnum, IsOptional, IsString } from 'class-validator'; import { EnvironmentCommand } from '../../../shared/commands/project.command'; export class RemoveMessagesByTransactionIdCommand extends EnvironmentCommand { @IsString() transactionId: string; @IsEnum(ChannelTypeEnum) @IsOptional() channel?: ChannelTypeEnum; } ================================================ FILE: apps/api/src/app/messages/usecases/remove-messages-by-transactionId/remove-messages-by-transactionId.usecase.ts ================================================ import { Injectable, NotFoundException } from '@nestjs/common'; import { buildFeedKey, buildMessageCountKey, InvalidateCacheService } from '@novu/application-generic'; import { EnforceEnvId, MessageEntity, MessageRepository } from '@novu/dal'; import { RemoveMessagesByTransactionIdCommand } from './remove-messages-by-transactionId.command'; @Injectable() export class RemoveMessagesByTransactionId { constructor( private messageRepository: MessageRepository, private invalidateCache: InvalidateCacheService ) {} async execute(command: RemoveMessagesByTransactionIdCommand) { const messages = await this.messageRepository.findMessagesByTransactionId({ transactionId: [command.transactionId], _environmentId: command.environmentId, _organizationId: command.organizationId, ...(command.channel && { channel: command.channel }), }); if (messages.length === 0) { throw new NotFoundException('Invalid transactionId or channel'); } for (const message of messages) { const subscriberId = message.subscriber?.subscriberId; if (subscriberId) { await this.invalidateCache.invalidateQuery({ key: buildMessageCountKey().invalidate({ subscriberId, _environmentId: command.environmentId, }), }); } } const deleteQuery: Partial & EnforceEnvId = { transactionId: command.transactionId, _environmentId: command.environmentId, _organizationId: command.organizationId, }; if (command.channel) { deleteQuery.channel = command.channel; } await this.messageRepository.delete(deleteQuery); } } ================================================ FILE: apps/api/src/app/notification-groups/dtos/create-notification-group-request.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { IsDefined, IsString } from 'class-validator'; export class CreateNotificationGroupRequestDto { @ApiProperty() @IsString() @IsDefined() name: string; } ================================================ FILE: apps/api/src/app/notification-groups/dtos/delete-notification-group-response.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { IsBoolean, IsDefined, IsString } from 'class-validator'; export class DeleteNotificationGroupResponseDto { @ApiProperty({ description: 'A boolean stating the success of the action', }) @IsBoolean() @IsDefined() acknowledged: boolean; @ApiProperty({ description: 'The status enum for the performed action', enum: ['deleted'], }) @IsString() @IsDefined() status: string; } ================================================ FILE: apps/api/src/app/notification-groups/dtos/notification-group-response.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class NotificationGroupResponseDto { @ApiPropertyOptional() _id?: string; @ApiProperty() name: string; @ApiProperty() _environmentId: string; @ApiProperty() _organizationId: string; @ApiPropertyOptional() _parentId?: string; } ================================================ FILE: apps/api/src/app/notification-groups/e2e/create-notification-group.e2e.ts ================================================ import { UserSession } from '@novu/testing'; import { expect } from 'chai'; describe('Create Notification Group - /notification-groups (POST) #novu-v0', async () => { let session: UserSession; before(async () => { session = new UserSession(); await session.initialize(); }); it('should create notification group', async () => { const testTemplate = { name: 'Test name', }; const { body } = await session.testAgent.post(`/v1/notification-groups`).send(testTemplate); expect(body.data).to.be.ok; const group = body.data; expect(group.name).to.equal(`Test name`); expect(group._environmentId).to.equal(session.environment._id); }); }); ================================================ FILE: apps/api/src/app/notification-groups/e2e/delete-notification-group.e2e.ts ================================================ import { UserSession } from '@novu/testing'; import { expect } from 'chai'; describe('Delete Notification Group - /notification-groups/:id (DELETE) #novu-v0', async () => { let session: UserSession; beforeEach(async () => { session = new UserSession(); await session.initialize(); }); it('should delete notification group by id', async () => { const postNotificationGroup1 = await session.testAgent.post(`/v1/notification-groups`).send({ name: 'Test delete group', }); const { id } = postNotificationGroup1.body.data; const getResult = await session.testAgent.get(`/v1/notification-groups/${id}`); const group = getResult.body.data; expect(group.name).to.equal(`Test delete group`); expect(group._id).to.equal(postNotificationGroup1.body.data.id); expect(group._environmentId).to.equal(session.environment._id); const { body: deleteResult } = await session.testAgent.delete(`/v1/notification-groups/${id}`); expect(deleteResult.data.acknowledged).to.equal(true); expect(deleteResult.data.status).to.equal('deleted'); const { body: getResultAfterDelete } = await session.testAgent.get(`/v1/notification-groups/${id}`); expect(getResultAfterDelete.statusCode).to.eq(404); }); it('should return 404 error when attempting to delete non-existent notification group', async () => { const postNotificationGroup1 = await session.testAgent.post(`/v1/notification-groups`).send({ name: 'Test name', }); const { id } = postNotificationGroup1.body.data; await session.testAgent.delete(`/v1/notification-groups/${id}`); const { body } = await session.testAgent.delete(`/v1/notification-groups/${id}`); expect(body.statusCode).to.equal(404); }); }); ================================================ FILE: apps/api/src/app/notification-groups/e2e/get-notification-group.e2e.ts ================================================ import { UserSession } from '@novu/testing'; import { expect } from 'chai'; describe('Get Notification Group - /notification-groups/:id (GET) #novu-v0', async () => { let session: UserSession; const testTemplate = { name: 'Test name', }; beforeEach(async () => { session = new UserSession(); await session.initialize(); }); it('should get the notification group by id', async () => { const postNotificationGroup1 = await session.testAgent.post(`/v1/notification-groups`).send(testTemplate); const { id } = postNotificationGroup1.body.data; const { body } = await session.testAgent.get(`/v1/notification-groups/${id}`); const group = body.data; expect(group.name).to.equal(`Test name`); expect(group._id).to.equal(postNotificationGroup1.body.data.id); expect(group._environmentId).to.equal(session.environment._id); }); it('should get 404 when notification group is not present with the requested id', async () => { const postNotificationGroup1 = await session.testAgent.post(`/v1/notification-groups`).send(testTemplate); const { id } = postNotificationGroup1.body.data; await session.testAgent.delete(`/v1/notification-groups/${id}`); const { body } = await session.testAgent.get(`/v1/notification-groups/${id}`); expect(body.statusCode).to.equal(404); }); }); ================================================ FILE: apps/api/src/app/notification-groups/e2e/get-notification-groups.e2e.ts ================================================ import { UserSession } from '@novu/testing'; import { expect } from 'chai'; describe('Get Notification Groups - /notification-groups (GET) #novu-v0', async () => { let session: UserSession; beforeEach(async () => { session = new UserSession(); await session.initialize(); }); it('should get all notification groups', async () => { await session.testAgent.post(`/v1/notification-groups`).send({ name: 'Test name', }); await session.testAgent.post(`/v1/notification-groups`).send({ name: 'Test name 2', }); const { body } = await session.testAgent.get(`/v1/notification-groups`); expect(body.data.length).to.equal(3); const group = body.data.find((i) => i.name === 'Test name'); expect(group.name).to.equal(`Test name`); expect(group._environmentId).to.equal(session.environment._id); }); }); ================================================ FILE: apps/api/src/app/notification-groups/e2e/update-notification-group.e2e.ts ================================================ import { UserSession } from '@novu/testing'; import { expect } from 'chai'; describe('Update Notification Group - /notification-groups/:id (PATCH) #novu-v0', async () => { let session: UserSession; beforeEach(async () => { session = new UserSession(); await session.initialize(); }); it('update the notification group by id', async () => { const postNotificationGroup = await session.testAgent.post(`/v1/notification-groups`).send({ name: 'Test name 1', }); const { id } = postNotificationGroup.body.data; const { body: getNotificationGroupResult } = await session.testAgent.get(`/v1/notification-groups/${id}`); expect(getNotificationGroupResult.data.name).to.equal(`Test name 1`); expect(getNotificationGroupResult.data._id).to.equal(postNotificationGroup.body.data.id); expect(getNotificationGroupResult.data._environmentId).to.equal(session.environment._id); const { body: putNotificationGroup } = await session.testAgent.patch(`/v1/notification-groups/${id}`).send({ name: 'Updated name', }); expect(putNotificationGroup.data._id).to.equal(id); const { body: getUpdatedNotificationGroupResult } = await session.testAgent.get(`/v1/notification-groups/${id}`); expect(getUpdatedNotificationGroupResult.data.name).to.equal(`Updated name`); expect(getUpdatedNotificationGroupResult.data.id).to.equal(id); expect(getUpdatedNotificationGroupResult.data._environmentId).to.equal(session.environment._id); }); it('should return a 404 error if the notification group to be updated does not exist', async () => { const postNotificationGroup1 = await session.testAgent.post(`/v1/notification-groups`).send({ name: 'Test name', }); const { id } = postNotificationGroup1.body.data; await session.testAgent.delete(`/v1/notification-groups/${id}`); const { body } = await session.testAgent.patch(`/v1/notification-groups/${id}`).send({ name: 'Updated name', }); expect(body.statusCode).to.equal(404); }); }); ================================================ FILE: apps/api/src/app/notification-groups/notification-groups.controller.ts ================================================ import { Body, ClassSerializerInterceptor, Controller, Delete, Get, Param, Patch, Post, UseInterceptors, } from '@nestjs/common'; import { ApiExcludeController, ApiOperation, ApiTags } from '@nestjs/swagger'; import { RequirePermissions } from '@novu/application-generic'; import { PermissionsEnum, UserSessionData } from '@novu/shared'; import { RequireAuthentication } from '../auth/framework/auth.decorator'; import { ExternalApiAccessible } from '../auth/framework/external-api.decorator'; import { ApiCommonResponses, ApiResponse } from '../shared/framework/response.decorator'; import { UserSession } from '../shared/framework/user.decorator'; import { CreateNotificationGroupRequestDto } from './dtos/create-notification-group-request.dto'; import { DeleteNotificationGroupResponseDto } from './dtos/delete-notification-group-response.dto'; import { NotificationGroupResponseDto } from './dtos/notification-group-response.dto'; import { CreateNotificationGroupCommand } from './usecases/create-notification-group/create-notification-group.command'; import { CreateNotificationGroup } from './usecases/create-notification-group/create-notification-group.usecase'; import { DeleteNotificationGroupCommand } from './usecases/delete-notification-group/delete-notification-group.command'; import { DeleteNotificationGroup } from './usecases/delete-notification-group/delete-notification-group.usecase'; import { GetNotificationGroupCommand } from './usecases/get-notification-group/get-notification-group.command'; import { GetNotificationGroup } from './usecases/get-notification-group/get-notification-group.usecase'; import { GetNotificationGroupsCommand } from './usecases/get-notification-groups/get-notification-groups.command'; import { GetNotificationGroups } from './usecases/get-notification-groups/get-notification-groups.usecase'; import { UpdateNotificationGroupCommand } from './usecases/update-notification-group/update-notification-group.command'; import { UpdateNotificationGroup } from './usecases/update-notification-group/update-notification-group.usecase'; @ApiCommonResponses() @Controller('/notification-groups') @UseInterceptors(ClassSerializerInterceptor) @RequireAuthentication() @ApiTags('Workflow groups') @ApiExcludeController() export class NotificationGroupsController { constructor( private createNotificationGroupUsecase: CreateNotificationGroup, private getNotificationGroupsUsecase: GetNotificationGroups, private getNotificationGroupUsecase: GetNotificationGroup, private deleteNotificationGroupUsecase: DeleteNotificationGroup, private updateNotificationGroupUsecase: UpdateNotificationGroup ) {} @Post('') @ExternalApiAccessible() @ApiResponse(NotificationGroupResponseDto, 201) @ApiOperation({ summary: 'Create workflow group', description: `workflow group was previously named notification group`, }) @RequirePermissions(PermissionsEnum.WORKFLOW_WRITE) createNotificationGroup( @UserSession() user: UserSessionData, @Body() body: CreateNotificationGroupRequestDto ): Promise { return this.createNotificationGroupUsecase.execute( CreateNotificationGroupCommand.create({ organizationId: user.organizationId, userId: user._id, environmentId: user.environmentId, name: body.name, }) ); } @Get('') @ExternalApiAccessible() @ApiResponse(NotificationGroupResponseDto, 200, true) @ApiOperation({ summary: 'Get workflow groups', description: `workflow group was previously named notification group`, }) @RequirePermissions(PermissionsEnum.WORKFLOW_READ) listNotificationGroups(@UserSession() user: UserSessionData): Promise { return this.getNotificationGroupsUsecase.execute( GetNotificationGroupsCommand.create({ organizationId: user.organizationId, userId: user._id, environmentId: user.environmentId, }) ); } @Get('/:id') @ExternalApiAccessible() @ApiResponse(NotificationGroupResponseDto, 200) @ApiOperation({ summary: 'Get workflow group', description: `workflow group was previously named notification group`, }) @RequirePermissions(PermissionsEnum.WORKFLOW_READ) getNotificationGroup( @UserSession() user: UserSessionData, @Param('id') id: string ): Promise { return this.getNotificationGroupUsecase.execute( GetNotificationGroupCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, userId: user._id, id, }) ); } @Patch('/:id') @ExternalApiAccessible() @ApiResponse(NotificationGroupResponseDto, 200) @ApiOperation({ summary: 'Update workflow group', description: `workflow group was previously named notification group`, }) @RequirePermissions(PermissionsEnum.WORKFLOW_WRITE) updateNotificationGroup( @UserSession() user: UserSessionData, @Param('id') id: string, @Body() body: CreateNotificationGroupRequestDto ): Promise { return this.updateNotificationGroupUsecase.execute( UpdateNotificationGroupCommand.create({ organizationId: user.organizationId, userId: user._id, environmentId: user.environmentId, name: body.name, id, }) ); } @Delete('/:id') @ExternalApiAccessible() @ApiResponse(DeleteNotificationGroupResponseDto, 200) @ApiOperation({ summary: 'Delete workflow group', description: `workflow group was previously named notification group`, }) @RequirePermissions(PermissionsEnum.WORKFLOW_WRITE) deleteNotificationGroup( @UserSession() user: UserSessionData, @Param('id') id: string ): Promise { return this.deleteNotificationGroupUsecase.execute( DeleteNotificationGroupCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, userId: user._id, id, }) ); } } ================================================ FILE: apps/api/src/app/notification-groups/notification-groups.module.ts ================================================ import { forwardRef, Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; import { ChangeModule } from '../change/change.module'; import { SharedModule } from '../shared/shared.module'; import { NotificationGroupsController } from './notification-groups.controller'; import { USE_CASES } from './usecases'; @Module({ imports: [SharedModule, forwardRef(() => AuthModule), ChangeModule], providers: [...USE_CASES], controllers: [NotificationGroupsController], exports: [...USE_CASES], }) export class NotificationGroupsModule {} ================================================ FILE: apps/api/src/app/notification-groups/usecases/create-notification-group/create-notification-group.command.ts ================================================ import { IsString } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; export class CreateNotificationGroupCommand extends EnvironmentWithUserCommand { @IsString() name: string; } ================================================ FILE: apps/api/src/app/notification-groups/usecases/create-notification-group/create-notification-group.usecase.ts ================================================ import { Injectable } from '@nestjs/common'; import { CreateChange, CreateChangeCommand } from '@novu/application-generic'; import { NotificationGroupEntity, NotificationGroupRepository } from '@novu/dal'; import { ChangeEntityTypeEnum } from '@novu/shared'; import { CreateNotificationGroupCommand } from './create-notification-group.command'; @Injectable() export class CreateNotificationGroup { constructor( private notificationGroupRepository: NotificationGroupRepository, private createChange: CreateChange ) {} async execute(command: CreateNotificationGroupCommand): Promise { const group = await this.notificationGroupRepository.findOne({ _organizationId: command.organizationId, }); const item = await this.notificationGroupRepository.create({ _environmentId: command.environmentId, _organizationId: command.organizationId, name: command.name, _parentId: group?._id, }); await this.createChange.execute( CreateChangeCommand.create({ item, environmentId: command.environmentId, organizationId: command.organizationId, userId: command.userId, type: ChangeEntityTypeEnum.NOTIFICATION_GROUP, changeId: NotificationGroupRepository.createObjectId(), }) ); return item; } } ================================================ FILE: apps/api/src/app/notification-groups/usecases/delete-notification-group/delete-notification-group.command.ts ================================================ import { IsDefined, IsString } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; export class DeleteNotificationGroupCommand extends EnvironmentWithUserCommand { @IsString() @IsDefined() id: string; } ================================================ FILE: apps/api/src/app/notification-groups/usecases/delete-notification-group/delete-notification-group.usecase.ts ================================================ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { DalException, NotificationGroupRepository } from '@novu/dal'; import { DeleteNotificationGroupCommand } from './delete-notification-group.command'; @Injectable() export class DeleteNotificationGroup { constructor(private notificationGroupRepository: NotificationGroupRepository) {} async execute(command: DeleteNotificationGroupCommand) { const { environmentId, id } = command; try { const group = await this.notificationGroupRepository.findOne({ _environmentId: environmentId, _id: id, }); if (group === null) throw new NotFoundException(); await this.notificationGroupRepository.delete({ _environmentId: environmentId, _id: id, }); } catch (e) { if (e instanceof DalException) { throw new BadRequestException(e.message); } throw e; } return { acknowledged: true, status: 'deleted', }; } } ================================================ FILE: apps/api/src/app/notification-groups/usecases/get-notification-group/get-notification-group.command.ts ================================================ import { IsDefined, IsString } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; export class GetNotificationGroupCommand extends EnvironmentWithUserCommand { @IsString() @IsDefined() id: string; } ================================================ FILE: apps/api/src/app/notification-groups/usecases/get-notification-group/get-notification-group.usecase.ts ================================================ import { Injectable, NotFoundException } from '@nestjs/common'; import { NotificationGroupEntity, NotificationGroupRepository } from '@novu/dal'; import { GetNotificationGroupCommand } from './get-notification-group.command'; @Injectable() export class GetNotificationGroup { constructor(private notificationGroupRepository: NotificationGroupRepository) {} async execute(command: GetNotificationGroupCommand): Promise { const { id, environmentId } = command; const result = await this.notificationGroupRepository.findOne({ _environmentId: environmentId, _id: id, }); if (result === null) throw new NotFoundException(); return result; } } ================================================ FILE: apps/api/src/app/notification-groups/usecases/get-notification-groups/get-notification-groups.command.ts ================================================ import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; export class GetNotificationGroupsCommand extends EnvironmentWithUserCommand {} ================================================ FILE: apps/api/src/app/notification-groups/usecases/get-notification-groups/get-notification-groups.usecase.ts ================================================ import { Injectable } from '@nestjs/common'; import { NotificationGroupEntity, NotificationGroupRepository } from '@novu/dal'; import { GetNotificationGroupsCommand } from './get-notification-groups.command'; @Injectable() export class GetNotificationGroups { constructor(private notificationGroupRepository: NotificationGroupRepository) {} async execute(command: GetNotificationGroupsCommand): Promise { return await this.notificationGroupRepository.find({ _environmentId: command.environmentId, }); } } ================================================ FILE: apps/api/src/app/notification-groups/usecases/index.ts ================================================ import { CreateNotificationGroup } from './create-notification-group/create-notification-group.usecase'; import { DeleteNotificationGroup } from './delete-notification-group/delete-notification-group.usecase'; import { GetNotificationGroup } from './get-notification-group/get-notification-group.usecase'; import { GetNotificationGroups } from './get-notification-groups/get-notification-groups.usecase'; import { UpdateNotificationGroup } from './update-notification-group/update-notification-group.usecase'; export const USE_CASES = [ GetNotificationGroups, CreateNotificationGroup, GetNotificationGroup, DeleteNotificationGroup, UpdateNotificationGroup, ]; ================================================ FILE: apps/api/src/app/notification-groups/usecases/update-notification-group/update-notification-group.command.ts ================================================ import { IsDefined, IsString } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; export class UpdateNotificationGroupCommand extends EnvironmentWithUserCommand { @IsString() @IsDefined() id: string; @IsString() @IsDefined() name: string; } ================================================ FILE: apps/api/src/app/notification-groups/usecases/update-notification-group/update-notification-group.usecase.ts ================================================ import { Injectable, NotFoundException } from '@nestjs/common'; import { NotificationGroupRepository } from '@novu/dal'; import { GetNotificationGroup } from '../get-notification-group/get-notification-group.usecase'; import { UpdateNotificationGroupCommand } from './update-notification-group.command'; @Injectable() export class UpdateNotificationGroup { constructor( private notificationGroupRepository: NotificationGroupRepository, private getNotificationGroup: GetNotificationGroup ) {} async execute(command: UpdateNotificationGroupCommand) { const { id, environmentId, name, organizationId, userId } = command; const item = await this.getNotificationGroup.execute({ environmentId, organizationId, userId, id, }); const result = await this.notificationGroupRepository.update( { _id: item._id, _environmentId: item._environmentId, }, { $set: { name, }, } ); if (result.matched === 0) { throw new NotFoundException(); } return await this.getNotificationGroup.execute({ environmentId, organizationId, userId, id, }); } } ================================================ FILE: apps/api/src/app/notifications/dtos/activities-request.dto.ts ================================================ import { ApiPropertyOptional } from '@nestjs/swagger'; import { ChannelTypeEnum, SeverityLevelEnum } from '@novu/shared'; import { Transform, Type } from 'class-transformer'; import { IsArray, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; import { IsEnumOrArray } from '../../shared/validators/is-enum-or-array'; export class ActivitiesRequestDto { @ApiPropertyOptional({ enum: [...Object.values(ChannelTypeEnum)], enumName: 'ChannelTypeEnum', isArray: true, description: 'Array of channel types', }) @IsOptional() channels?: ChannelTypeEnum[] | ChannelTypeEnum; @ApiPropertyOptional({ type: String, isArray: true, description: 'Array of template IDs or a single template ID', }) @IsOptional() templates?: string[] | string; @ApiPropertyOptional({ type: String, isArray: true, description: 'Array of email addresses or a single email address', }) @IsOptional() emails?: string | string[]; @ApiPropertyOptional({ type: String, deprecated: true, description: 'Search term (deprecated)', }) @IsOptional() search?: string; @ApiPropertyOptional({ type: String, isArray: true, description: 'Array of subscriber IDs or a single subscriber ID', }) @IsOptional() subscriberIds?: string | string[]; @ApiPropertyOptional({ type: String, isArray: true, description: 'Array of severity levels or a single severity level', }) @IsOptional() @IsEnumOrArray(SeverityLevelEnum) severity?: SeverityLevelEnum[] | SeverityLevelEnum; @ApiPropertyOptional({ type: Number, default: 0, description: 'Page number for pagination', }) @IsOptional() @Type(() => Number) @IsInt() @Min(0) page: number = 0; @ApiPropertyOptional({ type: Number, default: 10, minimum: 1, maximum: 50, description: 'Limit for pagination', }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(50) limit: number = 10; @ApiPropertyOptional({ type: String, description: 'The transaction ID to filter by', }) @IsOptional() transactionId?: string[] | string; @ApiPropertyOptional({ type: String, description: 'Topic Key for filtering notifications by topic', }) @IsOptional() @IsString() topicKey?: string; @ApiPropertyOptional({ type: String, description: 'Subscription ID for filtering notifications by subscription', }) @IsOptional() @IsString() subscriptionId?: string; @ApiPropertyOptional({ type: String, isArray: true, description: 'Filter by exact context keys, order insensitive (format: "type:id")', }) @IsOptional() @Transform(({ value }) => { // No parameter = no filter if (value === undefined) return undefined; // Empty string = filter for records with no context if (value === '') return []; // Normalize to array and remove empty strings const array = Array.isArray(value) ? value : [value]; return array.filter((v) => v !== ''); }) @IsArray() @IsString({ each: true }) contextKeys?: string[]; @ApiPropertyOptional({ type: String, description: 'Date filter for records after this timestamp. Defaults to earliest date allowed by subscription plan', }) @IsOptional() after?: string; @ApiPropertyOptional({ type: String, description: 'Date filter for records before this timestamp. Defaults to current time of request (now)', }) @IsOptional() before?: string; } ================================================ FILE: apps/api/src/app/notifications/dtos/activities-response.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { StepFilterDto } from '@novu/application-generic'; import { DaysEnum, DigestTypeEnum, DigestUnitEnum, ExecutionDetailsSourceEnum, ExecutionDetailsStatusEnum, MessageTemplateDto, MonthlyTypeEnum, OrdinalEnum, OrdinalValueEnum, ProvidersIdEnum, ProvidersIdEnumConst, ResourceOriginEnum, SeverityLevelEnum, StepTypeEnum, TriggerTypeEnum, } from '@novu/shared'; import { IsArray, IsEnum, IsNumber, IsOptional, IsString } from 'class-validator'; export class DigestTimedConfigDto { @ApiPropertyOptional({ description: 'Time at which the digest is triggered' }) @IsOptional() @IsString() atTime?: string; @ApiPropertyOptional({ description: 'Days of the week for the digest', type: 'array', items: { type: 'string', enum: Object.values(DaysEnum), }, enumName: 'DaysEnum', }) @IsOptional() @IsArray() @IsEnum(DaysEnum, { each: true }) weekDays?: DaysEnum[]; @ApiPropertyOptional({ description: 'Specific days of the month for the digest', type: [Number] }) @IsOptional() @IsArray() @IsNumber({}, { each: true }) monthDays?: number[]; @ApiPropertyOptional({ description: 'Ordinal position for the digest', enum: [...Object.values(OrdinalEnum)], enumName: 'OrdinalEnum', }) @IsOptional() @IsEnum(OrdinalEnum) ordinal?: OrdinalEnum; @ApiPropertyOptional({ description: 'Value of the ordinal', enum: [...Object.values(OrdinalValueEnum)], enumName: 'OrdinalValueEnum', }) @IsOptional() @IsEnum(OrdinalValueEnum) ordinalValue?: OrdinalValueEnum; @ApiPropertyOptional({ description: 'Type of monthly schedule', enum: [...Object.values(MonthlyTypeEnum)], enumName: 'MonthlyTypeEnum', }) @IsOptional() @IsEnum(MonthlyTypeEnum) monthlyType?: MonthlyTypeEnum; @ApiPropertyOptional({ description: 'Cron expression for scheduling' }) @IsOptional() @IsString() cronExpression?: string; @ApiPropertyOptional({ description: 'Until date for scheduling' }) @IsOptional() @IsString() untilDate?: string; } export class DigestMetadataDto { @ApiPropertyOptional({ description: 'Optional key for the digest' }) digestKey?: string; @ApiPropertyOptional({ description: 'Amount for the digest', type: Number }) amount?: number; @ApiPropertyOptional({ description: 'Unit of the digest', enum: DigestUnitEnum }) unit?: DigestUnitEnum; @ApiProperty({ enum: [...Object.values(DigestTypeEnum)], enumName: 'DigestTypeEnum', description: 'The Digest Type', type: String, }) type: DigestTypeEnum; @ApiPropertyOptional({ type: 'array', items: { type: 'object', additionalProperties: true, }, description: 'Optional array of events associated with the digest, represented as key-value pairs', }) events?: Record[]; // Properties for Regular Digest @ApiPropertyOptional({ description: 'Regular digest: Indicates if backoff is enabled for the regular digest', type: Boolean, }) backoff?: boolean; @ApiPropertyOptional({ description: 'Regular digest: Amount for backoff', type: Number }) backoffAmount?: number; @ApiPropertyOptional({ description: 'Regular digest: Unit for backoff', enum: [...Object.values(DigestUnitEnum)], enumName: 'DigestUnitEnum', }) backoffUnit?: DigestUnitEnum; @ApiPropertyOptional({ description: 'Regular digest: Indicates if the digest should update', type: Boolean }) updateMode?: boolean; // Properties for Timed Digest @ApiPropertyOptional({ description: 'Configuration for timed digest', type: () => DigestTimedConfigDto }) timed?: DigestTimedConfigDto; } export class ActivityNotificationStepResponseDto { @ApiProperty({ description: 'Unique identifier of the step', type: String }) _id: string; @ApiProperty({ description: 'Whether the step is active or not', type: Boolean }) active: boolean; @ApiPropertyOptional({ description: 'Reply callback settings', type: Object }) replyCallback?: { active: boolean; url: string; }; @ApiPropertyOptional({ description: 'Control variables', type: Object }) controlVariables?: Record; @ApiPropertyOptional({ description: 'Metadata for the workflow step', type: Object }) metadata?: any; // Adjust the type based on your actual metadata structure @ApiPropertyOptional({ description: 'Step issues', type: Object }) issues?: any; // Adjust the type based on your actual issues structure @ApiProperty({ description: 'Filter criteria for the step', isArray: true, type: StepFilterDto }) filters: StepFilterDto[]; @ApiPropertyOptional({ description: 'Optional template for the step', type: MessageTemplateDto }) template?: MessageTemplateDto; @ApiPropertyOptional({ description: 'Variants of the step', type: [ActivityNotificationStepResponseDto] }) variants?: ActivityNotificationStepResponseDto[]; // Assuming variants are the same type @ApiProperty({ description: 'The identifier for the template associated with this step', type: String }) _templateId: string; @ApiPropertyOptional({ description: 'The name of the step', type: String }) name?: string; @ApiPropertyOptional({ description: 'The unique identifier for the parent step', type: String }) _parentId?: string | null; } // Activity Notification Execution Detail Response DTO export class ActivityNotificationExecutionDetailResponseDto { @ApiProperty({ description: 'Unique identifier of the execution detail', type: String }) _id: string; @ApiPropertyOptional({ description: 'Creation time of the execution detail', type: String }) createdAt?: string; @ApiProperty({ enum: [...Object.values(ExecutionDetailsStatusEnum)], enumName: 'ExecutionDetailsStatusEnum', description: 'Status of the execution detail', type: String, }) status: ExecutionDetailsStatusEnum; @ApiProperty({ description: 'Detailed information about the execution', type: String }) detail: string; @ApiProperty({ description: 'Whether the execution is a retry or not', type: Boolean }) isRetry: boolean; @ApiProperty({ description: 'Whether the execution is a test or not', type: Boolean }) isTest: boolean; @ApiPropertyOptional({ enum: [...new Set([...Object.values(ProvidersIdEnumConst).flatMap((enumObj) => Object.values(enumObj))])], enumName: 'ProvidersIdEnum', description: 'Provider ID of the execution', type: String, }) @IsString() @IsOptional() @IsEnum(ProvidersIdEnumConst) providerId?: ProvidersIdEnum; @ApiPropertyOptional({ description: 'Raw data of the execution', type: String }) raw?: string | null; @ApiProperty({ enum: [...Object.values(ExecutionDetailsSourceEnum)], enumName: 'ExecutionDetailsSourceEnum', description: 'Source of the execution detail', type: String, }) @IsString() @IsEnum(ExecutionDetailsSourceEnum) source: ExecutionDetailsSourceEnum; } // Activity Notification Job Response DTO export class ActivityNotificationJobResponseDto { @ApiProperty({ description: 'Unique identifier of the job', type: String }) _id: string; @ApiProperty({ description: 'Type of the job', type: String }) type: StepTypeEnum; @ApiPropertyOptional({ description: 'Optional digest for the job, including metadata and events', type: DigestMetadataDto, }) digest?: DigestMetadataDto; @ApiProperty({ description: 'Execution details of the job', type: [ActivityNotificationExecutionDetailResponseDto], }) executionDetails: ActivityNotificationExecutionDetailResponseDto[]; @ApiProperty({ description: 'Step details of the job', type: ActivityNotificationStepResponseDto, }) step: ActivityNotificationStepResponseDto; @ApiPropertyOptional({ description: 'Optional context object for additional error details.', type: 'object', required: false, additionalProperties: true, example: { workflowId: 'some_wf_id', stepId: 'some_wf_id', }, }) overrides?: Record; @ApiPropertyOptional({ description: 'Optional payload for the job', type: Object }) payload?: Record; @ApiProperty({ enum: [...new Set([...Object.values(ProvidersIdEnumConst).flatMap((enumObj) => Object.values(enumObj))])], enumName: 'ProvidersIdEnum', description: 'Provider ID of the job', type: String, // Explicit type reference for enum }) providerId: ProvidersIdEnum; @ApiProperty({ description: 'Status of the job', type: String }) status: string; @ApiPropertyOptional({ description: 'Updated time of the notification', type: String }) updatedAt?: string; @ApiPropertyOptional({ description: 'The number of times the digest/delay job has been extended to align with the subscribers schedule', type: Number, }) scheduleExtensionsCount?: number; } // Activity Notification Subscriber Response DTO export class ActivityNotificationSubscriberResponseDto { @ApiPropertyOptional({ description: 'First name of the subscriber', type: String }) firstName?: string; @ApiProperty({ description: 'External unique identifier of the subscriber', type: String }) subscriberId: string; @ApiProperty({ description: 'Internal to Novu unique identifier of the subscriber', type: String }) _id: string; @ApiPropertyOptional({ description: 'Last name of the subscriber', type: String }) lastName?: string; @ApiPropertyOptional({ description: 'Email address of the subscriber', type: String }) email?: string; @ApiPropertyOptional({ description: 'Phone number of the subscriber', type: String }) phone?: string; } // Notification Trigger Variable DTO export class NotificationTriggerVariable { @ApiProperty({ description: 'Name of the variable', type: String }) name: string; } export class NotificationTriggerDto { @ApiProperty({ enum: TriggerTypeEnum, description: 'Type of the trigger', type: String, // Explicit type reference for enum }) type: TriggerTypeEnum; @ApiProperty({ description: 'Identifier of the trigger', type: String }) identifier: string; @ApiProperty({ description: 'Variables of the trigger', type: [NotificationTriggerVariable], }) variables: NotificationTriggerVariable[]; @ApiPropertyOptional({ description: 'Subscriber variables of the trigger', type: [NotificationTriggerVariable], }) subscriberVariables?: NotificationTriggerVariable[]; } // Activity Notification Template Response DTO export class ActivityNotificationTemplateResponseDto { @ApiPropertyOptional({ description: 'Unique identifier of the template', type: String }) _id?: string; @ApiProperty({ description: 'Name of the template', type: String }) name: string; @ApiProperty({ enum: [...Object.values(ResourceOriginEnum)], enumName: 'ResourceOriginEnum', description: 'Origin of the workflow', type: String, }) @IsString() @IsEnum(ResourceOriginEnum) origin?: ResourceOriginEnum; @ApiProperty({ description: 'Triggers of the template', type: [NotificationTriggerDto], }) triggers: NotificationTriggerDto[]; } export class ActivityTopicDto { @ApiProperty({ description: 'Internal Topic ID of the notification', type: String }) _topicId: string; @ApiProperty({ description: 'Topic Key of the notification', type: String }) topicKey: string; } // Activity Notification Response DTO export class ActivityNotificationResponseDto { @ApiPropertyOptional({ description: 'Unique identifier of the notification', type: String }) _id?: string; @ApiProperty({ description: 'Environment ID of the notification', type: String }) _environmentId: string; @ApiProperty({ description: 'Organization ID of the notification', type: String }) _organizationId: string; @ApiProperty({ description: 'Subscriber ID of the notification', type: String }) _subscriberId: string; // Added to align with NotificationEntity @ApiProperty({ description: 'Transaction ID of the notification', type: String }) transactionId: string; @ApiPropertyOptional({ description: 'Template ID of the notification', type: String }) _templateId?: string; // Added to align with NotificationEntity @ApiPropertyOptional({ description: 'Digested Notification ID', type: String }) _digestedNotificationId?: string; // Added to align with NotificationEntity @ApiPropertyOptional({ description: 'Creation time of the notification', type: String }) createdAt?: string; @ApiPropertyOptional({ description: 'Last updated time of the notification', type: String }) updatedAt?: string; // Added to align with NotificationEntity @ApiPropertyOptional({ description: 'Channels of the notification', enum: [...Object.values(StepTypeEnum)], enumName: 'StepTypeEnum', isArray: true, type: String, }) channels?: StepTypeEnum[]; @ApiPropertyOptional({ description: 'Subscriber of the notification', type: ActivityNotificationSubscriberResponseDto, }) subscriber?: ActivityNotificationSubscriberResponseDto; @ApiPropertyOptional({ description: 'Template of the notification', type: ActivityNotificationTemplateResponseDto, }) template?: ActivityNotificationTemplateResponseDto; @ApiPropertyOptional({ description: 'Jobs of the notification', type: [ActivityNotificationJobResponseDto], }) jobs?: ActivityNotificationJobResponseDto[]; @ApiPropertyOptional({ description: 'Payload of the notification', type: 'object', required: false, additionalProperties: true, }) payload?: Record; // Added to align with NotificationEntity @ApiPropertyOptional({ description: 'Tags associated with the notification', type: [String], }) tags?: string[]; // Added to align with NotificationEntity @ApiPropertyOptional({ description: 'Controls associated with the notification', type: 'object', required: false, additionalProperties: true, }) controls?: Record; // Added to align with NotificationEntity @ApiPropertyOptional({ description: 'To field for subscriber definition', type: 'object', required: false, additionalProperties: true, }) to?: Record; // Added to align with NotificationEntity @ApiPropertyOptional({ description: 'Topics of the notification', type: [ActivityTopicDto] }) topics?: ActivityTopicDto[]; @ApiPropertyOptional({ description: 'Severity of the notification', enum: [...Object.values(SeverityLevelEnum)], enumName: 'SeverityLevelEnum', }) severity: SeverityLevelEnum; @ApiPropertyOptional({ description: 'Criticality of the notification', type: Boolean }) critical?: boolean; @ApiPropertyOptional({ description: 'Context (single or multi) in which the notification was sent', type: [String] }) contextKeys?: string[]; } // Activities Response DTO export class ActivitiesResponseDto { @ApiProperty({ description: 'Indicates if there are more activities in the result set', type: Boolean }) hasMore: boolean; @ApiProperty({ description: 'Array of activity notifications', type: [ActivityNotificationResponseDto], }) data: ActivityNotificationResponseDto[]; @ApiProperty({ description: 'Page size of the activities', type: Number }) pageSize: number; @ApiProperty({ description: 'Current page of the activities', type: Number }) page: number; } ================================================ FILE: apps/api/src/app/notifications/dtos/activity-graph-states-response.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { ChannelTypeEnum } from '@novu/shared'; export class ActivityGraphStatesResponse { @ApiProperty() _id: string; @ApiProperty() count: number; @ApiProperty() templates: string[]; @ApiProperty({ enum: ChannelTypeEnum, isArray: true, }) channels: ChannelTypeEnum[]; } ================================================ FILE: apps/api/src/app/notifications/dtos/activity-stats-response.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; export class ActivityStatsResponseDto { @ApiProperty() weeklySent: number; @ApiProperty() monthlySent: number; } ================================================ FILE: apps/api/src/app/notifications/e2e/get-activity-feed.e2e.ts ================================================ import { Novu } from '@novu/api'; import { ActivityNotificationResponseDto, ChannelTypeEnum } from '@novu/api/models/components'; import { NotificationTemplateEntity, NotificationTemplateRepository, SubscriberRepository } from '@novu/dal'; import { CreateWorkflowDto, StepTypeEnum, WorkflowCreationSourceEnum, WorkflowResponseDto } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('Get activity feed - /notifications (GET) #novu-v2', async () => { let session: UserSession; let template: NotificationTemplateEntity; let smsOnlyTemplate: NotificationTemplateEntity; let subscriberId: string; let novuClient: Novu; beforeEach(async () => { session = new UserSession(); await session.initialize(); template = await session.createTemplate(); smsOnlyTemplate = await session.createChannelTemplate(StepTypeEnum.SMS); subscriberId = SubscriberRepository.createObjectId(); novuClient = initNovuClassSdk(session); await session.testAgent .post('/v1/widgets/session/initialize') .send({ applicationIdentifier: session.environment.identifier, subscriberId, firstName: 'Test', lastName: 'User', email: 'test@example.com', }) .expect(201); }); it('should get the current activity feed of user', async () => { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId, payload: { firstName: 'Test' }, }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId, payload: { firstName: 'Test' }, }); await session.waitForJobCompletion(template._id); const body = await novuClient.notifications.list({ page: 0 }); const activities = body.result; expect(activities.hasMore).to.equal(false); expect(activities.data.length, JSON.stringify(body.result)).to.equal(2); const activity = activities.data[0]; if (!activity || !activity.template || !activity.subscriber) { throw new Error(`must have activity${JSON.stringify(activity)}`); } expect(activity.template.name).to.equal(template.name); expect(activity.template.id).to.equal(template._id); expect(activity.subscriber.firstName).to.equal('Test'); expect(activity.channels).to.be.ok; expect(activity.channels).to.include.oneOf(Object.keys(ChannelTypeEnum).map((i) => ChannelTypeEnum[i])); }); it('should filter by channel', async () => { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId, payload: { firstName: 'Test' }, }); await novuClient.trigger({ workflowId: smsOnlyTemplate.triggers[0].identifier, to: subscriberId, payload: { firstName: 'Test', }, }); await novuClient.trigger({ workflowId: smsOnlyTemplate.triggers[0].identifier, to: subscriberId, payload: { firstName: 'Test', }, }); await session.waitForJobCompletion([template._id, smsOnlyTemplate._id]); await novuClient.notifications.list({ page: 0, transactionId: ChannelTypeEnum.Sms }); const body = await novuClient.notifications.list({ page: 0, channels: [ChannelTypeEnum.Sms] }); const activities = body.result; expect(activities.hasMore).to.equal(false); expect(activities.data.length).to.equal(2); const activity = activities.data[0]; if (!activity || !activity.template || !activity.subscriber) { throw new Error('must have activity'); } expect(activity.template?.name).to.equal(smsOnlyTemplate.name); expect(activity.channels).to.include(ChannelTypeEnum.Sms); }); it('should filter by templateId', async () => { await novuClient.trigger({ workflowId: smsOnlyTemplate.triggers[0].identifier, to: subscriberId, payload: { firstName: 'Test', }, }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId, payload: { firstName: 'Test' }, }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId, payload: { firstName: 'Test' }, }); await session.waitForJobCompletion(template._id); const body = await novuClient.notifications.list({ page: 0, templates: [template._id] }); const activities = body.result; expect(activities.hasMore).to.equal(false); expect(activities.data.length).to.equal(2); expect(getActivity(activities.data, 0).template?.id).to.equal(template._id); expect(getActivity(activities.data, 1).template?.id).to.equal(template._id); }); function getActivity( activities: Array, index: number ): ActivityNotificationResponseDto { const activity = activities[index]; if (!activity || !activity.template || !activity.subscriber) { throw new Error('must have activity'); } return activity; } it('should filter by email', async () => { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: { subscriberId: SubscriberRepository.createObjectId(), email: 'test@email.coms', }, payload: { firstName: 'Test', }, }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: { subscriberId: SubscriberRepository.createObjectId(), }, payload: { firstName: 'Test', }, }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: SubscriberRepository.createObjectId(), payload: { firstName: 'Test', }, }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: SubscriberRepository.createObjectId(), payload: { firstName: 'Test', }, }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId, payload: { firstName: 'Test', }, }); await session.waitForJobCompletion(template._id); const activities = (await novuClient.notifications.list({ page: 0, emails: ['test@email.coms'] })).result.data; expect(activities.length).to.equal(1); expect(getActivity(activities, 0).template?.id).to.equal(template._id); }); it('should filter by subscriberId', async () => { const subscriberIdToCreate = `${SubscriberRepository.createObjectId()}some-test`; await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: { subscriberId: subscriberIdToCreate, email: 'test@email.coms', }, payload: { firstName: 'Test', }, }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: SubscriberRepository.createObjectId(), payload: { firstName: 'Test', }, }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: SubscriberRepository.createObjectId(), payload: { firstName: 'Test', }, }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId, payload: { firstName: 'Test', }, }); await session.waitForJobCompletion(template._id); const { result } = await novuClient.notifications.list({ page: 0, subscriberIds: [subscriberIdToCreate] }); const activities = result.data; expect(activities.length).to.equal(1); expect(activities[0].template?.id, JSON.stringify(template)).to.equal(template._id); }); it('should return with deleted workflow and subscriber data', async () => { const notificationTemplateRepository = new NotificationTemplateRepository(); const subscriberRepository = new SubscriberRepository(); const templateToDelete = await session.createTemplate(); const subscriberIdToDelete = `${SubscriberRepository.createObjectId()}`; await novuClient.trigger({ workflowId: templateToDelete.triggers[0].identifier, to: subscriberIdToDelete, payload: { firstName: 'Test' }, }); await session.waitForJobCompletion(templateToDelete._id); await notificationTemplateRepository.delete({ _id: templateToDelete._id, _environmentId: session.environment._id }); const subscriberToDelete = await subscriberRepository.findOne({ subscriberId: subscriberIdToDelete, _environmentId: session.environment._id, }); await subscriberRepository.delete({ _id: subscriberToDelete?._id, _environmentId: session.environment._id }); const body = await novuClient.notifications.list({ page: 0 }); const activities = body.result; expect(activities.hasMore).to.equal(false); expect(activities.data.length, JSON.stringify(body.result)).to.equal(1); const activity = activities.data[0]; expect(activity.template).to.be.undefined; expect(activity.subscriber).to.be.undefined; expect(activity.channels).to.be.ok; expect(activity.channels).to.include.oneOf(Object.keys(ChannelTypeEnum).map((i) => ChannelTypeEnum[i])); }); it('should filter by contextKeys', async () => { const workflowBody: CreateWorkflowDto = { name: 'Test Context Workflow', workflowId: 'test-context-workflow-notifications', __source: WorkflowCreationSourceEnum.DASHBOARD, steps: [ { type: StepTypeEnum.IN_APP, name: 'Test Step', controlValues: { subject: 'Test Subject', body: 'Test Body', }, }, ], }; const workflowResponse = await session.testAgent.post('/v2/workflows').send(workflowBody); expect(workflowResponse.status).to.equal(201); const workflow: WorkflowResponseDto = workflowResponse.body.data; await novuClient.trigger({ workflowId: workflow.workflowId, to: subscriberId, payload: {}, context: { projectId: 'project-alpha' }, }); await novuClient.trigger({ workflowId: workflow.workflowId, to: subscriberId, payload: {}, context: { projectId: 'project-beta' }, }); await session.waitForWorkflowQueueCompletion(); await session.waitForSubscriberQueueCompletion(); await session.waitForStandardQueueCompletion(); await session.waitForJobCompletion(workflow._id); // Test 1: No contextKeys filter - should return all notifications let body = await novuClient.notifications.list({ page: 0 }); expect(body.result.data.length).to.be.equal(2); // Test 2: Filter by specific context - should return only matching notification body = await novuClient.notifications.list({ page: 0, contextKeys: ['projectId:project-alpha'] }); expect(body.result.data.length).to.be.equal(1); expect(body.result.data[0].template?.id).to.equal(workflow._id); expect(body.result.data[0].contextKeys).to.deep.equal(['projectId:project-alpha']); // Test 3: Filter by different context - should return only matching notification body = await novuClient.notifications.list({ page: 0, contextKeys: ['projectId:project-beta'] }); expect(body.result.data.length).to.be.equal(1); expect(body.result.data[0].template?.id).to.equal(workflow._id); expect(body.result.data[0].contextKeys).to.deep.equal(['projectId:project-beta']); }); }); ================================================ FILE: apps/api/src/app/notifications/e2e/get-activity.e2e.ts ================================================ import { Novu } from '@novu/api'; import { ActivityNotificationResponseDto } from '@novu/api/models/components'; import { MessageRepository, NotificationRepository, NotificationTemplateEntity, SubscriberRepository } from '@novu/dal'; import { JobStatusEnum, StepTypeEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('Get activity - /notifications/:notificationId (GET) #novu-v2', async () => { let session: UserSession; let template: NotificationTemplateEntity; let novuClient: Novu; let originalTraceReadValue: string | undefined; let originalTraceWriteValue: string | undefined; let originalStepRunEnvValue: string | undefined; const messageRepository: MessageRepository = new MessageRepository(); const notificationRepository: NotificationRepository = new NotificationRepository(); const updateNotification = async ({ id, status, body, }: { id: string; status: 'read' | 'unread' | 'archive' | 'unarchive' | 'snooze' | 'unsnooze'; body?: any; }) => { return await session.testAgent .patch(`/v1/inbox/notifications/${id}/${status}`) .set('Authorization', `Bearer ${session.subscriberToken}`) .send(body); }; before(async () => { originalTraceReadValue = process.env.IS_TRACE_LOGS_READ_ENABLED; originalTraceWriteValue = process.env.IS_TRACE_LOGS_ENABLED; (process.env as any).IS_TRACE_LOGS_READ_ENABLED = 'true'; (process.env as any).IS_TRACE_LOGS_ENABLED = 'true'; }); after(async () => { if (originalTraceReadValue === undefined) { delete (process.env as any).IS_TRACE_LOGS_READ_ENABLED; } else { (process.env as any).IS_TRACE_LOGS_READ_ENABLED = originalTraceReadValue; } if (originalTraceWriteValue === undefined) { delete (process.env as any).IS_TRACE_LOGS_ENABLED; } else { (process.env as any).IS_TRACE_LOGS_ENABLED = originalTraceWriteValue; } if (originalStepRunEnvValue === undefined) { delete (process.env as any).IS_STEP_RUN_LOGS_READ_ENABLED; } if (originalStepRunEnvValue !== undefined) { (process.env as any).IS_STEP_RUN_LOGS_READ_ENABLED = originalStepRunEnvValue; } }); beforeEach(async () => { session = new UserSession(); await session.initialize(); template = await session.createTemplate({ steps: [ { type: StepTypeEnum.IN_APP, content: 'Test notification content {{name}}', }, ], }); novuClient = initNovuClassSdk(session); }); it('should return traces in activity feed when traces feature flag is enabled', async () => { // Step 1: Trigger a notification to create trace logs const triggerResponse = await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: session.subscriberId, payload: { name: 'Test User' }, }); expect(triggerResponse.result?.acknowledged).to.equal(true); // Step 2: Wait for the worker to process the notification and create traces await session.waitForJobCompletion(template._id); const message = await messageRepository.findOne({ _environmentId: session.environment._id, _subscriberId: session.subscriberProfile?._id, _templateId: template._id, transactionId: triggerResponse.result?.transactionId, }); expect(message).to.be.ok; if (!message) throw new Error('Message not found'); const { body, status } = await updateNotification({ id: message._id, status: 'read', }); expect(status).to.equal(200); const notification = await notificationRepository.findOne({ _environmentId: session.environment._id, _subscriberId: session.subscriberProfile?._id, _templateId: template._id, transactionId: triggerResponse.result?.transactionId, }); expect(notification).to.be.ok; if (!notification) throw new Error('Notification not found'); const activityResponse = await session.testAgent.get(`/v1/notifications/${notification._id}`).expect(200); const activity: ActivityNotificationResponseDto = activityResponse.body.data; expect(activity).to.be.ok; if (!activity.jobs) throw new Error('Jobs not found'); expect(activity.jobs).to.be.an('array'); const actualDetails = activity.jobs[0].executionDetails.map((detail) => detail.detail); const expectedExecutionDetails = ['Step queued', 'Message created', 'Message sent', 'Message read']; expect(actualDetails.length).to.be.equal(4); expectedExecutionDetails.forEach((expectedDetail) => { expect(actualDetails).to.include(expectedDetail); }); }); it('should fallback to old method when traces query fails', async () => { const triggerResponse = await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: session.subscriberId, payload: { name: 'Test User' }, }); await session.waitForJobCompletion(template._id); const message = await messageRepository.findOne({ _environmentId: session.environment._id, _subscriberId: session.subscriberProfile?._id, _templateId: template._id, transactionId: triggerResponse.result?.transactionId, }); expect(message).to.be.ok; if (!message) throw new Error('Message not found'); const notification = await notificationRepository.findOne({ _environmentId: session.environment._id, _subscriberId: session.subscriberProfile?._id, _templateId: template._id, transactionId: triggerResponse.result?.transactionId, }); expect(notification).to.be.ok; if (!notification) throw new Error('Notification not found'); const activityResponse = await session.testAgent.get(`/v1/notifications/${notification._id}`).expect(200); const activity: ActivityNotificationResponseDto = activityResponse.body.data; expect(activity).to.be.ok; if (!activity.jobs) throw new Error('Jobs not found'); expect(activity.jobs).to.be.an('array'); const actualDetails = activity.jobs[0].executionDetails.map((detail) => detail.detail); const expectedExecutionDetails = ['Step queued', 'Message created', 'Message sent']; expect(actualDetails.length).to.be.equal(3); expectedExecutionDetails.forEach((expectedDetail) => { expect(actualDetails).to.include( expectedDetail, `Expected execution detail '${expectedDetail}' not found in job. Found: ${actualDetails.join(', ')}` ); }); expect(actualDetails).to.not.include('Message read'); }); it('should return traces in activity feed with step runs and trace logs', async () => { // Step 1: Trigger a notification to create trace logs const triggerResponse = await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: session.subscriberId, payload: { name: 'Test User' }, }); expect(triggerResponse.result?.acknowledged).to.equal(true); // Step 2: Wait for the worker to process the notification and create traces await session.waitForJobCompletion(template._id); const message = await messageRepository.findOne({ _environmentId: session.environment._id, _subscriberId: session.subscriberProfile?._id, _templateId: template._id, transactionId: triggerResponse.result?.transactionId, }); expect(message).to.be.ok; if (!message) throw new Error('Message not found'); const { body, status } = await updateNotification({ id: message._id, status: 'read', }); expect(status).to.equal(200); const notification = await notificationRepository.findOne({ _environmentId: session.environment._id, _subscriberId: session.subscriberProfile?._id, _templateId: template._id, transactionId: triggerResponse.result?.transactionId, }); expect(notification).to.be.ok; if (!notification) throw new Error('Notification not found'); const activityResponse = await session.testAgent.get(`/v1/notifications/${notification._id}`).expect(200); const activity: ActivityNotificationResponseDto = activityResponse.body.data; expect(activity).to.be.ok; if (!activity.jobs) throw new Error('Jobs not found'); expect(activity.jobs).to.be.an('array'); const actualDetails = activity.jobs[0].executionDetails.map((detail) => detail.detail); const expectedExecutionDetails = ['Step queued', 'Message created', 'Message sent', 'Message read']; expect(actualDetails.length).to.be.equal(4); expectedExecutionDetails.forEach((expectedDetail) => { expect(actualDetails).to.include( expectedDetail, `Expected execution detail '${expectedDetail}' not found in job. Found: ${actualDetails.join(', ')}` ); }); }); it('should use step runs when both trace and step run feature flags are enabled', async () => { // Enable both feature flags (process.env as any).IS_STEP_RUN_LOGS_READ_ENABLED = 'true'; const triggerResponse = await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: session.subscriberId, payload: { name: 'Test User' }, }); expect(triggerResponse.result?.acknowledged).to.equal(true); await session.waitForJobCompletion(template._id); const notification = await notificationRepository.findOne({ _environmentId: session.environment._id, _subscriberId: session.subscriberProfile?._id, _templateId: template._id, transactionId: triggerResponse.result?.transactionId, }); expect(notification).to.be.ok; if (!notification) throw new Error('Notification not found'); const activityResponse = await session.testAgent.get(`/v1/notifications/${notification._id}`).expect(200); const activity: ActivityNotificationResponseDto = activityResponse.body.data; expect(activity).to.be.ok; expect(activity.jobs?.length).to.be.equal(2); expect(activity.jobs?.[0].type).to.be.equal(StepTypeEnum.TRIGGER); expect(activity.jobs?.[0].status).to.be.equal(JobStatusEnum.COMPLETED); expect(activity.jobs?.[1].type).to.be.equal(StepTypeEnum.IN_APP); expect(activity.jobs?.[1].status).to.be.equal(JobStatusEnum.COMPLETED); // Reset feature flag delete (process.env as any).IS_STEP_RUN_LOGS_READ_ENABLED; }); it('should fallback to trace log method when step runs are not found', async () => { /* * Enable both feature flags * (process.env as any).IS_STEP_RUN_LOGS_READ_ENABLED = 'true'; */ const triggerResponse = await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: session.subscriberId, payload: { name: 'Test User' }, }); expect(triggerResponse.result?.acknowledged).to.equal(true); await session.waitForJobCompletion(template._id); const message = await messageRepository.findOne({ _environmentId: session.environment._id, _subscriberId: session.subscriberProfile?._id, _templateId: template._id, transactionId: triggerResponse.result?.transactionId, }); expect(message).to.be.ok; if (!message) throw new Error('Message not found'); const notification = await notificationRepository.findOne({ _environmentId: session.environment._id, _subscriberId: session.subscriberProfile?._id, _templateId: template._id, transactionId: triggerResponse.result?.transactionId, }); expect(notification).to.be.ok; if (!notification) throw new Error('Notification not found'); const activityResponse = await session.testAgent.get(`/v1/notifications/${notification._id}`).expect(200); const activity: ActivityNotificationResponseDto = activityResponse.body.data; expect(activity).to.be.ok; // Should still return jobs (even if from step_runs) expect(activity.jobs?.length).to.be.equal(1); expect(activity.jobs?.[0].type).to.be.equal(StepTypeEnum.IN_APP); expect(activity.jobs?.[0].status).to.be.equal(JobStatusEnum.COMPLETED); }); it('should fallback to old method when traces query fails', async () => { const triggerResponse = await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: session.subscriberId, payload: { name: 'Test User' }, }); await session.waitForJobCompletion(template._id); const message = await messageRepository.findOne({ _environmentId: session.environment._id, _subscriberId: session.subscriberProfile?._id, _templateId: template._id, transactionId: triggerResponse.result?.transactionId, }); expect(message).to.be.ok; if (!message) throw new Error('Message not found'); const notification = await notificationRepository.findOne({ _environmentId: session.environment._id, _subscriberId: session.subscriberProfile?._id, _templateId: template._id, transactionId: triggerResponse.result?.transactionId, }); expect(notification).to.be.ok; if (!notification) throw new Error('Notification not found'); const activityResponse = await session.testAgent.get(`/v1/notifications/${notification._id}`).expect(200); const activity: ActivityNotificationResponseDto = activityResponse.body.data; expect(activity).to.be.ok; if (!activity.jobs) throw new Error('Jobs not found'); expect(activity.jobs).to.be.an('array'); const actualDetails = activity.jobs[0].executionDetails.map((detail) => detail.detail); const expectedExecutionDetails = ['Step queued', 'Message created', 'Message sent']; expect(actualDetails.length).to.be.equal(3); expectedExecutionDetails.forEach((expectedDetail) => { expect(actualDetails).to.include( expectedDetail, `Expected execution detail '${expectedDetail}' not found in job. Found: ${actualDetails.join(', ')}` ); }); expect(actualDetails).to.not.include('Message read'); }); }); ================================================ FILE: apps/api/src/app/notifications/notification.controller.ts ================================================ import { Controller, Get, Param, Query } from '@nestjs/common'; import { ApiExcludeEndpoint, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; import { RequirePermissions } from '@novu/application-generic'; import { ChannelTypeEnum, PermissionsEnum, SeverityLevelEnum, UserSessionData } from '@novu/shared'; import { RequireAuthentication } from '../auth/framework/auth.decorator'; import { ExternalApiAccessible } from '../auth/framework/external-api.decorator'; import { ApiCommonResponses, ApiOkResponse, ApiResponse } from '../shared/framework/response.decorator'; import { SdkGroupName, SdkMethodName } from '../shared/framework/swagger/sdk.decorators'; import { UserSession } from '../shared/framework/user.decorator'; import { ActivitiesRequestDto } from './dtos/activities-request.dto'; import { ActivitiesResponseDto, ActivityNotificationResponseDto } from './dtos/activities-response.dto'; import { ActivityGraphStatesResponse } from './dtos/activity-graph-states-response.dto'; import { ActivityStatsResponseDto } from './dtos/activity-stats-response.dto'; import { GetActivityCommand } from './usecases/get-activity/get-activity.command'; import { GetActivity } from './usecases/get-activity/get-activity.usecase'; import { GetActivityFeedCommand } from './usecases/get-activity-feed/get-activity-feed.command'; import { GetActivityFeed } from './usecases/get-activity-feed/get-activity-feed.usecase'; import { GetActivityGraphStatsCommand } from './usecases/get-activity-graph-states/get-activity-graph-states.command'; import { GetActivityGraphStats } from './usecases/get-activity-graph-states/get-activity-graph-states.usecase'; import { GetActivityStats, GetActivityStatsCommand } from './usecases/get-activity-stats'; @ApiCommonResponses() @RequireAuthentication() @Controller('/notifications') @ApiTags('Notifications') export class NotificationsController { constructor( private getActivityFeedUsecase: GetActivityFeed, private getActivityStatsUsecase: GetActivityStats, private getActivityGraphStatsUsecase: GetActivityGraphStats, private getActivityUsecase: GetActivity ) {} @Get('') @ApiOkResponse({ type: ActivitiesResponseDto, }) @ApiOperation({ summary: 'List all events', description: `List all notification events (triggered events) for the current environment. This API supports filtering by **channels**, **templates**, **emails**, **subscriberIds**, **transactionId**, **topicKey**, **severity**, **contextKeys**. Checkout all available filters in the query section. This API returns event triggers, to list each channel notifications, check messages APIs.`, }) @ExternalApiAccessible() @RequirePermissions(PermissionsEnum.NOTIFICATION_READ) async listNotifications( @UserSession() user: UserSessionData, @Query() query: ActivitiesRequestDto ): Promise { let channelsQuery: ChannelTypeEnum[] | null = null; if (query.channels) { channelsQuery = Array.isArray(query.channels) ? query.channels : [query.channels]; } let templatesQuery: string[] | null = null; if (query.templates) { templatesQuery = Array.isArray(query.templates) ? query.templates : [query.templates]; } let emailsQuery: string[] = []; if (query.emails) { emailsQuery = Array.isArray(query.emails) ? query.emails : [query.emails]; } let subscribersQuery: string[] = []; if (query.subscriberIds) { subscribersQuery = Array.isArray(query.subscriberIds) ? query.subscriberIds : [query.subscriberIds]; } let transactionIdQuery: string[] | undefined; if (query.transactionId) { transactionIdQuery = Array.isArray(query.transactionId) ? query.transactionId : [query.transactionId]; } let severityQuery: SeverityLevelEnum[] | null = null; if (query.severity) { severityQuery = Array.isArray(query.severity) ? query.severity : [query.severity]; } return this.getActivityFeedUsecase.execute( GetActivityFeedCommand.create({ page: query.page, limit: query.limit, organizationId: user.organizationId, environmentId: user.environmentId, userId: user._id, channels: channelsQuery, templates: templatesQuery, emails: emailsQuery, search: query.search, subscriberIds: subscribersQuery, transactionId: transactionIdQuery, topicKey: query.topicKey, subscriptionId: query.subscriptionId, severity: severityQuery, after: query.after, before: query.before, contextKeys: query.contextKeys, }) ); } @ApiResponse(ActivityStatsResponseDto) @ApiExcludeEndpoint() @ApiOperation({ summary: 'Retrieve events statistics', description: `Retrieve notification statistics for the current environment. This API returns the number of weekly and monthly notifications sent for the current environment.`, deprecated: true, }) @Get('/stats') @ExternalApiAccessible() @SdkGroupName('Notifications.Stats') @RequirePermissions(PermissionsEnum.NOTIFICATION_READ) getActivityStats(@UserSession() user: UserSessionData): Promise { return this.getActivityStatsUsecase.execute( GetActivityStatsCommand.create({ organizationId: user.organizationId, environmentId: user.environmentId, }) ); } @Get('/graph/stats') @ExternalApiAccessible() @ApiExcludeEndpoint() @ApiResponse(ActivityGraphStatesResponse, 200, true) @ApiOperation({ summary: 'Retrieve events graph statistics', description: `Retrieve events graph statistics for the current environment. This API returns the number of events sent. This data is used to generate the graph in the legacy dashboard.`, deprecated: true, }) @ApiQuery({ name: 'days', type: Number, required: false, }) @SdkGroupName('Notifications.Stats') @SdkMethodName('graph') @RequirePermissions(PermissionsEnum.NOTIFICATION_READ) getActivityGraphStats( @UserSession() user: UserSessionData, @Query('days') days = 32 ): Promise { return this.getActivityGraphStatsUsecase.execute( GetActivityGraphStatsCommand.create({ days: days ? Number(days) : 32, organizationId: user.organizationId, environmentId: user.environmentId, userId: user._id, }) ); } @Get('/:notificationId') @ApiResponse(ActivityNotificationResponseDto) @ApiOperation({ summary: 'Retrieve an event', description: `Retrieve an event by its unique key identifier **notificationId**. Here **notificationId** is of mongodbId type. This API returns the event details - execution logs, status, actual notification (message) generated by each workflow step.`, }) @ExternalApiAccessible() @RequirePermissions(PermissionsEnum.NOTIFICATION_READ) getNotification( @UserSession() user: UserSessionData, @Param('notificationId') notificationId: string ): Promise { return this.getActivityUsecase.execute( GetActivityCommand.create({ notificationId, organizationId: user.organizationId, environmentId: user.environmentId, userId: user._id, }) ); } } ================================================ FILE: apps/api/src/app/notifications/notification.module.ts ================================================ import { Module } from '@nestjs/common'; import { CommunityOrganizationRepository } from '@novu/dal'; import { AuthModule } from '../auth/auth.module'; import { SharedModule } from '../shared/shared.module'; import { NotificationsController } from './notification.controller'; import { USE_CASES } from './usecases'; @Module({ imports: [SharedModule, AuthModule], providers: [...USE_CASES, CommunityOrganizationRepository], controllers: [NotificationsController], }) export class NotificationModule {} ================================================ FILE: apps/api/src/app/notifications/usecases/get-activity/get-activity.command.ts ================================================ import { IsDefined, IsMongoId } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; export class GetActivityCommand extends EnvironmentWithUserCommand { @IsDefined() @IsMongoId() notificationId: string; } ================================================ FILE: apps/api/src/app/notifications/usecases/get-activity/get-activity.usecase.ts ================================================ import { Injectable, NotFoundException } from '@nestjs/common'; import { AnalyticsService, FeatureFlagsService, PinoLogger, QueryBuilder, StepRun, StepRunRepository, Trace, TraceLogRepository, WorkflowRun, WorkflowRunRepository, } from '@novu/application-generic'; import { ExecutionDetailFeedItem, JobFeedItem, JobStatusEnum, NotificationFeedItemEntity, NotificationRepository, NotificationStepEntity, } from '@novu/dal'; import { ExecutionDetailsSourceEnum, ExecutionDetailsStatusEnum, FeatureFlagsKeysEnum, ProvidersIdEnum, StepTypeEnum, TriggerTypeEnum, } from '@novu/shared'; import { ActivityNotificationResponseDto } from '../../dtos/activities-response.dto'; import { mapFeedItemToDto } from '../get-activity-feed/map-feed-item-to.dto'; import { GetActivityCommand } from './get-activity.command'; const workflowRunSelectColumns = [ 'workflow_run_id', 'workflow_id', 'workflow_name', 'organization_id', 'environment_id', 'subscriber_id', 'external_subscriber_id', 'trigger_identifier', 'transaction_id', 'channels', 'subscriber_to', 'payload', 'topics', 'context_keys', 'created_at', 'updated_at', ] as const; const stepRunSelectColumns = [ 'step_run_id', 'step_id', 'step_type', 'provider_id', 'status', 'created_at', 'updated_at', 'schedule_extensions_count', ] as const; type StepRunFetchResult = Pick; const traceSelectColumns = ['id', 'entity_id', 'title', 'status', 'created_at', 'raw_data'] as const; @Injectable() export class GetActivity { constructor( private notificationRepository: NotificationRepository, private analyticsService: AnalyticsService, private traceLogRepository: TraceLogRepository, private stepRunRepository: StepRunRepository, private workflowRunRepository: WorkflowRunRepository, private logger: PinoLogger, private featureFlagsService: FeatureFlagsService ) {} async execute(command: GetActivityCommand): Promise { this.analyticsService.track('Get Activity Feed Item - [Activity Feed]', command.userId, { _organization: command.organizationId, }); const flagContext = { organization: { _id: command.organizationId }, user: { _id: command.userId }, environment: { _id: command.environmentId }, } as const; const [tracesEnabled, stepRunsEnabled, workflowRunsEnabled] = await Promise.all([ this.featureFlagsService.getFlag({ key: FeatureFlagsKeysEnum.IS_TRACE_LOGS_READ_ENABLED, defaultValue: false, ...flagContext, }), this.featureFlagsService.getFlag({ key: FeatureFlagsKeysEnum.IS_STEP_RUN_LOGS_READ_ENABLED, defaultValue: false, ...flagContext, }), this.featureFlagsService.getFlag({ key: FeatureFlagsKeysEnum.IS_WORKFLOW_RUN_LOGS_READ_ENABLED, defaultValue: false, ...flagContext, }), ]); this.logger.debug({ tracesEnabled, stepRunsEnabled, workflowRunsEnabled, }, 'feature flags'); let feedItem: NotificationFeedItemEntity | null = null; if (workflowRunsEnabled && stepRunsEnabled && tracesEnabled) { this.logger.debug('analytics full ingegration enabled'); feedItem = await this.getFeedItemFromWorkflowRuns(command); } else if (tracesEnabled && stepRunsEnabled) { this.logger.debug('analytics step runs enabled, no workflow runs'); feedItem = await this.getFeedItemFromStepRuns(command); } else if (tracesEnabled) { this.logger.debug('analytics traces enabled, no step runs or workflow runs'); feedItem = await this.getFeedItemFromTraceLog(command); } else { this.logger.debug('analytics fallback to old method'); feedItem = await this.notificationRepository.getFeedItem( command.notificationId, command.environmentId, command.organizationId ); } if (!feedItem) { throw new NotFoundException('Notification not found', { cause: `Notification with id ${command.notificationId} not found`, }); } return mapFeedItemToDto(feedItem); } private mapTraceStatusToExecutionStatus(traceStatus: string): ExecutionDetailsStatusEnum { switch (traceStatus.toLowerCase()) { case 'success': return ExecutionDetailsStatusEnum.SUCCESS; case 'error': case 'failed': return ExecutionDetailsStatusEnum.FAILED; case 'warning': return ExecutionDetailsStatusEnum.WARNING; case 'pending': return ExecutionDetailsStatusEnum.PENDING; case 'queued': return ExecutionDetailsStatusEnum.QUEUED; default: return ExecutionDetailsStatusEnum.PENDING; } } private async getExecutionDetailsByEntityId( entityIds: string[], command: GetActivityCommand ): Promise> { if (entityIds.length === 0) { return new Map(); } const traceQuery = new QueryBuilder({ environmentId: command.environmentId, }) .whereIn('entity_id', entityIds) .whereEquals('entity_type', 'step_run') .build(); const traceResult = await this.traceLogRepository.find({ where: traceQuery, orderBy: 'created_at', orderDirection: 'ASC', select: traceSelectColumns, }); const executionDetailsByEntityId = new Map(); // Group traces by entity ID const traceLogsByEntityId = new Map(); for (const trace of traceResult.data) { if (!traceLogsByEntityId.has(trace.entity_id)) { traceLogsByEntityId.set(trace.entity_id, []); } // biome-ignore lint/style/noNonNullAssertion: we we create it in the if above traceLogsByEntityId.get(trace.entity_id)!.push(trace); } // Convert traces to execution details for each entity for (const [entityId, traces] of traceLogsByEntityId) { const executionDetails: ExecutionDetailFeedItem[] = traces.map((trace) => ({ _id: trace.id, // TODO: add providerId from traces providerId: undefined, // Will be overridden by step runs if available detail: trace.title, source: ExecutionDetailsSourceEnum.INTERNAL, _jobId: entityId, status: this.mapTraceStatusToExecutionStatus(trace.status), isTest: false, isRetry: false, createdAt: new Date(trace.created_at).toISOString(), raw: trace.raw_data, })); executionDetailsByEntityId.set(entityId, executionDetails); } return executionDetailsByEntityId; } private async processStepRunsForFeedItem( feedItem: NotificationFeedItemEntity, command: GetActivityCommand ): Promise { const stepRunsQuery = new QueryBuilder({ environmentId: command.environmentId, }) .whereEquals('transaction_id', feedItem.transactionId) .build(); const stepRunsResult = await this.stepRunRepository.find({ where: stepRunsQuery, orderBy: 'created_at', orderDirection: 'ASC', useFinal: true, select: stepRunSelectColumns, }); if (!stepRunsResult.data || stepRunsResult.data.length === 0) { return []; } const stepRunIds = stepRunsResult.data.map((stepRun) => stepRun.step_run_id); const executionDetailsByStepRunId = await this.getExecutionDetailsByEntityId(stepRunIds, command); return stepRunsResult.data.map((stepRun) => mapStepRunToJob(stepRun, executionDetailsByStepRunId)); } private async getFeedItemFromStepRuns(command: GetActivityCommand): Promise { try { const feedItem = await this.notificationRepository.findNotificationMetadataOnly( command.notificationId, command.environmentId, command.organizationId ); if (!feedItem) { return null; } // Process step runs and add them to the feed item feedItem.jobs = await this.processStepRunsForFeedItem(feedItem, command); return feedItem; } catch (error) { this.logger.error( { error: error instanceof Error ? error.message : 'Unknown error', notificationId: command.notificationId, environmentId: command.environmentId, organizationId: command.organizationId, }, 'Failed to get feed item from step runs' ); // Fall back to the current stage 1 method (traces + jobs from MongoDB) return await this.getFeedItemFromTraceLog(command); } } private async getFeedItemFromWorkflowRuns(command: GetActivityCommand): Promise { try { const workflowRunQuery = new QueryBuilder({ environmentId: command.environmentId, }) .whereEquals('workflow_run_id', command.notificationId) .build(); const workflowRunsResult = await this.workflowRunRepository.find({ where: workflowRunQuery, orderBy: 'created_at', orderDirection: 'ASC', limit: 1, useFinal: true, select: workflowRunSelectColumns, }); if (!workflowRunsResult.data || workflowRunsResult.data.length === 0) { this.logger.warn( { notificationId: command.notificationId, environmentId: command.environmentId, organizationId: command.organizationId, }, 'No workflow run found in ClickHouse, falling back to step runs' ); // Fall back to step runs method return await this.getFeedItemFromStepRuns(command); } const mostRecentWorkflowRun = workflowRunsResult.data[0]; // Create the base feed item from workflow run data const feedItem: NotificationFeedItemEntity = { _id: mostRecentWorkflowRun.workflow_run_id, _organizationId: mostRecentWorkflowRun.organization_id, _environmentId: mostRecentWorkflowRun.environment_id, _templateId: mostRecentWorkflowRun.workflow_id, _subscriberId: mostRecentWorkflowRun.subscriber_id, transactionId: mostRecentWorkflowRun.transaction_id, template: { _id: mostRecentWorkflowRun.workflow_id, name: mostRecentWorkflowRun.workflow_name, triggers: [ { identifier: mostRecentWorkflowRun.trigger_identifier, type: TriggerTypeEnum.EVENT, variables: [], }, ], }, subscriber: { _id: mostRecentWorkflowRun.subscriber_id, subscriberId: mostRecentWorkflowRun.external_subscriber_id || '', firstName: '', lastName: '', email: '', phone: undefined, }, jobs: [], to: mostRecentWorkflowRun.subscriber_to ? JSON.parse(mostRecentWorkflowRun.subscriber_to) : {}, payload: mostRecentWorkflowRun.payload ? JSON.parse(mostRecentWorkflowRun.payload) : {}, contextKeys: mostRecentWorkflowRun.context_keys, createdAt: new Date(mostRecentWorkflowRun.created_at).toISOString(), updatedAt: new Date(mostRecentWorkflowRun.updated_at).toISOString(), channels: mostRecentWorkflowRun.channels ? JSON.parse(mostRecentWorkflowRun.channels) : [], topics: mostRecentWorkflowRun.topics ? JSON.parse(mostRecentWorkflowRun.topics) : [], }; feedItem.jobs = await this.processStepRunsForFeedItem(feedItem, command); return feedItem; } catch (error) { this.logger.error( { error: error instanceof Error ? error.message : 'Unknown error', notificationId: command.notificationId, environmentId: command.environmentId, organizationId: command.organizationId, }, 'Failed to get feed item from workflow runs' ); // Fall back to step runs method return await this.getFeedItemFromStepRuns(command); } } private async getFeedItemFromTraceLog(command: GetActivityCommand) { try { const feedItem = await this.notificationRepository.findMetadataForTraces( command.notificationId, command.environmentId, command.organizationId ); if (!feedItem) { return null; } const jobIds = feedItem.jobs.map((job) => job._id); if (jobIds.length === 0) { return feedItem; } const executionDetailsByJobId = await this.getExecutionDetailsByEntityId(jobIds, command); feedItem.jobs = feedItem.jobs.map((job) => { const executionDetails = executionDetailsByJobId.get(job._id) || []; return { ...job, executionDetails, }; }); return feedItem; } catch (error) { this.logger.error( { error: error instanceof Error ? error.message : 'Unknown error', notificationId: command.notificationId, environmentId: command.environmentId, organizationId: command.organizationId, }, 'Failed to get feed item from trace log' ); // Fall back to the old method if trace log query fails return await this.notificationRepository.getFeedItem( command.notificationId, command.environmentId, command.organizationId ); } } } function mapStepRunToJob( stepRun: StepRunFetchResult, executionDetailsByStepRunId: Map ): JobFeedItem { const baseExecutionDetails = executionDetailsByStepRunId.get(stepRun.step_run_id) || []; // Create execution details with provider ID from step run data const executionDetails: ExecutionDetailFeedItem[] = baseExecutionDetails.map((detail) => ({ ...detail, providerId: stepRun.provider_id as ProvidersIdEnum, })); const stepRunDto: NotificationStepEntity = { _id: stepRun.step_id, _templateId: stepRun.step_id, active: true, filters: [], }; const jobDto: JobFeedItem = { _id: stepRun.step_run_id, status: stepRun.status as JobStatusEnum, overrides: {}, // Step runs don't have overrides, use empty object payload: {}, // Step runs don't have payload, use empty object step: stepRunDto, type: stepRun.step_type as StepTypeEnum, providerId: stepRun.provider_id as ProvidersIdEnum, createdAt: new Date(stepRun.created_at).toISOString(), updatedAt: new Date(stepRun.updated_at).toISOString(), digest: undefined, // Step runs don't have digest info executionDetails, scheduleExtensionsCount: stepRun.schedule_extensions_count, }; return jobDto; } ================================================ FILE: apps/api/src/app/notifications/usecases/get-activity-feed/get-activity-feed.command.ts ================================================ import { ChannelTypeEnum, SeverityLevelEnum } from '@novu/shared'; import { IsArray, IsEnum, IsMongoId, IsNumber, IsOptional, IsString } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; export class GetActivityFeedCommand extends EnvironmentWithUserCommand { @IsNumber() page: number; @IsNumber() limit: number; @IsOptional() @IsEnum(ChannelTypeEnum, { each: true, }) channels?: ChannelTypeEnum[] | null; @IsOptional() @IsArray() @IsMongoId({ each: true }) templates?: string[] | null; @IsOptional() @IsArray() emails?: string[]; @IsOptional() @IsString() search?: string; @IsOptional() @IsArray() subscriberIds?: string[]; @IsOptional() @IsArray() @IsString({ each: true }) transactionId?: string[]; @IsOptional() @IsString() topicKey?: string; @IsOptional() @IsString() subscriptionId?: string; @IsOptional() @IsArray() @IsString({ each: true }) contextKeys?: string[]; @IsOptional() @IsArray() @IsEnum(SeverityLevelEnum, { each: true }) severity?: SeverityLevelEnum[] | null; @IsOptional() @IsString() after?: string; @IsOptional() @IsString() before?: string; } ================================================ FILE: apps/api/src/app/notifications/usecases/get-activity-feed/get-activity-feed.usecase.spec.ts ================================================ import { HttpException, HttpStatus } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { FeatureFlagsService, PinoLogger, TraceLogRepository } from '@novu/application-generic'; import { CommunityOrganizationRepository, NotificationRepository, SubscriberRepository } from '@novu/dal'; import { ApiServiceLevelEnum } from '@novu/shared'; import { expect } from 'chai'; import sinon from 'sinon'; import { GetActivityFeed } from './get-activity-feed.usecase'; describe('GetActivityFeed - validateRetentionLimitForTier', () => { let useCase: GetActivityFeed; let organizationRepository: CommunityOrganizationRepository; let sandbox: sinon.SinonSandbox; beforeEach(async () => { sandbox = sinon.createSandbox(); const moduleRef = await Test.createTestingModule({ providers: [ GetActivityFeed, SubscriberRepository, NotificationRepository, { provide: CommunityOrganizationRepository, useValue: { findById: () => {}, }, }, { provide: TraceLogRepository, useValue: { createStepRun: () => {}, }, }, { provide: FeatureFlagsService, useValue: { getFlag: () => Promise.resolve({ value: false }), }, }, { provide: PinoLogger, useValue: { info: () => {}, error: () => {}, warn: () => {}, debug: () => {}, trace: () => {}, setContext: () => {}, }, }, ], }).compile(); useCase = moduleRef.get(GetActivityFeed); organizationRepository = moduleRef.get(CommunityOrganizationRepository); }); afterEach(() => { sandbox.restore(); }); describe('Date handling', () => { it('should default to maximum allowed retention period when no dates provided', async () => { const now = new Date(); sandbox.useFakeTimers(now.getTime()); const mockOrg = { _id: 'org-123', apiServiceLevel: ApiServiceLevelEnum.PRO, createdAt: new Date('2024-01-01'), }; sandbox.stub(organizationRepository, 'findById').resolves(mockOrg as any); const result = await (useCase as any).validateRetentionLimitForTier('org-123'); const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); expect(new Date(result.after).getTime()).to.be.approximately(sevenDaysAgo.getTime(), 1000); // allowing 1s difference expect(result.before).to.equal(now.toISOString()); }); it('should use provided dates when within retention period', async () => { const now = new Date(); const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000); const mockOrg = { _id: 'org-123', apiServiceLevel: ApiServiceLevelEnum.FREE, createdAt: new Date('2024-01-01'), }; sandbox.stub(organizationRepository, 'findById').resolves(mockOrg as any); const result = await (useCase as any).validateRetentionLimitForTier( 'org-123', twoDaysAgo.toISOString(), now.toISOString() ); expect(result.after).to.equal(twoDaysAgo.toISOString()); expect(result.before).to.equal(now.toISOString()); }); it('should reject when after date is later than before date', async () => { const now = new Date(); const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000); const mockOrg = { _id: 'org-123', apiServiceLevel: ApiServiceLevelEnum.FREE, createdAt: new Date('2024-01-01'), }; sandbox.stub(organizationRepository, 'findById').resolves(mockOrg as any); try { await (useCase as any).validateRetentionLimitForTier('org-123', tomorrow.toISOString(), now.toISOString()); expect.fail('Should have thrown an error'); } catch (error) { expect(error).to.be.instanceOf(HttpException); expect(error.message).to.match(/Invalid date range/); expect(error.status).to.equal(HttpStatus.BAD_REQUEST); } }); }); describe('Retention periods by tier', () => { const testCases = [ { tier: 'Legacy Free', apiServiceLevel: ApiServiceLevelEnum.FREE, createdAt: new Date('2024-01-01'), allowedDays: 30, rejectedDays: 31, }, { tier: 'New Free', apiServiceLevel: ApiServiceLevelEnum.FREE, createdAt: new Date('2025-03-01'), allowedDays: 1, rejectedDays: 2, }, { tier: 'Pro', apiServiceLevel: ApiServiceLevelEnum.PRO, createdAt: new Date(), allowedDays: 7, rejectedDays: 8, }, { tier: 'Team', apiServiceLevel: ApiServiceLevelEnum.BUSINESS, createdAt: new Date(), allowedDays: 90, rejectedDays: 91, }, ]; testCases.forEach(({ tier, apiServiceLevel, createdAt, allowedDays, rejectedDays }) => { describe(tier, () => { it(`should allow access within ${allowedDays} days`, async () => { const now = new Date(); const withinPeriod = new Date(now.getTime() - allowedDays * 24 * 60 * 60 * 1000); const mockOrg = { _id: 'org-123', apiServiceLevel, createdAt, }; sandbox.stub(organizationRepository, 'findById').resolves(mockOrg as any); const result = await (useCase as any).validateRetentionLimitForTier( 'org-123', withinPeriod.toISOString(), now.toISOString() ); expect(result.after).to.equal(withinPeriod.toISOString()); expect(result.before).to.equal(now.toISOString()); }); it(`should reject access beyond ${rejectedDays} days`, async () => { const now = new Date(); const beyondPeriod = new Date(now.getTime() - rejectedDays * 24 * 60 * 60 * 1000); const mockOrg = { _id: 'org-123', apiServiceLevel, createdAt, }; sandbox.stub(organizationRepository, 'findById').resolves(mockOrg as any); try { await (useCase as any).validateRetentionLimitForTier( 'org-123', beyondPeriod.toISOString(), now.toISOString() ); expect.fail('Should have thrown an error'); } catch (error) { expect(error).to.be.instanceOf(HttpException); console.log(error.message); expect(error.message).to.match(/retention period/); expect(error.status).to.equal(HttpStatus.PAYMENT_REQUIRED); } }); }); }); }); }); ================================================ FILE: apps/api/src/app/notifications/usecases/get-activity-feed/get-activity-feed.usecase.ts ================================================ import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { FeatureFlagsService, Instrument, PinoLogger, QueryBuilder, Trace, TraceLogRepository, } from '@novu/application-generic'; import { CommunityOrganizationRepository, ExecutionDetailFeedItem, NotificationFeedItemEntity, NotificationRepository, OrganizationEntity, SubscriberRepository, } from '@novu/dal'; import { ApiServiceLevelEnum, ExecutionDetailsSourceEnum, ExecutionDetailsStatusEnum, FeatureFlagsKeysEnum, FeatureNameEnum, getFeatureForTierAsNumber, } from '@novu/shared'; import { ActivitiesResponseDto, ActivityNotificationResponseDto } from '../../dtos/activities-response.dto'; import { GetActivityFeedCommand } from './get-activity-feed.command'; import { mapFeedItemToDto } from './map-feed-item-to.dto'; const traceFindColumns = ['entity_id', 'id', 'status', 'title', 'raw_data', 'created_at'] as const; type TraceFindResult = Pick; @Injectable() export class GetActivityFeed { constructor( private subscribersRepository: SubscriberRepository, private notificationRepository: NotificationRepository, private organizationRepository: CommunityOrganizationRepository, private traceLogRepository: TraceLogRepository, private featureFlagsService: FeatureFlagsService, private logger: PinoLogger ) {} async execute(command: GetActivityFeedCommand): Promise { let subscriberIds: string[] | undefined; const { after, before } = await this.validateRetentionLimitForTier( command.organizationId, command.after, command.before ); command.after = after; command.before = before; if (command.search || command.emails?.length || command.subscriberIds?.length) { subscriberIds = await this.findSubscribers(command); } if (subscriberIds && subscriberIds.length === 0) { return { page: 0, hasMore: false, pageSize: command.limit, data: [], }; } const notifications: NotificationFeedItemEntity[] = await this.getFeedNotifications(command, subscriberIds); const data = notifications.reduce((memo, notification) => { // TODO: Identify why mongo returns an array of undefined or null values. Is it a data issue? if (notification) { memo.push(mapFeedItemToDto(notification)); } return memo; }, []); return { page: command.page, hasMore: notifications?.length === command.limit, pageSize: command.limit, data, }; } private async validateRetentionLimitForTier(organizationId: string, after?: string, before?: string) { const organization = await this.organizationRepository.findById(organizationId); if (!organization) { throw new HttpException('Organization not found', HttpStatus.INTERNAL_SERVER_ERROR); } const maxRetentionMs = this.getMaxRetentionPeriodByOrganization(organization); // For unlimited retention (self-hosted), skip retention validation if (maxRetentionMs === Number.MAX_SAFE_INTEGER) { const effectiveAfterDate = after ? this.parseAndValidateDate(after, 'after') : undefined; const effectiveBeforeDate = before ? this.parseAndValidateDate(before, 'before') : undefined; // Basic validation for date range if both dates are provided if (effectiveAfterDate && effectiveBeforeDate && effectiveAfterDate > effectiveBeforeDate) { throw new HttpException( 'Invalid date range: start date (after) must be earlier than end date (before)', HttpStatus.BAD_REQUEST ); } return { after: effectiveAfterDate?.toISOString(), before: effectiveBeforeDate?.toISOString(), }; } const earliestAllowedDate = new Date(Date.now() - maxRetentionMs); // If no after date is provided, default to the earliest allowed date const effectiveAfterDate = after ? this.parseAndValidateDate(after, 'after') : earliestAllowedDate; const effectiveBeforeDate = before ? this.parseAndValidateDate(before, 'before') : new Date(); this.validateDateRange(earliestAllowedDate, effectiveAfterDate, effectiveBeforeDate); return { after: effectiveAfterDate.toISOString(), before: effectiveBeforeDate.toISOString(), }; } private parseAndValidateDate(dateString: string, parameterName: string): Date { const parsedDate = new Date(dateString); if (Number.isNaN(parsedDate.getTime())) { throw new HttpException( `Invalid date format for parameter '${parameterName}': ${dateString}. Please provide a valid ISO 8601 date string.`, HttpStatus.BAD_REQUEST ); } return parsedDate; } private validateDateRange(earliestAllowedDate: Date, afterDate: Date, beforeDate: Date) { if (afterDate > beforeDate) { throw new HttpException( 'Invalid date range: start date (after) must be earlier than end date (before)', HttpStatus.BAD_REQUEST ); } // add buffer to account for time delay in execution const buffer = 1 * 60 * 60 * 1000; // 1 hour const bufferedEarliestAllowedDate = new Date(earliestAllowedDate.getTime() - buffer); if ( process.env.NODE_ENV !== 'local' && (afterDate < bufferedEarliestAllowedDate || beforeDate < bufferedEarliestAllowedDate) ) { throw new HttpException( `Requested date range exceeds your plan's retention period. ` + `The earliest accessible date for your plan is ${earliestAllowedDate.toISOString().split('T')[0]}. ` + `Please upgrade your plan to access older activities.`, HttpStatus.PAYMENT_REQUIRED ); } } /** * Notifications are automatically deleted after a certain period of time * by a background job. * * @see https://github.com/novuhq/cloud-infra/blob/main/scripts/expiredNotification.js#L93 */ private getMaxRetentionPeriodByOrganization(organization: OrganizationEntity) { // 1. Self-hosted: effectively unlimited, use a large but safe finite window (100 years) if (process.env.IS_SELF_HOSTED === 'true') { return 100 * 365 * 24 * 60 * 60 * 1000; // ~100 years in ms, safe for Date math } const { apiServiceLevel, createdAt } = organization; // 2. Special case: Free tier orgs created before Feb 28, 2025 get 30 days if (apiServiceLevel === ApiServiceLevelEnum.FREE && new Date(createdAt) < new Date('2025-02-28')) { return 30 * 24 * 60 * 60 * 1000; } // 3. Otherwise, use tier-based retention from feature flags return getFeatureForTierAsNumber( FeatureNameEnum.PLATFORM_ACTIVITY_FEED_RETENTION, apiServiceLevel ?? ApiServiceLevelEnum.FREE, true ); } @Instrument() private async findSubscribers(command: GetActivityFeedCommand): Promise { return await this.subscribersRepository.searchSubscribers( command.environmentId, command.subscriberIds, command.emails, command.search ); } @Instrument() private async getFeedNotifications( command: GetActivityFeedCommand, subscriberIds?: string[] ): Promise { const notifications = await this.notificationRepository.getFeed( command.environmentId, { channels: command.channels, templates: command.templates, subscriberIds: subscriberIds || [], transactionId: command.transactionId, topicKey: command.topicKey, subscriptionId: command.subscriptionId, after: command.after, before: command.before, severity: command.severity, contextKeys: command.contextKeys, }, command.page * command.limit, command.limit ); const isClickHouseOnlyEnabled = await this.featureFlagsService.getFlag({ key: FeatureFlagsKeysEnum.IS_EXECUTION_DETAILS_CLICKHOUSE_ONLY_ENABLED, defaultValue: false, organization: { _id: command.organizationId }, user: { _id: command.userId }, environment: { _id: command.environmentId }, }); if (isClickHouseOnlyEnabled) { return await this.enhanceNotificationsWithTraces(notifications, command); } return notifications; } private async enhanceNotificationsWithTraces( notifications: NotificationFeedItemEntity[], command: GetActivityFeedCommand ): Promise { try { // Collect all job IDs from all notifications const allJobIds: string[] = []; for (const notification of notifications) { if (notification.jobs) { allJobIds.push(...notification.jobs.map((job) => job._id)); } } if (allJobIds.length === 0) { return notifications; } // Get execution details from ClickHouse for all job IDs const executionDetailsByJobId = await this.getExecutionDetailsByEntityId(allJobIds, command); // Enhance each notification with the execution details const enhancedNotifications = notifications.map((notification) => { if (!notification.jobs) { return notification; } const enhancedJobs = notification.jobs.map((job) => { const executionDetails = executionDetailsByJobId.get(job._id) || []; return { ...job, executionDetails, }; }); return { ...notification, jobs: enhancedJobs, }; }); this.logger.debug({ notificationCount: notifications.length, jobCount: allJobIds.length, executionDetailsCount: Array.from(executionDetailsByJobId.values()).flat().length, }, 'Successfully enhanced notifications with ClickHouse execution details'); return enhancedNotifications; } catch (error) { this.logger.error( { error: error instanceof Error ? error.message : 'Unknown error', environmentId: command.environmentId, organizationId: command.organizationId, }, 'Failed to enhance notifications with ClickHouse execution details, falling back to MongoDB data' ); // Fall back to the original notifications if ClickHouse query fails return notifications; } } private mapTraceStatusToExecutionStatus(traceStatus: string): ExecutionDetailsStatusEnum { switch (traceStatus.toLowerCase()) { case 'success': return ExecutionDetailsStatusEnum.SUCCESS; case 'error': case 'failed': return ExecutionDetailsStatusEnum.FAILED; case 'warning': return ExecutionDetailsStatusEnum.WARNING; case 'pending': return ExecutionDetailsStatusEnum.PENDING; case 'queued': return ExecutionDetailsStatusEnum.QUEUED; default: return ExecutionDetailsStatusEnum.PENDING; } } private async getExecutionDetailsByEntityId( entityIds: string[], command: GetActivityFeedCommand ): Promise> { if (entityIds.length === 0) { return new Map(); } const traceQuery = new QueryBuilder({ environmentId: command.environmentId, }) .whereIn('entity_id', entityIds) .whereEquals('entity_type', 'step_run') .build(); const traceResult = await this.traceLogRepository.find({ where: traceQuery, orderBy: 'created_at', orderDirection: 'ASC', select: traceFindColumns, }); const executionDetailsByEntityId = new Map(); // Group traces by entity ID const traceLogsByEntityId = new Map(); for (const trace of traceResult.data) { if (!traceLogsByEntityId.has(trace.entity_id)) { traceLogsByEntityId.set(trace.entity_id, []); } const entityTraces = traceLogsByEntityId.get(trace.entity_id); if (entityTraces) { entityTraces.push(trace); } } // Convert traces to execution details for each entity for (const [entityId, traces] of traceLogsByEntityId) { const executionDetails: ExecutionDetailFeedItem[] = traces.map((trace: TraceFindResult) => ({ _id: trace.id, providerId: undefined, detail: trace.title, source: ExecutionDetailsSourceEnum.INTERNAL, _jobId: entityId, status: this.mapTraceStatusToExecutionStatus(trace.status), isTest: false, isRetry: false, createdAt: new Date(trace.created_at).toISOString(), raw: trace.raw_data, })); executionDetailsByEntityId.set(entityId, executionDetails); } return executionDetailsByEntityId; } } ================================================ FILE: apps/api/src/app/notifications/usecases/get-activity-feed/map-feed-item-to.dto.ts ================================================ import { FieldFilterPartDto, FilterPartsDto, OnlineInLastFilterPartDto, PreviousStepFilterPartDto, RealtimeOnlineFilterPartDto, StepFilterDto, TenantFilterPartDto, WebhookFilterPartDto, } from '@novu/application-generic'; import { ExecutionDetailFeedItem, JobFeedItem, NotificationFeedItemEntity, NotificationStepEntity, StepFilter, SubscriberFeedItem, TemplateFeedItem, } from '@novu/dal'; import { DigestTypeEnum, FilterParts, FilterPartTypeEnum, IDigestRegularMetadata, IDigestTimedMetadata, IWorkflowStepMetadata, ProvidersIdEnum, SeverityLevelEnum, StepTypeEnum, } from '@novu/shared'; import { MessageTemplateDto } from '../../../shared/dtos/message.template.dto'; import { ActivityNotificationExecutionDetailResponseDto, ActivityNotificationJobResponseDto, ActivityNotificationResponseDto, ActivityNotificationStepResponseDto, ActivityNotificationSubscriberResponseDto, ActivityNotificationTemplateResponseDto, DigestMetadataDto, } from '../../dtos/activities-response.dto'; function buildSubscriberDto(subscriber: SubscriberFeedItem): ActivityNotificationSubscriberResponseDto { return { _id: subscriber._id, subscriberId: subscriber.subscriberId, email: subscriber.email, firstName: subscriber.firstName, lastName: subscriber.lastName, phone: subscriber.phone, }; } function buildTemplate(template: TemplateFeedItem): ActivityNotificationTemplateResponseDto { return { _id: template._id, name: template.name, triggers: template.triggers, origin: template.origin, }; } export function mapFeedItemToDto(entity: NotificationFeedItemEntity): ActivityNotificationResponseDto { return { _digestedNotificationId: entity._digestedNotificationId, _environmentId: entity._environmentId, _id: entity._id, _organizationId: entity._organizationId, _subscriberId: entity._subscriberId, _templateId: entity._templateId, topics: entity.topics?.map((topic) => ({ _topicId: topic._topicId, topicKey: topic.topicKey, })), channels: entity.channels, createdAt: entity.createdAt, jobs: entity.jobs.map(mapJobToDto), tags: entity.tags, transactionId: entity.transactionId, updatedAt: entity.updatedAt, controls: entity.controls as Record, payload: entity.payload as Record, to: entity.to as Record, subscriber: entity.subscriber ? buildSubscriberDto(entity.subscriber) : undefined, template: entity.template ? buildTemplate(entity.template) : undefined, severity: entity.severity ?? SeverityLevelEnum.NONE, critical: entity.critical, contextKeys: entity.contextKeys, }; } function mapChildFilterToDto(filterPart: FilterParts): FilterPartsDto { switch (filterPart.on) { case FilterPartTypeEnum.SUBSCRIBER: case FilterPartTypeEnum.PAYLOAD: return { ...filterPart, on: filterPart.on, // Ensure the correct enum value is set } as FieldFilterPartDto; case FilterPartTypeEnum.WEBHOOK: return { ...filterPart, on: FilterPartTypeEnum.WEBHOOK, } as WebhookFilterPartDto; case FilterPartTypeEnum.IS_ONLINE: return { ...filterPart, on: FilterPartTypeEnum.IS_ONLINE, } as RealtimeOnlineFilterPartDto; case FilterPartTypeEnum.IS_ONLINE_IN_LAST: return { ...filterPart, on: FilterPartTypeEnum.IS_ONLINE_IN_LAST, } as OnlineInLastFilterPartDto; case FilterPartTypeEnum.PREVIOUS_STEP: return { ...filterPart, on: FilterPartTypeEnum.PREVIOUS_STEP, } as PreviousStepFilterPartDto; case FilterPartTypeEnum.TENANT: return { ...filterPart, on: FilterPartTypeEnum.TENANT, } as TenantFilterPartDto; default: throw new Error(`Unknown filter part type: ${filterPart}`); } } function mapToFilterDto(stepFilter: StepFilter): StepFilterDto { return { children: stepFilter.children.map((child) => mapChildFilterToDto(child)), isNegated: stepFilter.isNegated, type: stepFilter.type, value: stepFilter.value, }; } function convertStepToResponse(step: NotificationStepEntity): ActivityNotificationStepResponseDto { const responseDto = new ActivityNotificationStepResponseDto(); responseDto._id = step._id || ''; responseDto.active = step.active || false; responseDto.replyCallback = step.replyCallback; responseDto.controlVariables = step.controlVariables; responseDto.metadata = step.metadata; responseDto.issues = step.issues; responseDto._templateId = step._templateId || ''; responseDto.name = step.name; responseDto._parentId = step._parentId || null; // Map filters responseDto.filters = (step.filters || []).map(mapToFilterDto); // Map template if exists if (step.template) { const messageTemplateDto = new MessageTemplateDto(); messageTemplateDto.type = step.template.type; messageTemplateDto.content = step.template.content; messageTemplateDto.contentType = step.template.contentType; messageTemplateDto.cta = step.template.cta; messageTemplateDto.actor = step.template.actor; messageTemplateDto.variables = step.template.variables; messageTemplateDto._feedId = step.template._feedId; messageTemplateDto._layoutId = step.template._layoutId; messageTemplateDto.name = step.template.name; messageTemplateDto.subject = step.template.subject; messageTemplateDto.title = step.template.title; messageTemplateDto.preheader = step.template.preheader; messageTemplateDto.senderName = step.template.senderName; messageTemplateDto._creatorId = step.template._creatorId; responseDto.template = messageTemplateDto; } if (step.variants) { responseDto.variants = step.variants.map((variant) => convertStepToResponse(variant)); } return responseDto; } function isDigestRegularMetadata(item: IWorkflowStepMetadata): item is IDigestRegularMetadata { return 'type' in item && (item.type === DigestTypeEnum.REGULAR || item.type === DigestTypeEnum.BACKOFF); } function isDigestTimedMetadata(item: IWorkflowStepMetadata): item is IDigestTimedMetadata { return 'type' in item && item.type === DigestTypeEnum.TIMED; } function mapDigest( digestData?: | (IWorkflowStepMetadata & { events?: any[]; }) | string | null ): DigestMetadataDto | undefined { if (!digestData) { return undefined; } const digestItem = typeof digestData === 'string' ? (JSON.parse(digestData) as IWorkflowStepMetadata & { events?: any[]; }) : (digestData as IWorkflowStepMetadata & { events?: any[]; }); if (!digestItem) { return undefined; } // Type guarding and mapping based on the type of item if (isDigestRegularMetadata(digestItem)) { // If it's IDigestRegularMetadata return { digestKey: digestItem.digestKey, amount: digestItem.amount, unit: digestItem.unit, events: digestItem.events || [], // Default to an empty array if no events are provided type: digestItem.type, // Set the type as either REGULAR or BACKOFF backoff: digestItem.backoff, backoffAmount: digestItem.backoffAmount, backoffUnit: digestItem.backoffUnit, updateMode: digestItem.updateMode, // Set update mode if available }; } if (isDigestTimedMetadata(digestItem)) { return { digestKey: digestItem.digestKey, amount: digestItem.amount, unit: digestItem.unit, events: digestItem.events || [], // Default to an empty array if no events are provided type: DigestTypeEnum.TIMED, // Set the type as TIMED timed: { atTime: digestItem.timed?.atTime, weekDays: digestItem.timed?.weekDays, monthDays: digestItem.timed?.monthDays, ordinal: digestItem.timed?.ordinal, ordinalValue: digestItem.timed?.ordinalValue, monthlyType: digestItem.timed?.monthlyType, cronExpression: digestItem.timed?.cronExpression, untilDate: digestItem.timed?.untilDate, }, }; } return undefined; } function mapJobToDto(item: JobFeedItem): ActivityNotificationJobResponseDto { return { _id: item._id, type: item.type as StepTypeEnum, digest: mapDigest(item.digest), executionDetails: item.executionDetails.map(convertExecutionDetail), step: convertStepToResponse(item.step), overrides: item.overrides, payload: item.payload, providerId: item.providerId as ProvidersIdEnum, status: item.status, updatedAt: item.updatedAt, scheduleExtensionsCount: item.scheduleExtensionsCount, }; } function convertExecutionDetail(entity: ExecutionDetailFeedItem): ActivityNotificationExecutionDetailResponseDto { return { _id: entity._id, detail: entity.detail, isRetry: entity.isRetry, isTest: entity.isTest, providerId: entity.providerId as unknown as ProvidersIdEnum, source: entity.source, status: entity.status, raw: entity.raw || undefined, createdAt: entity.createdAt, }; } ================================================ FILE: apps/api/src/app/notifications/usecases/get-activity-graph-states/get-activity-graph-states.command.ts ================================================ import { IsNumber, IsOptional } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; export class GetActivityGraphStatsCommand extends EnvironmentWithUserCommand { @IsNumber() @IsOptional() days: number; } ================================================ FILE: apps/api/src/app/notifications/usecases/get-activity-graph-states/get-activity-graph-states.usecase.ts ================================================ import { Injectable } from '@nestjs/common'; import { NotificationRepository } from '@novu/dal'; import { subDays } from 'date-fns'; import { ActivityGraphStatesResponse } from '../../dtos/activity-graph-states-response.dto'; import { GetActivityGraphStatsCommand } from './get-activity-graph-states.command'; @Injectable() export class GetActivityGraphStats { constructor(private notificationRepository: NotificationRepository) {} async execute(command: GetActivityGraphStatsCommand): Promise { return await this.notificationRepository.getActivityGraphStats( subDays(new Date(), command.days), command.environmentId ); } } ================================================ FILE: apps/api/src/app/notifications/usecases/get-activity-stats/get-activity-stats.command.ts ================================================ import { EnvironmentCommand } from '../../../shared/commands/project.command'; export class GetActivityStatsCommand extends EnvironmentCommand {} ================================================ FILE: apps/api/src/app/notifications/usecases/get-activity-stats/get-activity-stats.usecase.ts ================================================ import { Injectable } from '@nestjs/common'; import { NotificationRepository } from '@novu/dal'; import { ActivityStatsResponseDto } from '../../dtos/activity-stats-response.dto'; import { GetActivityStatsCommand } from './get-activity-stats.command'; @Injectable() export class GetActivityStats { constructor(private notificationRepository: NotificationRepository) {} async execute(command: GetActivityStatsCommand): Promise { const result = await this.notificationRepository.getStats(command.environmentId); return { weeklySent: result.weekly, monthlySent: result.monthly, }; } } ================================================ FILE: apps/api/src/app/notifications/usecases/get-activity-stats/index.ts ================================================ export { GetActivityStatsCommand } from './get-activity-stats.command'; export { GetActivityStats } from './get-activity-stats.usecase'; ================================================ FILE: apps/api/src/app/notifications/usecases/index.ts ================================================ import { GetActivity } from './get-activity/get-activity.usecase'; import { GetActivityFeed } from './get-activity-feed/get-activity-feed.usecase'; import { GetActivityGraphStats } from './get-activity-graph-states/get-activity-graph-states.usecase'; import { GetActivityStats } from './get-activity-stats'; export const USE_CASES = [ GetActivityStats, GetActivityGraphStats, GetActivityFeed, GetActivity, // ]; ================================================ FILE: apps/api/src/app/organization/dtos/create-organization.dto.ts ================================================ import { ICreateOrganizationDto, JobTitleEnum, ProductUseCases } from '@novu/shared'; import { IsDefined, IsEnum, IsOptional, IsString } from 'class-validator'; export class CreateOrganizationDto implements ICreateOrganizationDto { @IsString() @IsDefined() name: string; @IsString() @IsOptional() logo?: string; @IsOptional() @IsEnum(JobTitleEnum) jobTitle?: JobTitleEnum; @IsString() @IsOptional() domain?: string; @IsOptional() language?: string[]; } ================================================ FILE: apps/api/src/app/organization/dtos/get-my-organization.dto.ts ================================================ import { OrganizationEntity } from '@novu/dal'; export type IGetMyOrganizationDto = OrganizationEntity; ================================================ FILE: apps/api/src/app/organization/dtos/get-organization-settings.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { IsValidLocale } from '@novu/application-generic'; import { OrganizationEntity } from '@novu/dal'; import { IsArray, IsBoolean, IsOptional, IsString } from 'class-validator'; export class GetOrganizationSettingsDto { @ApiProperty({ description: 'Remove Novu branding', example: false, }) @IsBoolean() removeNovuBranding: boolean; @ApiProperty({ description: 'Default locale', example: 'en_US', }) @IsValidLocale() defaultLocale: string; @ApiProperty({ description: 'Target locales', example: ['en_US', 'es_ES'], }) @IsOptional() @IsArray() @IsString({ each: true }) targetLocales?: string[]; } ================================================ FILE: apps/api/src/app/organization/dtos/get-organizations.dto.ts ================================================ import { OrganizationEntity } from '@novu/dal'; export type IGetOrganizationsDto = OrganizationEntity[]; ================================================ FILE: apps/api/src/app/organization/dtos/member-response.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { MemberRoleEnum, MemberStatusEnum } from '@novu/shared'; import { IsDate, IsEnum, IsObject, IsString } from 'class-validator'; export class MemberUserDto { @ApiProperty() @IsString() _id: string; @ApiProperty() @IsString() firstName: string; @ApiProperty() @IsString() lastName: string; @ApiProperty() @IsString() email: string; } export class MemberInviteDTO { @ApiProperty() @IsString() email: string; @ApiProperty() @IsString() token: string; @ApiProperty() @IsDate() invitationDate: Date; @ApiPropertyOptional() @IsDate() answerDate?: Date; @ApiProperty() @IsString() _inviterId: string; } export class MemberResponseDto { @ApiProperty() @IsString() _id: string; @ApiProperty() @IsString() _userId: string; @ApiPropertyOptional() @IsObject() user?: MemberUserDto; @ApiPropertyOptional({ enum: MemberRoleEnum }) @IsEnum(MemberRoleEnum) roles?: MemberRoleEnum; @ApiPropertyOptional() @IsObject() invite?: MemberInviteDTO; @ApiPropertyOptional({ enum: { ...MemberStatusEnum }, }) @IsEnum(MemberStatusEnum) memberStatus?: MemberStatusEnum; @ApiProperty() @IsString() _organizationId: string; } ================================================ FILE: apps/api/src/app/organization/dtos/organization-response.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { DirectionEnum, PartnerTypeEnum } from '@novu/dal'; import { IsArray, IsEnum, IsObject, IsString } from 'class-validator'; import { UpdateBrandingDetailsDto } from './update-branding-details.dto'; export class IPartnerConfigurationResponseDto { @ApiPropertyOptional() @IsArray() @IsString({ each: true }) projectIds?: string[]; @ApiProperty() @IsString() accessToken: string; @ApiProperty() @IsString() configurationId: string; @ApiPropertyOptional() @IsString() teamId: string; @ApiProperty({ enum: PartnerTypeEnum, description: 'Partner Type Enum', }) @IsEnum(PartnerTypeEnum) partnerType: PartnerTypeEnum; } export class OrganizationBrandingResponseDto extends UpdateBrandingDetailsDto { @ApiPropertyOptional({ enum: DirectionEnum, }) @IsString() direction?: DirectionEnum; } export class OrganizationResponseDto { @ApiProperty() @IsString() name: string; @ApiPropertyOptional() @IsString() logo?: string; @ApiProperty() @IsObject() branding: OrganizationBrandingResponseDto; @ApiPropertyOptional() @IsObject() partnerConfigurations: IPartnerConfigurationResponseDto[]; } ================================================ FILE: apps/api/src/app/organization/dtos/rename-organization.dto.ts ================================================ import { IsDefined, IsString } from 'class-validator'; export class RenameOrganizationDto { @IsString() @IsDefined() name: string; } ================================================ FILE: apps/api/src/app/organization/dtos/update-branding-details.dto.ts ================================================ import { IsHexColor, IsOptional, IsString, IsUrl } from 'class-validator'; import { IsImageUrl } from '../../shared/validators/image.validator'; const environments = ['production', 'test']; const protocols = environments.includes(process.env.NODE_ENV || '') ? ['https'] : ['http', 'https']; export class UpdateBrandingDetailsDto { @IsUrl({ require_protocol: true, protocols, require_tld: false, }) @IsImageUrl({ message: 'Logo must be a valid image URL with one of the following extensions: jpg, jpeg, png, gif, svg', }) @IsOptional() logo: string; @IsOptional() @IsHexColor() color: string; @IsOptional() @IsHexColor() fontColor: string; @IsOptional() @IsHexColor() contentBackground: string; @IsOptional() @IsString() fontFamily?: string; } ================================================ FILE: apps/api/src/app/organization/dtos/update-member-roles.dto.ts ================================================ import { MemberRoleEnum } from '@novu/shared'; import { IsEnum } from 'class-validator'; export class UpdateMemberRolesDto { @IsEnum(MemberRoleEnum) role: MemberRoleEnum.OSS_ADMIN; } ================================================ FILE: apps/api/src/app/organization/dtos/update-organization-settings.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { IsValidLocale } from '@novu/application-generic'; import { IsArray, IsBoolean, IsOptional, IsString } from 'class-validator'; export class UpdateOrganizationSettingsDto { @ApiProperty({ description: 'Enable or disable Novu branding', example: true, }) @IsOptional() @IsBoolean() removeNovuBranding?: boolean; @ApiProperty({ description: 'Default locale', example: 'en_US', }) @IsOptional() @IsValidLocale() defaultLocale?: string; @ApiProperty({ description: 'Target locales', example: ['en_US', 'es_ES'], }) @IsOptional() @IsArray() @IsString({ each: true }) // TODO: validate locales targetLocales?: string[]; } ================================================ FILE: apps/api/src/app/organization/e2e/change-member-role.e2e.ts ================================================ import { CommunityMemberRepository } from '@novu/dal'; import { MemberRoleEnum, MemberStatusEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { describe } from 'mocha'; describe('Change member role - /organizations/members/:memberId/role (PUT) #novu-v0-os', async () => { const memberRepository = new CommunityMemberRepository(); let session: UserSession; let user2: UserSession; let user3: UserSession; beforeEach(async () => { session = new UserSession(); await session.initialize(); user2 = new UserSession(); await user2.initialize({ noOrganization: true, }); user3 = new UserSession(); await user3.initialize({ noOrganization: true, }); }); // Currently skipped until we implement role management it.skip('should update admin to member', async () => { await memberRepository.addMember(session.organization._id, { _userId: user2.user._id, invite: null, roles: [MemberRoleEnum.OSS_ADMIN], memberStatus: MemberStatusEnum.ACTIVE, }); const member = await memberRepository.findMemberByUserId(session.organization._id, user2.user._id); const { body } = await session.testAgent.put(`/v1/organizations/members/${member._id}/roles`).send({ role: MemberRoleEnum.OSS_MEMBER, }); expect(body.data.roles.length).to.equal(1); expect(body.data.roles[0]).to.equal(MemberRoleEnum.OSS_MEMBER); }); it('should update member to admin', async () => { await memberRepository.addMember(session.organization._id, { _userId: user3.user._id, invite: null, roles: [MemberRoleEnum.OSS_MEMBER], memberStatus: MemberStatusEnum.ACTIVE, }); const member = await memberRepository.findMemberByUserId(session.organization._id, user3.user._id); const { body } = await session.testAgent.put(`/v1/organizations/members/${member._id}/roles`).send({ role: MemberRoleEnum.OSS_ADMIN, }); expect(body.data.roles.length).to.equal(1); expect(body.data.roles.includes(MemberRoleEnum.OSS_ADMIN)).to.be.ok; expect(body.data.roles.includes(MemberRoleEnum.OSS_MEMBER)).not.to.be.ok; }); }); ================================================ FILE: apps/api/src/app/organization/e2e/create-organization.e2e.ts ================================================ import { CommunityMemberRepository, CommunityOrganizationRepository, CommunityUserRepository, EnvironmentRepository, IntegrationRepository, } from '@novu/dal'; import { ApiServiceLevelEnum, ChannelTypeEnum, ChatProviderIdEnum, EmailProviderIdEnum, ICreateOrganizationDto, InAppProviderIdEnum, JobTitleEnum, MemberRoleEnum, SmsProviderIdEnum, } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; describe('Create Organization - /organizations (POST) #novu-v0-os', async () => { let session: UserSession; const organizationRepository = new CommunityOrganizationRepository(); const userRepository = new CommunityUserRepository(); const memberRepository = new CommunityMemberRepository(); const integrationRepository = new IntegrationRepository(); const environmentRepository = new EnvironmentRepository(); before(async () => { session = new UserSession(); await session.initialize({ noOrganization: true, }); }); describe('Valid Creation', () => { it('should add the user as admin', async () => { const { body } = await session.testAgent .post('/v1/organizations') .send({ name: 'Test Org 2', }) .expect(201); const dbOrganization = await organizationRepository.findById(body.data._id); const members = await memberRepository.getOrganizationMembers(dbOrganization?._id as string); expect(members.length).to.eq(1); expect(members[0]._userId).to.eq(session.user._id); expect(members[0].roles[0]).to.eq(MemberRoleEnum.OSS_ADMIN); }); it('should create organization with correct name', async () => { const demoOrganization = { name: 'Hello Org', }; const { body } = await session.testAgent.post('/v1/organizations').send(demoOrganization).expect(201); expect(body.data.name).to.eq(demoOrganization.name); }); it('should not create organization with no name', async () => { await session.testAgent.post('/v1/organizations').send({}).expect(400); }); it('should create organization with apiServiceLevel of free by default', async () => { const testOrganization = { name: 'Free Org', }; const { body } = await session.testAgent.post('/v1/organizations').send(testOrganization).expect(201); const dbOrganization = await organizationRepository.findById(body.data._id); expect(dbOrganization?.apiServiceLevel).to.eq(ApiServiceLevelEnum.FREE); }); it('should create organization with questionnaire data', async () => { const testOrganization: ICreateOrganizationDto = { name: 'Org Name', domain: 'org.com', }; const { body } = await session.testAgent.post('/v1/organizations').send(testOrganization).expect(201); const dbOrganization = await organizationRepository.findById(body.data._id); expect(dbOrganization?.name).to.eq(testOrganization.name); expect(dbOrganization?.domain).to.eq(testOrganization.domain); }); it('should update user job title on organization creation', async () => { const testOrganization: ICreateOrganizationDto = { name: 'Org Name', jobTitle: JobTitleEnum.PRODUCT_MANAGER, }; await session.testAgent.post('/v1/organizations').send(testOrganization).expect(201); const user = await userRepository.findById(session.user._id); expect(user?.jobTitle).to.eq(testOrganization.jobTitle); }); it('should create organization with built in Novu integrations and set them as primary', async () => { const testOrganization: ICreateOrganizationDto = { name: 'Org Name', }; const { body } = await session.testAgent.post('/v1/organizations').send(testOrganization).expect(201); const integrations = await integrationRepository.find({ _organizationId: body.data._id }); const environments = await environmentRepository.find({ _organizationId: body.data._id }); const productionEnv = environments.find((e) => e.name === 'Production'); const developmentEnv = environments.find((e) => e.name === 'Development'); const novuEmailIntegration = integrations.filter( (i) => i.active && i.channel === ChannelTypeEnum.EMAIL && i.providerId === EmailProviderIdEnum.Novu ); const novuSmsIntegration = integrations.filter( (i) => i.active && i.channel === ChannelTypeEnum.SMS && i.providerId === SmsProviderIdEnum.Novu ); const novuChatIntegration = integrations.filter( (i) => i.active && i.channel === ChannelTypeEnum.CHAT && i.providerId === ChatProviderIdEnum.Novu ); const novuInAppIntegration = integrations.filter( (i) => i.active && i.channel === ChannelTypeEnum.IN_APP && i.providerId === InAppProviderIdEnum.Novu ); const novuEmailIntegrationProduction = novuEmailIntegration.filter( (el) => el._environmentId === productionEnv?._id ); const novuEmailIntegrationDevelopment = novuEmailIntegration.filter( (el) => el._environmentId === developmentEnv?._id ); const novuSmsIntegrationProduction = novuSmsIntegration.filter((el) => el._environmentId === productionEnv?._id); const novuSmsIntegrationDevelopment = novuSmsIntegration.filter( (el) => el._environmentId === developmentEnv?._id ); const novuInAppIntegrationProduction = novuInAppIntegration.filter( (el) => el._environmentId === productionEnv?._id ); const novuInAppIntegrationDevelopment = novuInAppIntegration.filter( (el) => el._environmentId === developmentEnv?._id ); expect(integrations.length).to.eq(6); expect(novuEmailIntegration?.length).to.eq(2); expect(novuSmsIntegration?.length).to.eq(2); expect(novuChatIntegration?.length).to.eq(1); expect(novuInAppIntegration?.length).to.eq(2); expect(novuEmailIntegrationProduction.length).to.eq(1); expect(novuSmsIntegrationProduction.length).to.eq(1); expect(novuInAppIntegrationProduction.length).to.eq(1); expect(novuEmailIntegrationDevelopment.length).to.eq(1); expect(novuSmsIntegrationDevelopment.length).to.eq(1); expect(novuInAppIntegrationDevelopment.length).to.eq(1); expect(novuEmailIntegrationProduction[0].primary).to.eq(true); expect(novuSmsIntegrationProduction[0].primary).to.eq(true); expect(novuEmailIntegrationDevelopment[0].primary).to.eq(true); expect(novuSmsIntegrationDevelopment[0].primary).to.eq(true); }); it('when Novu Email credentials are not set it should not create Novu Email integration', async () => { const oldNovuEmailIntegrationApiKey = process.env.NOVU_EMAIL_INTEGRATION_API_KEY; process.env.NOVU_EMAIL_INTEGRATION_API_KEY = ''; const testOrganization: ICreateOrganizationDto = { name: 'Org Name', }; const { body } = await session.testAgent.post('/v1/organizations').send(testOrganization).expect(201); const integrations = await integrationRepository.find({ _organizationId: body.data._id }); const environments = await environmentRepository.find({ _organizationId: body.data._id }); const productionEnv = environments.find((e) => e.name === 'Production'); const developmentEnv = environments.find((e) => e.name === 'Development'); const novuSmsIntegration = integrations.filter( (i) => i.active && i.name === 'Novu SMS' && i.providerId === SmsProviderIdEnum.Novu ); expect(integrations.length).to.eq(4); expect(novuSmsIntegration?.length).to.eq(2); expect(novuSmsIntegration.filter((el) => el._environmentId === productionEnv?._id).length).to.eq(1); expect(novuSmsIntegration.filter((el) => el._environmentId === developmentEnv?._id).length).to.eq(1); process.env.NOVU_EMAIL_INTEGRATION_API_KEY = oldNovuEmailIntegrationApiKey; }); it('when Novu SMS credentials are not set it should not create Novu SMS integration', async () => { const oldNovuSmsIntegrationAccountSid = process.env.NOVU_SMS_INTEGRATION_ACCOUNT_SID; process.env.NOVU_SMS_INTEGRATION_ACCOUNT_SID = ''; const testOrganization: ICreateOrganizationDto = { name: 'Org Name', }; const { body } = await session.testAgent.post('/v1/organizations').send(testOrganization).expect(201); const integrations = await integrationRepository.find({ _organizationId: body.data._id }); const environments = await environmentRepository.find({ _organizationId: body.data._id }); const productionEnv = environments.find((e) => e.name === 'Production'); const developmentEnv = environments.find((e) => e.name === 'Development'); const novuEmailIntegrations = integrations.filter( (i) => i.active && i.name === 'Novu Email' && i.providerId === EmailProviderIdEnum.Novu ); expect(integrations.length).to.eq(4); expect(novuEmailIntegrations?.length).to.eq(2); expect(novuEmailIntegrations.filter((el) => el._environmentId === productionEnv?._id).length).to.eq(1); expect(novuEmailIntegrations.filter((el) => el._environmentId === developmentEnv?._id).length).to.eq(1); process.env.NOVU_SMS_INTEGRATION_ACCOUNT_SID = oldNovuSmsIntegrationAccountSid; }); it('when Novu Chat credentials are not set it should not create Novu Chat integration', async () => { // todo }); }); }); ================================================ FILE: apps/api/src/app/organization/e2e/get-members.e2e.ts ================================================ import { CommunityMemberRepository } from '@novu/dal'; import { MemberRoleEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; describe('Get members - /organization/members (GET) #novu-v0-os', async () => { let session: UserSession; let otherSession: UserSession; const memberRepository = new CommunityMemberRepository(); before(async () => { session = new UserSession(); await session.initialize(); otherSession = new UserSession(); await otherSession.initialize({ noOrganization: true, }); await session.testAgent .post('/v1/invites/bulk') .send({ invitees: [ { email: 'dddd@asdas.com', role: MemberRoleEnum.OSS_ADMIN, }, ], }) .expect(201); const members = await memberRepository.getOrganizationMembers(session.organization._id); const invitee = members.find((i) => !i._userId); await otherSession.testAgent.post(`/v1/invites/${invitee.invite.token}/accept`).expect(201); otherSession.organization = session.organization; await otherSession.fetchJWT(); }); it('should see emails of all members as admin', async () => { const { body } = await session.testAgent.get('/v1/organizations/members').expect(200); expect(JSON.stringify(body.data)).to.include('dddd@asdas.com'); expect(JSON.stringify(body.data)).to.include(session.user.firstName); }); }); ================================================ FILE: apps/api/src/app/organization/e2e/get-my-organization.e2e.ts ================================================ import { UserSession } from '@novu/testing'; import { expect } from 'chai'; describe('Get my organization - /organizations/me (GET) #novu-v0-os', async () => { let session: UserSession; before(async () => { session = new UserSession(); await session.initialize(); }); describe('Get organization profile', () => { it('should return the correct organization', async () => { const { body } = await session.testAgent.get('/v1/organizations/me').expect(200); expect(body.data._id).to.eq(session.organization._id); }); }); }); ================================================ FILE: apps/api/src/app/organization/e2e/get-organizations.e2e.ts ================================================ import { CommunityMemberRepository, OrganizationEntity } from '@novu/dal'; import { MemberRoleEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; describe('Get organizations - /organizations (GET) #novu-v0-os', async () => { let session: UserSession; let otherSession: UserSession; let thirdSession: UserSession; let thirdOldOrganization: OrganizationEntity; const memberRepository = new CommunityMemberRepository(); before(async () => { session = new UserSession(); await session.initialize(); otherSession = new UserSession(); await otherSession.initialize(); thirdSession = new UserSession(); await thirdSession.initialize(); await session.testAgent .post('/v1/invites/bulk') .send({ invitees: [ { email: 'dddd@asdas.com', role: MemberRoleEnum.OSS_MEMBER, }, ], }) .expect(201); const members = await memberRepository.getOrganizationMembers(session.organization._id); const invitee = members.find((i) => !i._userId); thirdOldOrganization = thirdSession.organization; await thirdSession.testAgent.post(`/v1/invites/${invitee.invite.token}/accept`).expect(201); }); it('should see all organizations that you are a part of', async () => { const { body } = await thirdSession.testAgent.get('/v1/organizations').expect(200); expect(JSON.stringify(body.data)).to.include(session.organization.name); expect(JSON.stringify(body.data)).to.include(thirdSession.organization.name); expect(JSON.stringify(body.data)).to.include(thirdOldOrganization.name); expect(JSON.stringify(body.data)).to.not.include(otherSession.organization.name); }); }); ================================================ FILE: apps/api/src/app/organization/e2e/remove-member.e2e.ts ================================================ import { CommunityMemberRepository, EnvironmentRepository, MemberEntity } from '@novu/dal'; import { MemberRoleEnum, MemberStatusEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { describe } from 'mocha'; describe('Remove organization member - /organizations/members/:memberId (DELETE) #novu-v0-os', async () => { let session: UserSession; const memberRepository = new CommunityMemberRepository(); const environmentRepository = new EnvironmentRepository(); let user2: UserSession; let user3: UserSession; beforeEach(async () => { session = new UserSession(); await session.initialize(); user2 = new UserSession(); await user2.initialize({ noOrganization: true, }); user3 = new UserSession(); await user3.initialize({ noOrganization: true, }); await memberRepository.addMember(session.organization._id, { _userId: user2.user._id, invite: null, roles: [MemberRoleEnum.OSS_ADMIN], memberStatus: MemberStatusEnum.ACTIVE, }); await memberRepository.addMember(session.organization._id, { _userId: user3.user._id, invite: null, roles: [MemberRoleEnum.OSS_ADMIN], memberStatus: MemberStatusEnum.ACTIVE, }); user2.organization = session.organization; user3.organization = session.organization; }); it('should switch the apiKey association when api key creator removed', async () => { const members: MemberEntity[] = await getOrganizationMembers(); const originalCreator = members.find((i) => i._userId === session.user._id); await user2.fetchJWT(); expect(session.environment.apiKeys[0]._userId).to.equal(session.user._id); const { body } = await user2.testAgent.delete(`/v1/organizations/members/${originalCreator._id}`); expect(body.data._id).to.equal(originalCreator._id); const membersAfterRemoval: MemberEntity[] = await getOrganizationMembers(user2); const originalCreatorAfterRemoval = membersAfterRemoval.find((i) => i._userId === originalCreator.user._id); expect(originalCreatorAfterRemoval).to.not.be.ok; const environment = await environmentRepository.findOne({ _id: session.environment._id }); expect(environment.apiKeys[0]._userId).to.not.equal(session.user._id); }); it('should remove the member by his id', async () => { const members: MemberEntity[] = await getOrganizationMembers(); const user2Member = members.find((i) => i._userId === user2.user._id); const { body } = await session.testAgent.delete(`/v1/organizations/members/${user2Member._id}`).expect(200); expect(body.data._id).to.equal(user2Member._id); const membersAfterRemoval: MemberEntity[] = await getOrganizationMembers(); const user2Removed = membersAfterRemoval.find((i) => i._userId === user2.user._id); expect(user2Removed).to.not.be.ok; /** * The API Key owner should not be updated if non creator was removed */ const environment = await environmentRepository.findOne({ _id: session.environment._id }); expect(environment.apiKeys[0]._userId).to.equal(session.user._id); }); async function getOrganizationMembers(sessionToUser = session) { const { body } = await sessionToUser.testAgent.get('/v1/organizations/members'); return body.data; } }); ================================================ FILE: apps/api/src/app/organization/e2e/rename-organization.e2e.ts ================================================ import { UserSession } from '@novu/testing'; import { expect } from 'chai'; describe('Rename Organization - /organizations (PATCH) #novu-v0-os', () => { let session: UserSession; beforeEach(async () => { session = new UserSession(); await session.initialize(); }); it('should rename the organization', async () => { const payload = { name: 'Liberty Powers', }; await session.testAgent.patch('/v1/organizations').send(payload); const { body } = await session.testAgent.get('/v1/organizations/me').expect(200); const organization = body.data; expect(organization?.name).to.equal(payload.name); }); }); ================================================ FILE: apps/api/src/app/organization/e2e/update-branding-details.e2e.ts ================================================ import { processTestAgentExpectedStatusCode, UserSession } from '@novu/testing'; import { expect } from 'chai'; describe('Update Branding Details - /organizations/branding (PUT) #novu-v0-os', () => { let session: UserSession; beforeEach(async () => { session = new UserSession(); await session.initialize(); }); it('should update organization name only', async () => { const payload = { name: 'New Name', }; await session.testAgent.patch('/v1/organizations').send(payload).expect(processTestAgentExpectedStatusCode(200)); const { body } = await session.testAgent.get('/v1/organizations/me').expect(200); const organization = body.data; expect(organization?.name).to.equal(payload.name); expect(organization?.logo).to.equal(session.organization.logo); }); it('should update the branding details', async () => { const payload = { color: '#fefefe', fontColor: '#f4f4f4', contentBackground: '#fefefe', fontFamily: 'Nunito', logo: 'https://s3.us-east-1.amazonaws.com/novu-app-bucket/2/1/3.png', }; const result = await session.testAgent .put('/v1/organizations/branding') .send(payload) .expect(processTestAgentExpectedStatusCode(200)); const { body } = await session.testAgent.get('/v1/organizations/me').expect(200); const organization = body.data; expect(organization?.branding.color).to.equal(payload.color); expect(organization?.branding.logo).to.equal(payload.logo); expect(organization?.branding.fontColor).to.equal(payload.fontColor); expect(organization?.branding.fontFamily).to.equal(payload.fontFamily); expect(organization?.branding.contentBackground).to.equal(payload.contentBackground); }); it('logo should be an https protocol', async () => { const payload = { logo: 'http://s3.us-east-1.amazonaws.com/novu-app-bucket/2/1/3.png', }; const result = await session.testAgent.put('/v1/organizations/branding').send(payload).expect(400); }); ['png', 'jpg', 'jpeg', 'gif', 'svg'].forEach((extension) => { it(`should update if logo is a valid image URL with ${extension} extension`, async () => { const payload = { logo: `https://s3.us-east-1.amazonaws.com/novu-app-bucket/2/1/3.${extension}`, }; const result = await session.testAgent .put('/v1/organizations/branding') .send(payload) .expect(processTestAgentExpectedStatusCode(200)); }); }); ['exe', 'zip'].forEach((extension) => { it(`should fail to update if logo is a valid image URL with ${extension} extension`, async () => { const payload = { logo: `https://s3.us-east-1.amazonaws.com/novu-app-bucket/2/1/3.${extension}`, }; const result = await session.testAgent.put('/v1/organizations/branding').send(payload).expect(400); }); }); }); ================================================ FILE: apps/api/src/app/organization/e2e/update-organization-settings.e2e.ts ================================================ import { CommunityOrganizationRepository } from '@novu/dal'; import { ApiServiceLevelEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; describe('Update Organization Settings - /organizations/settings (PATCH) #novu-v2', () => { let session: UserSession; let organizationRepository: CommunityOrganizationRepository; beforeEach(async () => { session = new UserSession(); await session.initialize(); organizationRepository = new CommunityOrganizationRepository(); }); it('should allow updating removeNovuBranding for PRO tier organizations', async () => { await organizationRepository.update( { _id: session.organization._id }, { apiServiceLevel: ApiServiceLevelEnum.PRO } ); const payload = { removeNovuBranding: true }; const { body } = await session.testAgent.patch('/v1/organizations/settings').send(payload).expect(200); expect(body.data.removeNovuBranding).to.equal(true); }); it('should block branding updates for free tier organizations', async () => { await organizationRepository.update( { _id: session.organization._id }, { apiServiceLevel: ApiServiceLevelEnum.FREE } ); const payload = { removeNovuBranding: true }; const { body } = await session.testAgent.patch('/v1/organizations/settings').send(payload).expect(402); expect(body.message).to.include('Removing Novu branding is not allowed on the free plan'); }); it('should allow free tier organizations to call endpoint without branding changes', async () => { await organizationRepository.update( { _id: session.organization._id }, { apiServiceLevel: ApiServiceLevelEnum.FREE } ); const payload = {}; const { body } = await session.testAgent.patch('/v1/organizations/settings').send(payload).expect(200); expect(body.data).to.have.property('removeNovuBranding'); expect(typeof body.data.removeNovuBranding).to.equal('boolean'); }); }); ================================================ FILE: apps/api/src/app/organization/ee.organization.controller.ts ================================================ import { Body, ClassSerializerInterceptor, Controller, Get, Patch, Put, UseInterceptors } from '@nestjs/common'; import { ApiExcludeController, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ExternalApiAccessible, RequirePermissions } from '@novu/application-generic'; import { PermissionsEnum, UserSessionData } from '@novu/shared'; import { RequireAuthentication } from '../auth/framework/auth.decorator'; import { ApiCommonResponses, ApiResponse } from '../shared/framework/response.decorator'; import { UserSession } from '../shared/framework/user.decorator'; import { IGetMyOrganizationDto } from './dtos/get-my-organization.dto'; import { GetOrganizationSettingsDto } from './dtos/get-organization-settings.dto'; import { OrganizationBrandingResponseDto, OrganizationResponseDto } from './dtos/organization-response.dto'; import { RenameOrganizationDto } from './dtos/rename-organization.dto'; import { UpdateBrandingDetailsDto } from './dtos/update-branding-details.dto'; import { UpdateOrganizationSettingsDto } from './dtos/update-organization-settings.dto'; import { GetMyOrganizationCommand } from './usecases/get-my-organization/get-my-organization.command'; import { GetMyOrganization } from './usecases/get-my-organization/get-my-organization.usecase'; import { GetOrganizationSettingsCommand } from './usecases/get-organization-settings/get-organization-settings.command'; import { GetOrganizationSettings } from './usecases/get-organization-settings/get-organization-settings.usecase'; import { RenameOrganization } from './usecases/rename-organization/rename-organization.usecase'; import { RenameOrganizationCommand } from './usecases/rename-organization/rename-organization-command'; import { UpdateBrandingDetailsCommand } from './usecases/update-branding-details/update-branding-details.command'; import { UpdateBrandingDetails } from './usecases/update-branding-details/update-branding-details.usecase'; import { UpdateOrganizationSettingsCommand } from './usecases/update-organization-settings/update-organization-settings.command'; import { UpdateOrganizationSettings } from './usecases/update-organization-settings/update-organization-settings.usecase'; @Controller('/organizations') @UseInterceptors(ClassSerializerInterceptor) @RequireAuthentication() @ApiTags('Organizations') @ApiCommonResponses() @ApiExcludeController() export class EEOrganizationController { constructor( private updateBrandingDetailsUsecase: UpdateBrandingDetails, private getMyOrganizationUsecase: GetMyOrganization, private renameOrganizationUsecase: RenameOrganization, private getOrganizationSettingsUsecase: GetOrganizationSettings, private updateOrganizationSettingsUsecase: UpdateOrganizationSettings ) {} /** * @deprecated - used in v1 legacy web */ @Get('/me') @ApiResponse(OrganizationResponseDto) @ApiOperation({ summary: 'Fetch current organization details', }) async getMyOrganization(@UserSession() user: UserSessionData): Promise { const command = GetMyOrganizationCommand.create({ userId: user._id, id: user.organizationId, }); return await this.getMyOrganizationUsecase.execute(command); } /** * @deprecated - used in v1 legacy web */ @Put('/branding') @ExternalApiAccessible() @ApiResponse(OrganizationBrandingResponseDto) @ApiOperation({ summary: 'Update organization branding details', }) async updateBrandingDetails(@UserSession() user: UserSessionData, @Body() body: UpdateBrandingDetailsDto) { return await this.updateBrandingDetailsUsecase.execute( UpdateBrandingDetailsCommand.create({ logo: body.logo, color: body.color, userId: user._id, id: user.organizationId, fontColor: body.fontColor, fontFamily: body.fontFamily, contentBackground: body.contentBackground, }) ); } /** * @deprecated - used in v1 legacy web */ @Patch('/') @ExternalApiAccessible() @ApiResponse(RenameOrganizationDto) @ApiOperation({ summary: 'Rename organization name', }) async renameOrganization(@UserSession() user: UserSessionData, @Body() body: RenameOrganizationDto) { return await this.renameOrganizationUsecase.execute( RenameOrganizationCommand.create({ name: body.name, userId: user._id, id: user.organizationId, }) ); } @Get('/settings') @ExternalApiAccessible() @ApiResponse(GetOrganizationSettingsDto) @ApiOperation({ summary: 'Get organization settings', }) @RequirePermissions(PermissionsEnum.ORG_SETTINGS_READ) async getSettings(@UserSession() user: UserSessionData) { return await this.getOrganizationSettingsUsecase.execute( GetOrganizationSettingsCommand.create({ organizationId: user.organizationId, }) ); } @Patch('/settings') @ApiResponse(UpdateOrganizationSettingsDto) @ExternalApiAccessible() @ApiOperation({ summary: 'Update organization settings', }) @RequirePermissions(PermissionsEnum.ORG_SETTINGS_WRITE) async updateSettings(@UserSession() user: UserSessionData, @Body() body: UpdateOrganizationSettingsDto) { return await this.updateOrganizationSettingsUsecase.execute( UpdateOrganizationSettingsCommand.create({ userId: user._id, organizationId: user.organizationId, removeNovuBranding: body.removeNovuBranding, defaultLocale: body.defaultLocale, targetLocales: body.targetLocales, }) ); } } ================================================ FILE: apps/api/src/app/organization/organization.controller.ts ================================================ import { Body, ClassSerializerInterceptor, Controller, Delete, Get, Param, Patch, Post, Put, UseInterceptors, } from '@nestjs/common'; import { ApiExcludeEndpoint, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger'; import { ApiExcludeController } from '@nestjs/swagger/dist/decorators/api-exclude-controller.decorator'; import { OrganizationEntity } from '@novu/dal'; import { MemberRoleEnum, UserSessionData } from '@novu/shared'; import { RequireAuthentication } from '../auth/framework/auth.decorator'; import { ExternalApiAccessible } from '../auth/framework/external-api.decorator'; import { ApiCommonResponses, ApiResponse } from '../shared/framework/response.decorator'; import { UserSession } from '../shared/framework/user.decorator'; import { CreateOrganizationDto } from './dtos/create-organization.dto'; import { IGetMyOrganizationDto } from './dtos/get-my-organization.dto'; import { IGetOrganizationsDto } from './dtos/get-organizations.dto'; import { MemberResponseDto } from './dtos/member-response.dto'; import { OrganizationBrandingResponseDto, OrganizationResponseDto } from './dtos/organization-response.dto'; import { RenameOrganizationDto } from './dtos/rename-organization.dto'; import { UpdateBrandingDetailsDto } from './dtos/update-branding-details.dto'; import { UpdateMemberRolesDto } from './dtos/update-member-roles.dto'; import { CreateOrganizationCommand } from './usecases/create-organization/create-organization.command'; import { CreateOrganization } from './usecases/create-organization/create-organization.usecase'; import { GetMyOrganizationCommand } from './usecases/get-my-organization/get-my-organization.command'; import { GetMyOrganization } from './usecases/get-my-organization/get-my-organization.usecase'; import { GetOrganizationsCommand } from './usecases/get-organizations/get-organizations.command'; import { GetOrganizations } from './usecases/get-organizations/get-organizations.usecase'; import { ChangeMemberRoleCommand } from './usecases/membership/change-member-role/change-member-role.command'; import { ChangeMemberRole } from './usecases/membership/change-member-role/change-member-role.usecase'; import { GetMembersCommand } from './usecases/membership/get-members/get-members.command'; import { GetMembers } from './usecases/membership/get-members/get-members.usecase'; import { RemoveMemberCommand } from './usecases/membership/remove-member/remove-member.command'; import { RemoveMember } from './usecases/membership/remove-member/remove-member.usecase'; import { RenameOrganization } from './usecases/rename-organization/rename-organization.usecase'; import { RenameOrganizationCommand } from './usecases/rename-organization/rename-organization-command'; import { UpdateBrandingDetailsCommand } from './usecases/update-branding-details/update-branding-details.command'; import { UpdateBrandingDetails } from './usecases/update-branding-details/update-branding-details.usecase'; @Controller('/organizations') @UseInterceptors(ClassSerializerInterceptor) @RequireAuthentication() @ApiTags('Organizations') @ApiCommonResponses() @ApiExcludeController() export class OrganizationController { constructor( private createOrganizationUsecase: CreateOrganization, private getMembers: GetMembers, private removeMemberUsecase: RemoveMember, private changeMemberRoleUsecase: ChangeMemberRole, private updateBrandingDetailsUsecase: UpdateBrandingDetails, private getOrganizationsUsecase: GetOrganizations, private getMyOrganizationUsecase: GetMyOrganization, private renameOrganizationUsecase: RenameOrganization ) {} @Post('/') @ExternalApiAccessible() @ApiResponse(OrganizationResponseDto, 201) @ApiOperation({ summary: 'Create an organization', }) async createOrganization( @UserSession() user: UserSessionData, @Body() body: CreateOrganizationDto ): Promise { return await this.createOrganizationUsecase.execute( CreateOrganizationCommand.create({ userId: user._id, logo: body.logo, name: body.name, jobTitle: body.jobTitle, domain: body.domain, language: body.language, }) ); } @Get('/') @ExternalApiAccessible() @ApiResponse(OrganizationResponseDto, 200, true) @ApiOperation({ summary: 'Fetch all organizations', }) async listOrganizations(@UserSession() user: UserSessionData): Promise { const command = GetOrganizationsCommand.create({ userId: user._id, }); return await this.getOrganizationsUsecase.execute(command); } @Get('/me') @ExternalApiAccessible() @ApiResponse(OrganizationResponseDto) @ApiOperation({ summary: 'Fetch current organization details', }) async getSelfOrganizationData(@UserSession() user: UserSessionData): Promise { const command = GetMyOrganizationCommand.create({ userId: user._id, id: user.organizationId, }); return await this.getMyOrganizationUsecase.execute(command); } @Delete('/members/:memberId') @ExternalApiAccessible() @ApiResponse(MemberResponseDto) @ApiOperation({ summary: 'Remove a member from organization using memberId', }) @ApiParam({ name: 'memberId', type: String, required: true }) async remove(@UserSession() user: UserSessionData, @Param('memberId') memberId: string) { return await this.removeMemberUsecase.execute( RemoveMemberCommand.create({ userId: user._id, organizationId: user.organizationId, memberId, }) ); } @Put('/members/:memberId/roles') @ExternalApiAccessible() @ApiExcludeEndpoint() @ApiResponse(MemberResponseDto) @ApiOperation({ summary: 'Update a member role to admin', }) @ApiParam({ name: 'memberId', type: String, required: true }) async updateMemberRoles( @UserSession() user: UserSessionData, @Param('memberId') memberId: string, @Body() body: UpdateMemberRolesDto ) { if (body.role !== MemberRoleEnum.OSS_ADMIN) { throw new Error('Only admin role can be assigned to a member'); } return await this.changeMemberRoleUsecase.execute( ChangeMemberRoleCommand.create({ memberId, role: MemberRoleEnum.OSS_ADMIN, userId: user._id, organizationId: user.organizationId, }) ); } @Get('/members') @ExternalApiAccessible() @ApiResponse(MemberResponseDto, 200, true) @ApiOperation({ summary: 'Fetch all members of current organizations', }) async listOrganizationMembers(@UserSession() user: UserSessionData) { return await this.getMembers.execute( GetMembersCommand.create({ user, userId: user._id, organizationId: user.organizationId, }) ); } @Put('/branding') @ExternalApiAccessible() @ApiResponse(OrganizationBrandingResponseDto) @ApiOperation({ summary: 'Update organization branding details', }) async updateBrandingDetails(@UserSession() user: UserSessionData, @Body() body: UpdateBrandingDetailsDto) { return await this.updateBrandingDetailsUsecase.execute( UpdateBrandingDetailsCommand.create({ logo: body.logo, color: body.color, userId: user._id, id: user.organizationId, fontColor: body.fontColor, fontFamily: body.fontFamily, contentBackground: body.contentBackground, }) ); } @Patch('/') @ExternalApiAccessible() @ApiResponse(RenameOrganizationDto) @ApiOperation({ summary: 'Rename organization name', }) async rename(@UserSession() user: UserSessionData, @Body() body: RenameOrganizationDto) { return await this.renameOrganizationUsecase.execute( RenameOrganizationCommand.create({ name: body.name, userId: user._id, id: user.organizationId, }) ); } } ================================================ FILE: apps/api/src/app/organization/organization.module.ts ================================================ import { DynamicModule, ForwardReference, forwardRef, MiddlewareConsumer, Module, NestModule, RequestMethod, } from '@nestjs/common'; import { Type } from '@nestjs/common/interfaces/type.interface'; import { AuthGuard } from '@nestjs/passport'; import { isBetterAuthEnabled, isClerkEnabled } from '@novu/shared'; import { AuthModule } from '../auth/auth.module'; import { EnvironmentsModuleV1 } from '../environments-v1/environments-v1.module'; import { IntegrationModule } from '../integrations/integrations.module'; import { LayoutsV2Module } from '../layouts-v2/layouts.module'; import { SharedModule } from '../shared/shared.module'; import { UserModule } from '../user/user.module'; import { EEOrganizationController } from './ee.organization.controller'; import { OrganizationController } from './organization.controller'; import { USE_CASES } from './usecases'; const enterpriseImports = (): Array | ForwardReference> => { const modules: Array | ForwardReference> = []; if (process.env.NOVU_ENTERPRISE === 'true' || process.env.CI_EE_TEST === 'true') { if (require('@novu/ee-billing')?.BillingModule) { modules.push(require('@novu/ee-billing')?.BillingModule.forRoot()); } } return modules; }; function getControllers() { if (isClerkEnabled() || isBetterAuthEnabled()) { return [EEOrganizationController]; } return [OrganizationController]; } @Module({ imports: [ SharedModule, UserModule, EnvironmentsModuleV1, IntegrationModule, forwardRef(() => AuthModule), LayoutsV2Module, ...enterpriseImports(), ], controllers: [...getControllers()], providers: [...USE_CASES], exports: [...USE_CASES], }) export class OrganizationModule implements NestModule { configure(consumer: MiddlewareConsumer): MiddlewareConsumer | void { if (process.env.NOVU_ENTERPRISE !== 'true' && process.env.CI_EE_TEST !== 'true') { consumer.apply(AuthGuard).exclude({ method: RequestMethod.GET, path: '/organizations/invite/:inviteToken', }); } } } ================================================ FILE: apps/api/src/app/organization/usecases/create-organization/create-organization.command.ts ================================================ import { ApiServiceLevelEnum, JobTitleEnum } from '@novu/shared'; import { IsDefined, IsEnum, IsOptional, IsString } from 'class-validator'; import { AuthenticatedCommand } from '../../../shared/commands/authenticated.command'; export class CreateOrganizationCommand extends AuthenticatedCommand { @IsString() @IsDefined() public readonly name: string; @IsString() @IsOptional() public readonly logo?: string; @IsOptional() @IsEnum(JobTitleEnum) jobTitle?: JobTitleEnum; @IsString() @IsOptional() domain?: string; @IsOptional() language?: string[]; @IsOptional() @IsEnum(ApiServiceLevelEnum) apiServiceLevel?: ApiServiceLevelEnum; } ================================================ FILE: apps/api/src/app/organization/usecases/create-organization/create-organization.usecase.ts ================================================ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { AnalyticsService } from '@novu/application-generic'; import { OrganizationEntity, OrganizationRepository, UserRepository } from '@novu/dal'; import { ApiServiceLevelEnum, EnvironmentEnum, JobTitleEnum, MemberRoleEnum } from '@novu/shared'; import { CreateEnvironmentCommand } from '../../../environments-v1/usecases/create-environment/create-environment.command'; import { CreateEnvironment } from '../../../environments-v1/usecases/create-environment/create-environment.usecase'; import { GetOrganizationCommand } from '../get-organization/get-organization.command'; import { GetOrganization } from '../get-organization/get-organization.usecase'; import { AddMemberCommand } from '../membership/add-member/add-member.command'; import { AddMember } from '../membership/add-member/add-member.usecase'; import { CreateOrganizationCommand } from './create-organization.command'; @Injectable() export class CreateOrganization { constructor( private readonly organizationRepository: OrganizationRepository, private readonly addMemberUsecase: AddMember, private readonly getOrganizationUsecase: GetOrganization, private readonly userRepository: UserRepository, private readonly createEnvironmentUsecase: CreateEnvironment, private analyticsService: AnalyticsService ) {} async execute(command: CreateOrganizationCommand): Promise { const user = await this.userRepository.findById(command.userId); if (!user) throw new BadRequestException('User not found'); const isSelfHosted = process.env.IS_SELF_HOSTED === 'true'; const isEnterprise = process.env.NOVU_ENTERPRISE === 'true' || process.env.CI_EE_TEST === 'true'; const defaultApiServiceLevel = isSelfHosted && isEnterprise ? ApiServiceLevelEnum.UNLIMITED : ApiServiceLevelEnum.FREE; const createdOrganization = await this.organizationRepository.create({ logo: command.logo, name: command.name, apiServiceLevel: command.apiServiceLevel || defaultApiServiceLevel, domain: command.domain, language: command.language, }); if (command.jobTitle) { await this.updateJobTitle(user, command.jobTitle); } await this.addMemberUsecase.execute( AddMemberCommand.create({ roles: [MemberRoleEnum.OSS_ADMIN], organizationId: createdOrganization._id, userId: command.userId, }) ); const devEnv = await this.createEnvironmentUsecase.execute( CreateEnvironmentCommand.create({ userId: user._id, name: EnvironmentEnum.DEVELOPMENT, organizationId: createdOrganization._id, system: true, }) ); await this.createEnvironmentUsecase.execute( CreateEnvironmentCommand.create({ userId: user._id, name: EnvironmentEnum.PRODUCTION, organizationId: createdOrganization._id, parentEnvironmentId: devEnv._id, system: true, }) ); this.analyticsService.upsertGroup(createdOrganization._id, createdOrganization, user); this.analyticsService.track('[Authentication] - Create Organization', user._id, { _organization: createdOrganization._id, language: command.language, creatorJobTitle: command.jobTitle, }); const organizationAfterChanges = await this.getOrganizationUsecase.execute( GetOrganizationCommand.create({ id: createdOrganization._id, userId: command.userId, }) ); return organizationAfterChanges as OrganizationEntity; } private async updateJobTitle(user, jobTitle: JobTitleEnum) { await this.userRepository.update( { _id: user._id, }, { $set: { jobTitle, }, } ); this.analyticsService.setValue(user._id, 'jobTitle', jobTitle); } } ================================================ FILE: apps/api/src/app/organization/usecases/create-organization/sync-external-organization/sync-external-organization.command.ts ================================================ import { AuthenticatedCommand } from '@novu/application-generic'; import { IsDefined, IsEnum, IsOptional, IsString } from 'class-validator'; export class SyncExternalOrganizationCommand extends AuthenticatedCommand { @IsDefined() @IsString() externalId: string; @IsDefined() @IsString() email: string; @IsOptional() headers: Record; } ================================================ FILE: apps/api/src/app/organization/usecases/create-organization/sync-external-organization/sync-external-organization.usecase.ts ================================================ import { BadRequestException, Injectable } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import { AnalyticsService, PinoLogger } from '@novu/application-generic'; import { OrganizationEntity, OrganizationRepository, UserRepository } from '@novu/dal'; import { CreateEnvironmentCommand } from '../../../../environments-v1/usecases/create-environment/create-environment.command'; import { CreateEnvironment } from '../../../../environments-v1/usecases/create-environment/create-environment.usecase'; import { CreateNovuIntegrationsCommand } from '../../../../integrations/usecases/create-novu-integrations/create-novu-integrations.command'; import { CreateNovuIntegrations } from '../../../../integrations/usecases/create-novu-integrations/create-novu-integrations.usecase'; import { UpsertLayout, UpsertLayoutCommand } from '../../../../layouts-v2/usecases/upsert-layout'; import { createDefaultLayout } from '../../../../layouts-v2/utils/layout-templates'; import { GetOrganizationCommand } from '../../get-organization/get-organization.command'; import { GetOrganization } from '../../get-organization/get-organization.usecase'; import { SyncExternalOrganizationCommand } from './sync-external-organization.command'; // TODO: eventually move to @novu/ee-auth /** * This logic is closely related to the CreateOrganization use case. * @see src/app/organization/usecases/create-organization/create-organization.usecase.ts * * The side effects of creating a new organization are largely * consistent with those in CreateOrganization, with only minor differences. */ @Injectable() export class SyncExternalOrganization { constructor( private readonly organizationRepository: OrganizationRepository, private readonly getOrganizationUsecase: GetOrganization, private readonly createEnvironmentUsecase: CreateEnvironment, private readonly createNovuIntegrations: CreateNovuIntegrations, private readonly upsertLayoutUsecase: UpsertLayout, private analyticsService: AnalyticsService, private moduleRef: ModuleRef, private logger: PinoLogger ) { this.logger.setContext(this.constructor.name); } async execute(command: SyncExternalOrganizationCommand): Promise { const isSelfHosted = process.env.IS_SELF_HOSTED === 'true'; const isEnterprise = process.env.NOVU_ENTERPRISE === 'true' || process.env.CI_EE_TEST === 'true'; const organization = await this.organizationRepository.create( { externalId: command.externalId, apiServiceLevel: isSelfHosted && isEnterprise ? 'unlimited' : undefined, }, { headers: command.headers } ); const devEnv = await this.createEnvironmentUsecase.execute( CreateEnvironmentCommand.create({ userId: command.userId, name: 'Development', organizationId: organization._id, system: true, }) ); await this.createNovuIntegrations.execute( CreateNovuIntegrationsCommand.create({ environmentId: devEnv._id, organizationId: devEnv._organizationId, userId: command.userId, name: devEnv.name, }) ); await this.upsertLayoutUsecase.execute( UpsertLayoutCommand.create({ environmentId: devEnv._id, organizationId: devEnv._organizationId, userId: command.userId, layoutDto: { name: 'Default layout', controlValues: { email: { body: JSON.stringify(createDefaultLayout(organization.name)), editorType: 'block', }, }, }, }) ); const prodEnv = await this.createEnvironmentUsecase.execute( CreateEnvironmentCommand.create({ userId: command.userId, name: 'Production', organizationId: organization._id, parentEnvironmentId: devEnv._id, system: true, }) ); await this.createNovuIntegrations.execute( CreateNovuIntegrationsCommand.create({ environmentId: prodEnv._id, organizationId: prodEnv._organizationId, userId: command.userId, name: prodEnv.name, }) ); await this.upsertLayoutUsecase.execute( UpsertLayoutCommand.create({ environmentId: prodEnv._id, organizationId: prodEnv._organizationId, userId: command.userId, layoutDto: { name: 'Default layout', controlValues: { email: { body: JSON.stringify(createDefaultLayout(organization.name)), editorType: 'block', }, }, }, }) ); this.analyticsService.upsertGroup(organization._id, organization, { _id: command.userId }); this.analyticsService.track('[Authentication] - Create Organization', command.userId, { _organization: organization._id, }); const organizationAfterChanges = await this.getOrganizationUsecase.execute( GetOrganizationCommand.create({ id: organization._id, userId: command.userId, }) ); if (organizationAfterChanges !== null) { await this.createCustomer(command.email, organizationAfterChanges._id); } return organizationAfterChanges as OrganizationEntity; } private async createCustomer(billingEmail: string, organizationId: string) { try { if (process.env.NOVU_ENTERPRISE === 'true' || process.env.CI_EE_TEST === 'true') { if (!require('@novu/ee-billing')?.GetOrCreateCustomer) { throw new BadRequestException('Billing module is not loaded'); } const usecase = this.moduleRef.get(require('@novu/ee-billing')?.GetOrCreateCustomer, { strict: false, }); await usecase.execute({ organizationId, billingEmail, }); } } catch (e) { this.logger.error({ err: e }, `Unexpected error while importing enterprise modules`); } } } ================================================ FILE: apps/api/src/app/organization/usecases/get-my-organization/get-my-organization.command.ts ================================================ import { IsDefined } from 'class-validator'; import { AuthenticatedCommand } from '../../../shared/commands/authenticated.command'; export class GetMyOrganizationCommand extends AuthenticatedCommand { @IsDefined() public readonly id: string; } ================================================ FILE: apps/api/src/app/organization/usecases/get-my-organization/get-my-organization.usecase.ts ================================================ import { Injectable, Scope, UnauthorizedException } from '@nestjs/common'; import { GetOrganizationCommand } from '../get-organization/get-organization.command'; import { GetOrganization } from '../get-organization/get-organization.usecase'; import { GetMyOrganizationCommand } from './get-my-organization.command'; @Injectable({ scope: Scope.REQUEST, }) export class GetMyOrganization { constructor(private getOrganizationUseCase: GetOrganization) {} async execute(command: GetMyOrganizationCommand) { const organization = await this.getOrganizationUseCase.execute( GetOrganizationCommand.create({ id: command.id, userId: command.userId, }) ); if (!organization) throw new UnauthorizedException('No organization found'); return organization; } } ================================================ FILE: apps/api/src/app/organization/usecases/get-organization/get-organization.command.ts ================================================ import { AuthenticatedCommand } from '../../../shared/commands/authenticated.command'; export class GetOrganizationCommand extends AuthenticatedCommand { public readonly id: string; } ================================================ FILE: apps/api/src/app/organization/usecases/get-organization/get-organization.usecase.ts ================================================ import { Injectable, Scope } from '@nestjs/common'; import { OrganizationRepository } from '@novu/dal'; import { GetOrganizationCommand } from './get-organization.command'; @Injectable() export class GetOrganization { constructor(private readonly organizationRepository: OrganizationRepository) {} async execute(command: GetOrganizationCommand) { return await this.organizationRepository.findById(command.id); } } ================================================ FILE: apps/api/src/app/organization/usecases/get-organization-settings/get-organization-settings.command.ts ================================================ import { BaseCommand } from '@novu/application-generic'; import { OrganizationEntity } from '@novu/dal'; import { IsNotEmpty, IsOptional } from 'class-validator'; export class GetOrganizationSettingsCommand extends BaseCommand { @IsNotEmpty() readonly organizationId: string; @IsOptional() readonly organization?: OrganizationEntity; } ================================================ FILE: apps/api/src/app/organization/usecases/get-organization-settings/get-organization-settings.usecase.ts ================================================ import { Injectable, NotFoundException } from '@nestjs/common'; import { CommunityOrganizationRepository } from '@novu/dal'; import { DEFAULT_LOCALE } from '@novu/shared'; import { GetOrganizationSettingsDto } from '../../dtos/get-organization-settings.dto'; import { GetOrganizationSettingsCommand } from './get-organization-settings.command'; @Injectable() export class GetOrganizationSettings { constructor(private organizationRepository: CommunityOrganizationRepository) {} async execute(command: GetOrganizationSettingsCommand): Promise { const organization = command.organization ?? (await this.organizationRepository.findById(command.organizationId)); if (!organization) { throw new NotFoundException('Organization not found'); } return { removeNovuBranding: organization.removeNovuBranding || false, defaultLocale: organization.defaultLocale || DEFAULT_LOCALE, targetLocales: organization.targetLocales || [], }; } } ================================================ FILE: apps/api/src/app/organization/usecases/get-organizations/get-organizations.command.ts ================================================ import { AuthenticatedCommand } from '../../../shared/commands/authenticated.command'; export class GetOrganizationsCommand extends AuthenticatedCommand {} ================================================ FILE: apps/api/src/app/organization/usecases/get-organizations/get-organizations.usecase.ts ================================================ import { Injectable, Scope } from '@nestjs/common'; import { OrganizationRepository } from '@novu/dal'; import { GetOrganizationsCommand } from './get-organizations.command'; @Injectable({ scope: Scope.REQUEST, }) export class GetOrganizations { constructor(private readonly organizationRepository: OrganizationRepository) {} async execute(command: GetOrganizationsCommand) { return await this.organizationRepository.findUserActiveOrganizations(command.userId); } } ================================================ FILE: apps/api/src/app/organization/usecases/index.ts ================================================ import { isBetterAuthEnabled, isClerkEnabled } from '@novu/shared'; import { CreateOrganization } from './create-organization/create-organization.usecase'; import { SyncExternalOrganization } from './create-organization/sync-external-organization/sync-external-organization.usecase'; import { GetMyOrganization } from './get-my-organization/get-my-organization.usecase'; import { GetOrganization } from './get-organization/get-organization.usecase'; import { GetOrganizationSettings } from './get-organization-settings/get-organization-settings.usecase'; import { GetOrganizations } from './get-organizations/get-organizations.usecase'; import { AddMember } from './membership/add-member/add-member.usecase'; import { ChangeMemberRole } from './membership/change-member-role/change-member-role.usecase'; import { GetMembers } from './membership/get-members/get-members.usecase'; import { RemoveMember } from './membership/remove-member/remove-member.usecase'; import { RenameOrganization } from './rename-organization/rename-organization.usecase'; import { UpdateBrandingDetails } from './update-branding-details/update-branding-details.usecase'; import { UpdateOrganizationSettings } from './update-organization-settings/update-organization-settings.usecase'; // TODO: move ee.organization.controller.ts to EE package function getEnterpriseUsecases() { if (isClerkEnabled() || isBetterAuthEnabled()) { return [ { provide: 'SyncOrganizationUsecase', useClass: SyncExternalOrganization, }, ]; } return []; } export const USE_CASES = [ AddMember, CreateOrganization, GetOrganization, GetMembers, RemoveMember, ChangeMemberRole, UpdateBrandingDetails, GetOrganizations, GetMyOrganization, RenameOrganization, GetOrganizationSettings, UpdateOrganizationSettings, ...getEnterpriseUsecases(), ]; ================================================ FILE: apps/api/src/app/organization/usecases/membership/add-member/add-member.command.ts ================================================ import { MemberRoleEnum } from '@novu/shared'; import { ArrayNotEmpty } from 'class-validator'; import { OrganizationCommand } from '../../../../shared/commands/organization.command'; export class AddMemberCommand extends OrganizationCommand { @ArrayNotEmpty() public readonly roles: MemberRoleEnum[]; } ================================================ FILE: apps/api/src/app/organization/usecases/membership/add-member/add-member.usecase.ts ================================================ import { BadRequestException, Injectable } from '@nestjs/common'; import { MemberRepository } from '@novu/dal'; import { MemberStatusEnum } from '@novu/shared'; import { AddMemberCommand } from './add-member.command'; @Injectable() export class AddMember { constructor(private readonly memberRepository: MemberRepository) {} async execute(command: AddMemberCommand): Promise { const isAlreadyMember = await this.isMember(command.organizationId, command.userId); if (isAlreadyMember) throw new BadRequestException('Member already exists'); await this.memberRepository.addMember(command.organizationId, { _userId: command.userId, roles: command.roles, memberStatus: MemberStatusEnum.ACTIVE, }); } private async isMember(organizationId: string, userId: string): Promise { return !!(await this.memberRepository.findMemberByUserId(organizationId, userId)); } } ================================================ FILE: apps/api/src/app/organization/usecases/membership/change-member-role/change-member-role.command.ts ================================================ import { MemberRoleEnum } from '@novu/shared'; import { IsDefined, IsEnum, IsMongoId } from 'class-validator'; import { OrganizationCommand } from '../../../../shared/commands/organization.command'; export class ChangeMemberRoleCommand extends OrganizationCommand { @IsDefined() role: MemberRoleEnum.OSS_ADMIN; @IsDefined() @IsMongoId() memberId: string; } ================================================ FILE: apps/api/src/app/organization/usecases/membership/change-member-role/change-member-role.usecase.ts ================================================ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { MemberRepository, OrganizationRepository } from '@novu/dal'; import { MemberRoleEnum } from '@novu/shared'; import { ChangeMemberRoleCommand } from './change-member-role.command'; @Injectable() export class ChangeMemberRole { constructor( private organizationRepository: OrganizationRepository, private memberRepository: MemberRepository ) {} async execute(command: ChangeMemberRoleCommand) { if (![MemberRoleEnum.OSS_MEMBER, MemberRoleEnum.OSS_ADMIN].includes(command.role)) { throw new BadRequestException('Not supported role type'); } if (command.role !== MemberRoleEnum.OSS_ADMIN) { throw new BadRequestException(`The change of role to an ${command.role} type is not supported`); } const organization = await this.organizationRepository.findById(command.organizationId); if (!organization) throw new NotFoundException('No organization was found'); const member = await this.memberRepository.findMemberById(organization._id, command.memberId); if (!member) throw new NotFoundException('No member was found'); const roles = [command.role]; await this.memberRepository.updateMemberRoles(organization._id, command.memberId, roles); return this.memberRepository.findMemberByUserId(organization._id, member._userId); } } ================================================ FILE: apps/api/src/app/organization/usecases/membership/get-members/get-members.command.ts ================================================ import { UserSessionData } from '@novu/shared'; import { IsDefined } from 'class-validator'; import { OrganizationCommand } from '../../../../shared/commands/organization.command'; export class GetMembersCommand extends OrganizationCommand { @IsDefined() user: UserSessionData; } ================================================ FILE: apps/api/src/app/organization/usecases/membership/get-members/get-members.usecase.ts ================================================ import { Injectable, Scope } from '@nestjs/common'; import { MemberRepository } from '@novu/dal'; import { MemberRoleEnum, MemberStatusEnum } from '@novu/shared'; import { GetMembersCommand } from './get-members.command'; @Injectable({ scope: Scope.REQUEST, }) export class GetMembers { constructor(private membersRepository: MemberRepository) {} async execute(command: GetMembersCommand) { return (await this.membersRepository.getOrganizationMembers(command.organizationId)) .map((member) => { if (!command.user.roles.includes(MemberRoleEnum.OSS_ADMIN)) { if (member.memberStatus === MemberStatusEnum.INVITED) return null; if (member.user) member.user.email = ''; if (member.invite) member.invite.email = ''; } return member; }) .filter((member) => !!member); } } ================================================ FILE: apps/api/src/app/organization/usecases/membership/remove-member/remove-member.command.ts ================================================ import { IsMongoId, IsString } from 'class-validator'; import { OrganizationCommand } from '../../../../shared/commands/organization.command'; export class RemoveMemberCommand extends OrganizationCommand { @IsString() @IsMongoId() memberId: string; } ================================================ FILE: apps/api/src/app/organization/usecases/membership/remove-member/remove-member.usecase.ts ================================================ import { BadRequestException, Injectable, NotFoundException, Scope } from '@nestjs/common'; import { EnvironmentRepository, MemberRepository } from '@novu/dal'; import { RemoveMemberCommand } from './remove-member.command'; @Injectable({ scope: Scope.REQUEST, }) export class RemoveMember { constructor( private memberRepository: MemberRepository, private environmentRepository: EnvironmentRepository ) {} async execute(command: RemoveMemberCommand) { const members = await this.memberRepository.getOrganizationMembers(command.organizationId); const memberToRemove = members.find((i) => i._id === command.memberId); if (!memberToRemove) throw new NotFoundException('Member not found'); if (memberToRemove._userId && memberToRemove._userId && memberToRemove._userId === command.userId) { throw new BadRequestException('Cannot remove self from members'); } await this.memberRepository.removeMemberById(command.organizationId, memberToRemove._id); const environments = await this.environmentRepository.findOrganizationEnvironments(command.organizationId); const isMemberAssociatedWithEnvironment = environments.some((i) => i.apiKeys.some((key) => key._userId === memberToRemove._userId) ); if (isMemberAssociatedWithEnvironment) { const owner = await this.memberRepository.getOrganizationOwnerAccount(command.organizationId); if (!owner) throw new NotFoundException('No owner account found for organization'); await this.environmentRepository.updateApiKeyUserId( command.organizationId, memberToRemove._userId, owner._userId ); } return memberToRemove; } } ================================================ FILE: apps/api/src/app/organization/usecases/rename-organization/rename-organization-command.ts ================================================ import { IsDefined, IsNotEmpty } from 'class-validator'; import { AuthenticatedCommand } from '../../../shared/commands/authenticated.command'; export class RenameOrganizationCommand extends AuthenticatedCommand { @IsDefined() public readonly id: string; @IsDefined() @IsNotEmpty() name: string; } ================================================ FILE: apps/api/src/app/organization/usecases/rename-organization/rename-organization.usecase.ts ================================================ import { Injectable } from '@nestjs/common'; import { OrganizationRepository } from '@novu/dal'; import { RenameOrganizationCommand } from './rename-organization-command'; @Injectable() export class RenameOrganization { constructor(private organizationRepository: OrganizationRepository) {} async execute(command: RenameOrganizationCommand) { const payload = { name: command.name, }; await this.organizationRepository.renameOrganization(command.id, payload); return payload; } } ================================================ FILE: apps/api/src/app/organization/usecases/update-branding-details/update-branding-details.command.ts ================================================ import { IsDefined, IsHexColor, IsOptional, IsUrl } from 'class-validator'; import { AuthenticatedCommand } from '../../../shared/commands/authenticated.command'; export class UpdateBrandingDetailsCommand extends AuthenticatedCommand { @IsDefined() public readonly id: string; @IsUrl({ require_tld: false }) @IsOptional() logo: string; @IsOptional() @IsHexColor() color: string; @IsOptional() @IsHexColor() fontColor: string; @IsOptional() @IsHexColor() contentBackground: string; @IsOptional() fontFamily?: string; } ================================================ FILE: apps/api/src/app/organization/usecases/update-branding-details/update-branding-details.usecase.ts ================================================ import { Injectable } from '@nestjs/common'; import { OrganizationRepository } from '@novu/dal'; import { UpdateBrandingDetailsCommand } from './update-branding-details.command'; @Injectable() export class UpdateBrandingDetails { constructor(private organizationRepository: OrganizationRepository) {} async execute(command: UpdateBrandingDetailsCommand) { const payload = { color: command.color, logo: command.logo, fontColor: command.fontColor, contentBackground: command.contentBackground, fontFamily: command.fontFamily, }; await this.organizationRepository.updateBrandingDetails(command.id, payload); return payload; } } ================================================ FILE: apps/api/src/app/organization/usecases/update-organization-settings/update-organization-settings.command.ts ================================================ import { AuthenticatedCommand, IsValidLocale } from '@novu/application-generic'; import { IsArray, IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator'; export class UpdateOrganizationSettingsCommand extends AuthenticatedCommand { @IsNotEmpty() readonly organizationId: string; @IsOptional() @IsBoolean() removeNovuBranding?: boolean; @IsOptional() @IsValidLocale() defaultLocale?: string; @IsOptional() @IsArray() @IsString({ each: true }) targetLocales?: string[]; } ================================================ FILE: apps/api/src/app/organization/usecases/update-organization-settings/update-organization-settings.usecase.ts ================================================ import { HttpException, HttpStatus, Injectable, NotFoundException } from '@nestjs/common'; import { AnalyticsService } from '@novu/application-generic'; import { CommunityOrganizationRepository, OrganizationEntity } from '@novu/dal'; import { ApiServiceLevelEnum, DEFAULT_LOCALE, FeatureNameEnum, getFeatureForTierAsBoolean } from '@novu/shared'; import { GetOrganizationSettingsDto } from '../../dtos/get-organization-settings.dto'; import { UpdateOrganizationSettingsCommand } from './update-organization-settings.command'; @Injectable() export class UpdateOrganizationSettings { constructor( private organizationRepository: CommunityOrganizationRepository, private analyticsService: AnalyticsService ) {} async execute(command: UpdateOrganizationSettingsCommand): Promise { const organization = await this.organizationRepository.findById(command.organizationId); if (!organization) { throw new NotFoundException('Organization not found'); } this.validateTierRestrictions(command, organization); const updateFields = this.buildUpdateFields(command); if (Object.keys(updateFields).length === 0) { return this.buildSettingsResponse(organization); } await this.organizationRepository.updateOne({ _id: organization._id }, { $set: updateFields }); if (command.removeNovuBranding !== undefined) { this.analyticsService.mixpanelTrack('Remove Branding', command.userId, { _organization: command.organizationId, newStatus: command.removeNovuBranding, }); } return this.buildSettingsResponse({ ...organization, ...updateFields, }); } private validateTierRestrictions(command: UpdateOrganizationSettingsCommand, organization: OrganizationEntity): void { // Only validate branding feature access if user is trying to update it if (command.removeNovuBranding !== undefined) { const canRemoveNovuBranding = getFeatureForTierAsBoolean( FeatureNameEnum.PLATFORM_REMOVE_NOVU_BRANDING_BOOLEAN, organization.apiServiceLevel || ApiServiceLevelEnum.FREE ); if (!canRemoveNovuBranding) { throw new HttpException( { error: 'Payment Required', message: 'Removing Novu branding is not allowed on the free plan. Please upgrade to a paid plan to access this feature.', }, HttpStatus.PAYMENT_REQUIRED ); } } if (command.targetLocales !== undefined || command.defaultLocale !== undefined) { const canUseTranslations = getFeatureForTierAsBoolean( FeatureNameEnum.AUTO_TRANSLATIONS, organization.apiServiceLevel || ApiServiceLevelEnum.FREE ); if (!canUseTranslations) { throw new HttpException( { error: 'Payment Required', message: 'Update of locales is a part of the translation feature. Please upgrade to a paid plan to access this feature.', }, HttpStatus.PAYMENT_REQUIRED ); } } } private buildUpdateFields(command: UpdateOrganizationSettingsCommand): Partial { const updateFields: Partial = {}; if (command.removeNovuBranding !== undefined) { updateFields.removeNovuBranding = command.removeNovuBranding; } if (command.defaultLocale !== undefined) { updateFields.defaultLocale = command.defaultLocale; } if (command.targetLocales !== undefined) { updateFields.targetLocales = command.targetLocales; } return updateFields; } private buildSettingsResponse(organization: OrganizationEntity): GetOrganizationSettingsDto { return { removeNovuBranding: organization.removeNovuBranding || false, defaultLocale: organization.defaultLocale || DEFAULT_LOCALE, targetLocales: organization.targetLocales || [], }; } } ================================================ FILE: apps/api/src/app/outbound-webhooks/dtos/create-webhook-portal-response.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; export class CreateWebhookPortalResponseDto { @ApiProperty({ description: 'The webhook portal application ID', }) appId: string; } ================================================ FILE: apps/api/src/app/outbound-webhooks/dtos/get-webhook-portal-token-response.dto.ts ================================================ import { IsNotEmpty, IsString } from 'class-validator'; export class GetWebhookPortalTokenResponseDto { @IsNotEmpty() @IsString() url: string; @IsNotEmpty() @IsString() token: string; @IsNotEmpty() @IsString() appId: string; } ================================================ FILE: apps/api/src/app/outbound-webhooks/outbound-webhooks.controller.ts ================================================ import { ClassSerializerInterceptor, Controller, Get, Post, UseInterceptors } from '@nestjs/common'; import { ApiExcludeController, ApiOperation } from '@nestjs/swagger'; import { ProductFeature, RequirePermissions, UserSession } from '@novu/application-generic'; import { PermissionsEnum, ProductFeatureKeyEnum, UserSessionData } from '@novu/shared'; import { RequireAuthentication } from '../auth/framework/auth.decorator'; import { CreateWebhookPortalResponseDto } from './dtos/create-webhook-portal-response.dto'; import { GetWebhookPortalTokenResponseDto } from './dtos/get-webhook-portal-token-response.dto'; import { CreateWebhookPortalCommand } from './usecases/create-webhook-portal-token/create-webhook-portal.command'; import { CreateWebhookPortalUsecase } from './usecases/create-webhook-portal-token/create-webhook-portal.usecase'; import { GetWebhookPortalTokenCommand } from './usecases/get-webhook-portal-token/get-webhook-portal-token.command'; import { GetWebhookPortalTokenUsecase } from './usecases/get-webhook-portal-token/get-webhook-portal-token.usecase'; @Controller({ path: `/outbound-webhooks`, version: '2' }) @UseInterceptors(ClassSerializerInterceptor) @RequireAuthentication() @ApiExcludeController() export class OutboundWebhooksController { constructor( private getWebhookPortalTokenUsecase: GetWebhookPortalTokenUsecase, private createWebhookPortalTokenUsecase: CreateWebhookPortalUsecase ) {} @Get('/portal/token') @ProductFeature(ProductFeatureKeyEnum.WEBHOOKS) @RequirePermissions(PermissionsEnum.WEBHOOK_WRITE, PermissionsEnum.WEBHOOK_READ) @ApiOperation({ summary: 'Get Webhook Portal Access Token', description: 'Generates a short-lived token and URL for accessing the Svix application portal for the current environment.', }) async getPortalToken(@UserSession() user: UserSessionData): Promise { return await this.getWebhookPortalTokenUsecase.execute( GetWebhookPortalTokenCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, userId: user._id, }) ); } @Post('/portal/token') @ProductFeature(ProductFeatureKeyEnum.WEBHOOKS) @RequirePermissions(PermissionsEnum.WEBHOOK_WRITE) @ApiOperation({ summary: 'Create Webhook Portal Access Token', description: 'Creates a token for accessing the webhook portal for the current environment.', }) async createPortalToken(@UserSession() user: UserSessionData): Promise { return await this.createWebhookPortalTokenUsecase.execute( CreateWebhookPortalCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, userId: user._id, }) ); } } ================================================ FILE: apps/api/src/app/outbound-webhooks/outbound-webhooks.module.ts ================================================ import { DynamicModule, Module } from '@nestjs/common'; import { SendWebhookMessage, SvixProviderService } from '@novu/application-generic'; import { NoopSendWebhookMessage } from '../inbox/usecases/noop-send-webhook-message.usecase'; import { SharedModule } from '../shared/shared.module'; import { OutboundWebhooksController } from './outbound-webhooks.controller'; import { CreateWebhookPortalUsecase } from './usecases/create-webhook-portal-token/create-webhook-portal.usecase'; import { GetWebhookPortalTokenUsecase } from './usecases/get-webhook-portal-token/get-webhook-portal-token.usecase'; @Module({}) class OutboundWebhooksModuleDefinition {} export const OutboundWebhooksModule = { forRoot(): DynamicModule { const isEnterprise = process.env.NOVU_ENTERPRISE === 'true'; if (isEnterprise) { return { module: OutboundWebhooksModuleDefinition, imports: [SharedModule], controllers: [OutboundWebhooksController], providers: [GetWebhookPortalTokenUsecase, CreateWebhookPortalUsecase, SvixProviderService, SendWebhookMessage], exports: [SendWebhookMessage], }; } return { module: OutboundWebhooksModuleDefinition, imports: [SharedModule], providers: [ { provide: SendWebhookMessage, useClass: NoopSendWebhookMessage, }, ], exports: [SendWebhookMessage], }; }, }; ================================================ FILE: apps/api/src/app/outbound-webhooks/usecases/create-webhook-portal-token/create-webhook-portal.command.ts ================================================ import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; export class CreateWebhookPortalCommand extends EnvironmentWithUserCommand {} ================================================ FILE: apps/api/src/app/outbound-webhooks/usecases/create-webhook-portal-token/create-webhook-portal.usecase.ts ================================================ import { BadRequestException, Inject, Injectable, NotFoundException, Scope } from '@nestjs/common'; import { generateWebhookAppId, LogDecorator, SvixClient } from '@novu/application-generic'; import { EnvironmentRepository, OrganizationRepository } from '@novu/dal'; import { CreateWebhookPortalResponseDto } from '../../dtos/create-webhook-portal-response.dto'; import { CreateWebhookPortalCommand } from './create-webhook-portal.command'; @Injectable() export class CreateWebhookPortalUsecase { constructor( private environmentRepository: EnvironmentRepository, @Inject('SVIX_CLIENT') private svix: SvixClient, private organizationRepository: OrganizationRepository ) {} @LogDecorator() async execute(command: CreateWebhookPortalCommand): Promise { if (!this.svix) { throw new BadRequestException('Webhook system is not enabled'); } const environment = await this.environmentRepository.findOne({ _id: command.environmentId, _organizationId: command.organizationId, }); if (!environment) { throw new NotFoundException( `Environment not found for id ${command.environmentId} and organization ${command.organizationId}` ); } const organization = await this.organizationRepository.findById(command.organizationId); if (!organization) { throw new NotFoundException(`Organization not found for id ${command.organizationId}`); } try { const app = await this.svix.application.create({ name: organization.name, uid: generateWebhookAppId(command.organizationId, command.environmentId), metadata: { environmentId: command.environmentId, organizationId: command.organizationId, }, }); await this.environmentRepository.updateOne({ _id: command.environmentId }, { $set: { webhookAppId: app.uid } }); return { appId: app.uid!, }; } catch (error) { throw new BadRequestException(`Failed to generate Svix portal token: ${error?.message}`); } } } ================================================ FILE: apps/api/src/app/outbound-webhooks/usecases/get-webhook-portal-token/get-webhook-portal-token.command.ts ================================================ import { BaseCommand } from '@novu/application-generic'; import { IsDefined } from 'class-validator'; import { EnvironmentCommand } from '../../../shared/commands/project.command'; export class GetWebhookPortalTokenCommand extends EnvironmentCommand { @IsDefined() userId: string; } ================================================ FILE: apps/api/src/app/outbound-webhooks/usecases/get-webhook-portal-token/get-webhook-portal-token.usecase.ts ================================================ import { BadRequestException, Inject, Injectable, NotFoundException, Scope } from '@nestjs/common'; import { generateWebhookAppId, LogDecorator, SvixClient } from '@novu/application-generic'; import { EnvironmentRepository } from '@novu/dal'; import { GetWebhookPortalTokenResponseDto } from '../../dtos/get-webhook-portal-token-response.dto'; import { GetWebhookPortalTokenCommand } from './get-webhook-portal-token.command'; @Injectable() export class GetWebhookPortalTokenUsecase { constructor( private environmentRepository: EnvironmentRepository, @Inject('SVIX_CLIENT') private svix: SvixClient ) {} @LogDecorator() async execute(command: GetWebhookPortalTokenCommand): Promise { if (!this.svix) { throw new BadRequestException('Webhook system is not enabled'); } const environment = await this.environmentRepository.findOne({ _id: command.environmentId, _organizationId: command.organizationId, }); if (!environment) { throw new NotFoundException( `Environment not found for id ${command.environmentId} and organization ${command.organizationId}` ); } if (!environment.webhookAppId) { throw new NotFoundException(`Portal not found for environment ${command.environmentId}`); } try { const svixResponse = await this.svix.authentication.appPortalAccess(environment.webhookAppId, {}); return { url: svixResponse.url, token: svixResponse.token, appId: environment.webhookAppId, }; } catch (error) { if (error.code === 404) { throw new NotFoundException(`Portal not found for environment ${command.environmentId}`); } throw new BadRequestException(`Failed to generate Svix portal token: ${error?.message}`); } } } ================================================ FILE: apps/api/src/app/outbound-webhooks/webhooks.const.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { MessageWebhookResponseDto, WorkflowResponseDto } from '@novu/application-generic'; import { WebhookEventEnum, WebhookObjectTypeEnum } from '@novu/shared'; import { InboxPreference } from '../inbox/utils/types'; interface WebhookEventConfig { event: WebhookEventEnum; // biome-ignore lint/complexity/noBannedTypes: This is the expected type for the payloadDto for SwaggerDocumentOptions.extraModels payloadDto: Function; objectType: WebhookObjectTypeEnum; } type WebhookEventRecord = Record; export class WebhookUpdatedWorkflowDto { @ApiProperty({ description: 'Current workflow state', type: () => WorkflowResponseDto }) object: WorkflowResponseDto; @ApiProperty({ description: 'Previous state of the workflow', type: () => WorkflowResponseDto }) previousObject: WorkflowResponseDto; } export class WebhookCreatedWorkflowDto { @ApiProperty({ description: 'Current workflow state', type: () => WorkflowResponseDto }) object: WorkflowResponseDto; } export class WebhookDeletedWorkflowDto { @ApiProperty({ description: 'Current workflow state', type: () => WorkflowResponseDto }) object: WorkflowResponseDto; } export class WebhookMessageDto { @ApiProperty({ description: 'Current message state' }) object: MessageWebhookResponseDto; } enum MessageFailedErrorCodeEnum { TOKEN_EXPIRED = 'token_expired', } export class MessageFailedWebhookDto { @ApiProperty({ description: 'Current message state' }) object: MessageWebhookResponseDto; @ApiProperty({ description: 'Error message' }) errorCode: MessageFailedErrorCodeEnum; } export class MessageFailedPushDto { @ApiProperty({ description: 'Is invalid token' }) isInvalidToken: boolean; @ApiProperty({ description: 'Device token' }) deviceToken: string; } export class MessageFailedErrorDto { @ApiProperty({ description: 'Error message' }) message: string; @ApiProperty({ description: 'Push error' }) push?: MessageFailedPushDto; } export class WebhookMessageFailedDto { @ApiProperty({ description: 'Current message state' }) object: MessageWebhookResponseDto; @ApiProperty({ description: 'Error message' }) error: MessageFailedErrorDto; } export class WebhookPreferenceDto { @ApiProperty({ description: 'Current preference state' }) object: InboxPreference; @ApiProperty({ description: 'Subscriber ID' }) subscriberId: string; } // Create the webhook events as a record to ensure all enum values are covered const webhookEventRecord = { [WebhookEventEnum.MESSAGE_SENT]: { event: WebhookEventEnum.MESSAGE_SENT, payloadDto: WebhookMessageDto, objectType: WebhookObjectTypeEnum.MESSAGE, }, [WebhookEventEnum.MESSAGE_FAILED]: { event: WebhookEventEnum.MESSAGE_FAILED, payloadDto: WebhookMessageFailedDto, objectType: WebhookObjectTypeEnum.MESSAGE, }, [WebhookEventEnum.MESSAGE_DELIVERED]: { event: WebhookEventEnum.MESSAGE_DELIVERED, payloadDto: WebhookMessageDto, objectType: WebhookObjectTypeEnum.MESSAGE, }, [WebhookEventEnum.MESSAGE_SEEN]: { event: WebhookEventEnum.MESSAGE_SEEN, payloadDto: WebhookMessageDto, objectType: WebhookObjectTypeEnum.MESSAGE, }, [WebhookEventEnum.MESSAGE_READ]: { event: WebhookEventEnum.MESSAGE_READ, payloadDto: WebhookMessageDto, objectType: WebhookObjectTypeEnum.MESSAGE, }, [WebhookEventEnum.MESSAGE_UNREAD]: { event: WebhookEventEnum.MESSAGE_UNREAD, payloadDto: WebhookMessageDto, objectType: WebhookObjectTypeEnum.MESSAGE, }, [WebhookEventEnum.MESSAGE_ARCHIVED]: { event: WebhookEventEnum.MESSAGE_ARCHIVED, payloadDto: WebhookMessageDto, objectType: WebhookObjectTypeEnum.MESSAGE, }, [WebhookEventEnum.MESSAGE_UNARCHIVED]: { event: WebhookEventEnum.MESSAGE_UNARCHIVED, payloadDto: WebhookMessageDto, objectType: WebhookObjectTypeEnum.MESSAGE, }, [WebhookEventEnum.MESSAGE_SNOOZED]: { event: WebhookEventEnum.MESSAGE_SNOOZED, payloadDto: WebhookMessageDto, objectType: WebhookObjectTypeEnum.MESSAGE, }, [WebhookEventEnum.MESSAGE_UNSNOOZED]: { event: WebhookEventEnum.MESSAGE_UNSNOOZED, payloadDto: WebhookMessageDto, objectType: WebhookObjectTypeEnum.MESSAGE, }, [WebhookEventEnum.MESSAGE_DELETED]: { event: WebhookEventEnum.MESSAGE_DELETED, payloadDto: WebhookMessageDto, objectType: WebhookObjectTypeEnum.MESSAGE, }, [WebhookEventEnum.WORKFLOW_CREATED]: { event: WebhookEventEnum.WORKFLOW_CREATED, payloadDto: WebhookCreatedWorkflowDto, objectType: WebhookObjectTypeEnum.WORKFLOW, }, [WebhookEventEnum.WORKFLOW_UPDATED]: { event: WebhookEventEnum.WORKFLOW_UPDATED, payloadDto: WebhookUpdatedWorkflowDto, objectType: WebhookObjectTypeEnum.WORKFLOW, }, [WebhookEventEnum.WORKFLOW_DELETED]: { event: WebhookEventEnum.WORKFLOW_DELETED, payloadDto: WebhookDeletedWorkflowDto, objectType: WebhookObjectTypeEnum.WORKFLOW, }, [WebhookEventEnum.WORKFLOW_PUBLISHED]: { event: WebhookEventEnum.WORKFLOW_PUBLISHED, payloadDto: WebhookUpdatedWorkflowDto, objectType: WebhookObjectTypeEnum.WORKFLOW, }, [WebhookEventEnum.PREFERENCE_UPDATED]: { event: WebhookEventEnum.PREFERENCE_UPDATED, payloadDto: WebhookPreferenceDto, objectType: WebhookObjectTypeEnum.PREFERENCE, }, } as const satisfies WebhookEventRecord; // Helper function to ensure all enum values are present exactly once function createWebhookEvents(record: T): WebhookEventConfig[] { return Object.values(record); } // Export the webhook events array created from the type-safe record export const webhookEvents = createWebhookEvents(webhookEventRecord); ================================================ FILE: apps/api/src/app/partner-integrations/dtos/create-vercel-integration-request.dto.ts ================================================ import { IsDefined, IsString } from 'class-validator'; export class CreateVercelIntegrationRequestDto { @IsDefined() @IsString() vercelIntegrationCode: string; @IsDefined() @IsString() configurationId: string; } ================================================ FILE: apps/api/src/app/partner-integrations/dtos/create-vercel-integration-response.dto.ts ================================================ import { IsDefined } from 'class-validator'; export class CreateVercelIntegrationResponseDto { @IsDefined() success: boolean; } ================================================ FILE: apps/api/src/app/partner-integrations/dtos/update-vercel-integration-request.dto.ts ================================================ import { IsDefined, IsString } from 'class-validator'; export class UpdateVercelIntegrationRequestDto { @IsDefined() data: Record; @IsDefined() @IsString() configurationId: string; } ================================================ FILE: apps/api/src/app/partner-integrations/partner-integrations.controller.ts ================================================ import { Body, ClassSerializerInterceptor, Controller, Get, Headers, Param, Post, Put, Query, UseInterceptors, } from '@nestjs/common'; import { ApiExcludeController, ApiTags } from '@nestjs/swagger'; import { RequirePermissions } from '@novu/application-generic'; import { PermissionsEnum, UserSessionData } from '@novu/shared'; import { RequireAuthentication } from '../auth/framework/auth.decorator'; import { UserSession } from '../shared/framework/user.decorator'; import { CreateVercelIntegrationRequestDto } from './dtos/create-vercel-integration-request.dto'; import { CreateVercelIntegrationResponseDto } from './dtos/create-vercel-integration-response.dto'; import { UpdateVercelIntegrationRequestDto } from './dtos/update-vercel-integration-request.dto'; import { CreateVercelIntegrationCommand } from './usecases/create-vercel-integration/create-vercel-integration.command'; import { CreateVercelIntegration } from './usecases/create-vercel-integration/create-vercel-integration.usecase'; import { GetVercelIntegrationCommand } from './usecases/get-vercel-integration/get-vercel-integration.command'; import { GetVercelIntegration } from './usecases/get-vercel-integration/get-vercel-integration.usecase'; import { GetVercelIntegrationProjectsCommand } from './usecases/get-vercel-projects/get-vercel-integration-projects.command'; import { GetVercelIntegrationProjects } from './usecases/get-vercel-projects/get-vercel-integration-projects.usecase'; import { ProcessVercelWebhookCommand } from './usecases/process-vercel-webhook/process-vercel-webhook.command'; import { ProcessVercelWebhook } from './usecases/process-vercel-webhook/process-vercel-webhook.usecase'; import { UpdateVercelIntegrationCommand } from './usecases/update-vercel-integration/update-vercel-integration.command'; import { UpdateVercelIntegration } from './usecases/update-vercel-integration/update-vercel-integration.usecase'; @Controller('/partner-integrations') @UseInterceptors(ClassSerializerInterceptor) @ApiTags('Partner Integrations') @ApiExcludeController() export class PartnerIntegrationsController { constructor( private createVercelIntegrationUsecase: CreateVercelIntegration, private getVercelIntegrationProjectsUsecase: GetVercelIntegrationProjects, private getVercelIntegrationUsecase: GetVercelIntegration, private updateVercelIntegrationUsecase: UpdateVercelIntegration, private processVercelWebhookUsecase: ProcessVercelWebhook ) {} @Post('/vercel') @RequireAuthentication() @RequirePermissions(PermissionsEnum.PARTNER_INTEGRATION_WRITE) async createVercelIntegration( @UserSession() user: UserSessionData, @Body() body: CreateVercelIntegrationRequestDto ): Promise { return await this.createVercelIntegrationUsecase.execute( CreateVercelIntegrationCommand.create({ vercelIntegrationCode: body.vercelIntegrationCode, environmentId: user.environmentId, organizationId: user.organizationId, userId: user._id, configurationId: body.configurationId, }) ); } @Put('/vercel') @RequireAuthentication() @RequirePermissions(PermissionsEnum.PARTNER_INTEGRATION_WRITE) async updateVercelIntegration(@UserSession() user: UserSessionData, @Body() body: UpdateVercelIntegrationRequestDto) { return await this.updateVercelIntegrationUsecase.execute( UpdateVercelIntegrationCommand.create({ data: body.data, environmentId: user.environmentId, organizationId: user.organizationId, userId: user._id, configurationId: body.configurationId, }) ); } @Get('/vercel/:configurationId') @RequireAuthentication() @RequirePermissions(PermissionsEnum.PARTNER_INTEGRATION_READ) async getVercelIntegration(@UserSession() user: UserSessionData, @Param('configurationId') configurationId: string) { return await this.getVercelIntegrationUsecase.execute( GetVercelIntegrationCommand.create({ userId: user._id, configurationId, environmentId: user.environmentId, organizationId: user.organizationId, }) ); } @Get('/vercel/:configurationId/projects') @RequireAuthentication() @RequirePermissions(PermissionsEnum.PARTNER_INTEGRATION_READ) async getVercelProjects( @UserSession() user: UserSessionData, @Param('configurationId') configurationId: string, @Query('nextPage') nextPage?: string ) { return await this.getVercelIntegrationProjectsUsecase.execute( GetVercelIntegrationProjectsCommand.create({ configurationId, environmentId: user.environmentId, organizationId: user.organizationId, userId: user._id, ...(nextPage && { nextPage }), }) ); } @Post('/vercel/webhook') async webhook(@Body() body: any, @Headers('x-vercel-signature') signatureHeader: string) { return this.processVercelWebhookUsecase.execute( ProcessVercelWebhookCommand.create({ body, signatureHeader, }) ); } } ================================================ FILE: apps/api/src/app/partner-integrations/partner-integrations.module.ts ================================================ import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; import { CommunityOrganizationRepository, CommunityUserRepository } from '@novu/dal'; import { BridgeModule } from '../bridge'; import { EnvironmentsModuleV1 } from '../environments-v1/environments-v1.module'; import { SharedModule } from '../shared/shared.module'; import { PartnerIntegrationsController } from './partner-integrations.controller'; import { USE_CASES } from './usecases'; @Module({ imports: [SharedModule, HttpModule, EnvironmentsModuleV1, BridgeModule], providers: [...USE_CASES, CommunityUserRepository, CommunityOrganizationRepository], controllers: [PartnerIntegrationsController], }) export class PartnerIntegrationsModule {} ================================================ FILE: apps/api/src/app/partner-integrations/usecases/create-vercel-integration/create-vercel-integration.command.ts ================================================ import { IsDefined } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; export class CreateVercelIntegrationCommand extends EnvironmentWithUserCommand { @IsDefined() vercelIntegrationCode: string; @IsDefined() configurationId: string; } ================================================ FILE: apps/api/src/app/partner-integrations/usecases/create-vercel-integration/create-vercel-integration.spec.ts ================================================ import { HttpService } from '@nestjs/axios'; import { BadRequestException } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { AnalyticsService } from '@novu/application-generic'; import { OrganizationRepository, PartnerTypeEnum } from '@novu/dal'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { of } from 'rxjs'; import { assert, match, restore, stub } from 'sinon'; import { CreateVercelIntegration } from './create-vercel-integration.usecase'; describe('CreateVercelIntegration', () => { let createVercelIntegration: CreateVercelIntegration; let session: UserSession; let httpServiceMock; let organizationRepositoryMock; let analyticsServiceMock; beforeEach(async () => { httpServiceMock = { post: stub().returns( of({ data: { access_token: 'test-token', team_id: 'test-team-id', }, }) ), }; organizationRepositoryMock = { upsertPartnerConfiguration: stub().resolves(true), }; analyticsServiceMock = { track: stub().resolves(), }; const moduleRef = await Test.createTestingModule({ providers: [ CreateVercelIntegration, { provide: HttpService, useValue: httpServiceMock, }, { provide: OrganizationRepository, useValue: organizationRepositoryMock, }, { provide: AnalyticsService, useValue: analyticsServiceMock }, ], }).compile(); session = new UserSession(); await session.initialize(); createVercelIntegration = moduleRef.get(CreateVercelIntegration); // @ts-ignore process.env.VERCEL_CLIENT_ID = 'test-client-id'; // @ts-ignore process.env.VERCEL_CLIENT_SECRET = 'test-client-secret'; // @ts-ignore process.env.VERCEL_REDIRECT_URI = 'test-redirect-uri'; // @ts-ignore process.env.VERCEL_BASE_URL = 'https://api.vercel.com'; }); afterEach(() => { restore(); }); it('should successfully set vercel configuration', async () => { const command = { organizationId: session.organization._id, vercelIntegrationCode: 'test-code', configurationId: 'test-config-id', userId: session.user._id, environmentId: session.environment._id, }; const result = await createVercelIntegration.execute(command); expect(result.success).to.equal(true); // Verify HTTP call assert.calledWith( httpServiceMock.post, 'https://api.vercel.com/v2/oauth/access_token', match.instanceOf(URLSearchParams), { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, } ); // Verify the URLSearchParams content const postCall = httpServiceMock.post.getCall(0); const [, postData] = postCall.args; expect(postData.get('code')).to.equal('test-code'); expect(postData.get('client_id')).to.equal('test-client-id'); expect(postData.get('client_secret')).to.equal('test-client-secret'); expect(postData.get('redirect_uri')).to.equal('test-redirect-uri'); // Verify organization repository call assert.calledWith(organizationRepositoryMock.upsertPartnerConfiguration, { organizationId: command.organizationId, configuration: { accessToken: 'test-token', configurationId: command.configurationId, teamId: 'test-team-id', partnerType: PartnerTypeEnum.VERCEL, }, }); assert.calledWith( analyticsServiceMock.track, 'Create Vercel Integration - [Partner Integrations]', command.userId, { _organization: command.organizationId } ); }); it('should throw BadRequestException when Vercel returns an error', async () => { httpServiceMock.post.throws(new Error('Vercel error')); try { await createVercelIntegration.execute({ userId: session.user._id, organizationId: session.organization._id, environmentId: session.environment._id, vercelIntegrationCode: 'test-code', configurationId: 'test-config-id', }); throw new Error('Should not reach here'); } catch (error) { expect(error).to.be.instanceOf(BadRequestException); expect(error.message).to.equal('Vercel error'); assert.notCalled(organizationRepositoryMock.upsertPartnerConfiguration); } }); }); ================================================ FILE: apps/api/src/app/partner-integrations/usecases/create-vercel-integration/create-vercel-integration.usecase.ts ================================================ import { HttpService } from '@nestjs/axios'; import { BadRequestException, Injectable } from '@nestjs/common'; import { AnalyticsService } from '@novu/application-generic'; import { OrganizationRepository, PartnerTypeEnum } from '@novu/dal'; import { lastValueFrom } from 'rxjs'; import { CreateVercelIntegrationResponseDto } from '../../dtos/create-vercel-integration-response.dto'; import { CreateVercelIntegrationCommand } from './create-vercel-integration.command'; @Injectable() export class CreateVercelIntegration { constructor( private httpService: HttpService, private organizationRepository: OrganizationRepository, private analyticsService: AnalyticsService ) {} async execute(command: CreateVercelIntegrationCommand): Promise { try { const tokenData = await this.getVercelToken(command.vercelIntegrationCode); const configuration = { accessToken: tokenData.accessToken, configurationId: command.configurationId, teamId: tokenData.teamId, partnerType: PartnerTypeEnum.VERCEL, }; await this.organizationRepository.upsertPartnerConfiguration({ organizationId: command.organizationId, configuration, }); this.analyticsService.track('Create Vercel Integration - [Partner Integrations]', command.userId, { _organization: command.organizationId, }); return { success: true, }; } catch (error) { throw new BadRequestException( error?.response?.data?.error_description || error?.response?.data?.message || error.message ); } } private async getVercelToken(code: string): Promise<{ accessToken: string; teamId: string; }> { try { const postData = new URLSearchParams({ code: code as string, client_id: process.env.VERCEL_CLIENT_ID as string, client_secret: process.env.VERCEL_CLIENT_SECRET as string, redirect_uri: process.env.VERCEL_REDIRECT_URI as string, }); const response = await lastValueFrom( this.httpService.post(`${process.env.VERCEL_BASE_URL}/v2/oauth/access_token`, postData, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, }) ); const { data } = response; return { accessToken: data.access_token, teamId: data.team_id, }; } catch (error) { throw new BadRequestException( error?.response?.data?.error_description || error?.response?.data?.message || error.message ); } } } ================================================ FILE: apps/api/src/app/partner-integrations/usecases/get-vercel-integration/get-vercel-integration.command.ts ================================================ import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; export class GetVercelIntegrationCommand extends EnvironmentWithUserCommand { configurationId: string; } ================================================ FILE: apps/api/src/app/partner-integrations/usecases/get-vercel-integration/get-vercel-integration.spec.ts ================================================ import { Test } from '@nestjs/testing'; import { OrganizationRepository } from '@novu/dal'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { assert, restore, stub } from 'sinon'; import { GetVercelIntegration } from './get-vercel-integration.usecase'; describe('GetVercelIntegration', () => { let getVercelIntegration: GetVercelIntegration; let session: UserSession; let organizationRepositoryMock; beforeEach(async () => { organizationRepositoryMock = { findByPartnerConfigurationId: stub().resolves([ { _id: 'org-id-1', partnerConfigurations: [ { projectIds: ['project-1', 'project-2'], }, ], }, { _id: 'org-id-2', partnerConfigurations: [ { projectIds: ['project-2', 'project-3'], }, ], }, ]), }; const moduleRef = await Test.createTestingModule({ providers: [ GetVercelIntegration, { provide: OrganizationRepository, useValue: organizationRepositoryMock, }, ], }).compile(); session = new UserSession(); await session.initialize(); getVercelIntegration = moduleRef.get(GetVercelIntegration); }); afterEach(() => { restore(); }); it('should get vercel configuration details', async () => { const command = { userId: session.user._id, organizationId: session.organization._id, environmentId: session.environment._id, configurationId: 'test-config-id', }; const result = await getVercelIntegration.execute(command); expect(result).to.be.an('array'); expect(result[0]).to.deep.equal({ organizationId: 'org-id-1', projectIds: ['project-1', 'project-2'], }); expect(result[1]).to.deep.equal({ organizationId: 'org-id-2', projectIds: ['project-2', 'project-3'], }); assert.calledOnceWithExactly(organizationRepositoryMock.findByPartnerConfigurationId, { userId: command.userId, configurationId: command.configurationId, }); }); it('should return empty array when no configurations found', async () => { organizationRepositoryMock.findByPartnerConfigurationId.resolves([]); const command = { userId: session.user._id, organizationId: session.organization._id, environmentId: session.environment._id, configurationId: 'test-config-id', }; const result = await getVercelIntegration.execute(command); expect(result).to.be.an('array'); expect(result).to.have.length(0); assert.calledOnceWithExactly(organizationRepositoryMock.findByPartnerConfigurationId, { userId: command.userId, configurationId: command.configurationId, }); }); }); ================================================ FILE: apps/api/src/app/partner-integrations/usecases/get-vercel-integration/get-vercel-integration.usecase.ts ================================================ import { Injectable } from '@nestjs/common'; import { OrganizationRepository } from '@novu/dal'; import { GetVercelIntegrationCommand } from './get-vercel-integration.command'; @Injectable() export class GetVercelIntegration { constructor(private organizationRepository: OrganizationRepository) {} async execute(command: GetVercelIntegrationCommand) { return await this.getConfigurationDetails(command); } private async getConfigurationDetails(command: GetVercelIntegrationCommand) { const details = await this.organizationRepository.findByPartnerConfigurationId({ userId: command.userId, configurationId: command.configurationId, }); return details.reduce( (acc, curr) => { if ( curr.partnerConfigurations && curr.partnerConfigurations.length >= 1 && curr.partnerConfigurations[0].projectIds && curr.partnerConfigurations[0].projectIds.length >= 1 ) { acc.push({ organizationId: curr._id, projectIds: curr.partnerConfigurations[0].projectIds, }); } return acc; }, [] as { organizationId: string; projectIds: string[] }[] ); } } ================================================ FILE: apps/api/src/app/partner-integrations/usecases/get-vercel-projects/get-vercel-integration-projects.command.ts ================================================ import { IsDefined, IsOptional, IsString } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; export class GetVercelIntegrationProjectsCommand extends EnvironmentWithUserCommand { @IsDefined() @IsString() configurationId: string; @IsOptional() @IsString() nextPage?: string; } ================================================ FILE: apps/api/src/app/partner-integrations/usecases/get-vercel-projects/get-vercel-integration-projects.spec.ts ================================================ import { HttpService } from '@nestjs/axios'; import { BadRequestException } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { OrganizationRepository } from '@novu/dal'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { of } from 'rxjs'; import { assert, restore, stub } from 'sinon'; import { GetVercelIntegrationProjects } from './get-vercel-integration-projects.usecase'; describe('GetVercelIntegrationProjects', () => { let getVercelIntegrationProjects: GetVercelIntegrationProjects; let session: UserSession; let httpServiceMock; let organizationRepositoryMock; beforeEach(async () => { httpServiceMock = { get: stub().returns( of({ data: { projects: [ { id: 'project-1', name: 'Project One' }, { id: 'project-2', name: 'Project Two' }, ], pagination: { next: 'next-page-token', }, }, }) ), }; organizationRepositoryMock = { findByPartnerConfigurationId: stub().resolves([ { partnerConfigurations: [ { configurationId: 'test-config-id', accessToken: 'test-token', teamId: 'test-team-id', }, ], }, ]), }; const moduleRef = await Test.createTestingModule({ providers: [ GetVercelIntegrationProjects, { provide: HttpService, useValue: httpServiceMock, }, { provide: OrganizationRepository, useValue: organizationRepositoryMock, }, ], }).compile(); session = new UserSession(); await session.initialize(); getVercelIntegrationProjects = moduleRef.get(GetVercelIntegrationProjects); }); afterEach(() => { restore(); }); it('should get vercel projects successfully', async () => { const command = { userId: session.user._id, organizationId: session.organization._id, environmentId: session.environment._id, configurationId: 'test-config-id', }; const result = await getVercelIntegrationProjects.execute(command); expect(result.projects).to.have.length(2); expect(result.projects[0]).to.deep.equal({ name: 'Project One', id: 'project-1', }); expect(result.pagination).to.deep.equal({ next: 'next-page-token', }); assert.calledWith(organizationRepositoryMock.findByPartnerConfigurationId, { userId: command.userId, configurationId: command.configurationId, }); const expectedUrl = `${process.env.VERCEL_BASE_URL}/v10/projects?limit=100&teamId=test-team-id`; assert.calledWith(httpServiceMock.get, expectedUrl, { headers: { Authorization: 'Bearer test-token', }, }); }); it('should throw BadRequestException when no configuration found', async () => { organizationRepositoryMock.findByPartnerConfigurationId.resolves([]); try { await getVercelIntegrationProjects.execute({ userId: session.user._id, organizationId: session.organization._id, environmentId: session.environment._id, configurationId: 'test-config-id', }); throw new Error('Should not reach here'); } catch (error) { expect(error).to.be.instanceOf(BadRequestException); expect(error.message).to.equal('No partner configuration found.'); assert.notCalled(httpServiceMock.get); } }); it('should throw BadRequestException when HTTP request fails', async () => { httpServiceMock.get.throws(new Error('HTTP Error')); try { await getVercelIntegrationProjects.execute({ userId: session.user._id, organizationId: session.organization._id, environmentId: session.environment._id, configurationId: 'test-config-id', }); throw new Error('Should not reach here'); } catch (error) { expect(error).to.be.instanceOf(BadRequestException); expect(error.message).to.equal('HTTP Error'); assert.called(httpServiceMock.get); } }); }); ================================================ FILE: apps/api/src/app/partner-integrations/usecases/get-vercel-projects/get-vercel-integration-projects.usecase.ts ================================================ import { HttpService } from '@nestjs/axios'; import { BadRequestException, Injectable } from '@nestjs/common'; import { OrganizationRepository } from '@novu/dal'; import { lastValueFrom } from 'rxjs'; import { GetVercelIntegrationProjectsCommand } from './get-vercel-integration-projects.command'; @Injectable() export class GetVercelIntegrationProjects { constructor( private httpService: HttpService, private organizationRepository: OrganizationRepository ) {} async execute(command: GetVercelIntegrationProjectsCommand) { try { const configuration = await this.getCurrentOrgPartnerConfiguration({ userId: command.userId, configurationId: command.configurationId, }); if (!configuration || !configuration.accessToken) { throw new BadRequestException({ message: 'No partner configuration found.', type: 'vercel', }); } const projects = await this.getVercelProjects(configuration.accessToken, configuration.teamId, command.nextPage); return projects; } catch (error) { throw new BadRequestException(error.message); } } async getCurrentOrgPartnerConfiguration({ userId, configurationId }: { userId: string; configurationId: string }) { const orgsWithIntegration = await this.organizationRepository.findByPartnerConfigurationId({ userId, configurationId, }); if (orgsWithIntegration.length === 0) { throw new BadRequestException({ message: 'No partner configuration found.', type: 'vercel', }); } const firstOrg = orgsWithIntegration[0]; const configuration = firstOrg.partnerConfigurations?.find((config) => config.configurationId === configurationId); if (!firstOrg.partnerConfigurations?.length || !configuration) { throw new BadRequestException({ message: 'No partner configuration found', type: 'vercel', }); } return configuration; } private async getVercelProjects(accessToken: string, teamId: string | null, until?: string) { const queryParams = new URLSearchParams(); queryParams.set('limit', '100'); if (teamId) { queryParams.set('teamId', teamId); } if (until) { queryParams.set('until', until); } const response = await lastValueFrom( this.httpService.get(`${process.env.VERCEL_BASE_URL}/v10/projects?${queryParams.toString()}`, { headers: { Authorization: `Bearer ${accessToken}`, }, }) ); return { projects: this.mapProjects(response.data.projects), pagination: response.data.pagination }; } private mapProjects(projects) { return projects.map((project) => { return { name: project.name, id: project.id, }; }); } } ================================================ FILE: apps/api/src/app/partner-integrations/usecases/index.ts ================================================ import { CreateVercelIntegration } from './create-vercel-integration/create-vercel-integration.usecase'; import { GetVercelIntegration } from './get-vercel-integration/get-vercel-integration.usecase'; import { GetVercelIntegrationProjects } from './get-vercel-projects/get-vercel-integration-projects.usecase'; import { ProcessVercelWebhook } from './process-vercel-webhook/process-vercel-webhook.usecase'; import { UpdateVercelIntegration } from './update-vercel-integration/update-vercel-integration.usecase'; export const USE_CASES = [ CreateVercelIntegration, GetVercelIntegrationProjects, GetVercelIntegration, UpdateVercelIntegration, ProcessVercelWebhook, ]; ================================================ FILE: apps/api/src/app/partner-integrations/usecases/process-vercel-webhook/process-vercel-webhook.command.ts ================================================ import { BaseCommand } from '@novu/application-generic'; import { IsDefined } from 'class-validator'; export class ProcessVercelWebhookCommand extends BaseCommand { @IsDefined() signatureHeader: string; @IsDefined() body: any; } ================================================ FILE: apps/api/src/app/partner-integrations/usecases/process-vercel-webhook/process-vercel-webhook.spec.ts ================================================ import crypto from 'node:crypto'; import { BadRequestException } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { PinoLogger } from '@novu/application-generic'; import { CommunityOrganizationRepository, CommunityUserRepository, EnvironmentRepository, MemberRepository, } from '@novu/dal'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { assert, restore, stub } from 'sinon'; import { Sync } from '../../../bridge/usecases/sync'; import { ProcessVercelWebhook } from './process-vercel-webhook.usecase'; describe('ProcessVercelWebhook', () => { let processVercelWebhook: ProcessVercelWebhook; let session: UserSession; let organizationRepositoryMock; let environmentRepositoryMock; let memberRepositoryMock; let communityUserRepositoryMock; let syncUsecaseMock; let loggerMock; beforeEach(async () => { organizationRepositoryMock = { find: stub().resolves([{ _id: 'test-org-id' }]), }; environmentRepositoryMock = { findOne: stub().resolves({ _id: 'test-env-id', _organizationId: 'test-org-id', }), }; memberRepositoryMock = { getOrganizationOwnerAccount: stub().resolves({ _userId: 'test-user-id', }), }; communityUserRepositoryMock = { findOne: stub().resolves({ _id: 'test-internal-user-id', }), }; syncUsecaseMock = { execute: stub().resolves(true), }; loggerMock = { info: stub(), error: stub(), warn: stub(), debug: stub(), trace: stub(), setContext: stub(), }; const moduleRef = await Test.createTestingModule({ providers: [ ProcessVercelWebhook, { provide: CommunityOrganizationRepository, useValue: organizationRepositoryMock, }, { provide: EnvironmentRepository, useValue: environmentRepositoryMock, }, { provide: MemberRepository, useValue: memberRepositoryMock, }, { provide: CommunityUserRepository, useValue: communityUserRepositoryMock, }, { provide: Sync, useValue: syncUsecaseMock, }, { provide: PinoLogger, useValue: loggerMock, }, ], }).compile(); // @ts-ignore process.env.VERCEL_CLIENT_SECRET = 'test-secret'; session = new UserSession(); await session.initialize(); processVercelWebhook = moduleRef.get(ProcessVercelWebhook); }); afterEach(() => { restore(); }); it('should skip non-deployment events', async () => { const result = await processVercelWebhook.execute({ body: { type: 'other-event', }, signatureHeader: 'test-signature', }); expect(result).to.equal(true); assert.notCalled(organizationRepositoryMock.find); }); it('should process deployment succeeded event', async () => { const body = { type: 'deployment.succeeded', payload: { team: { id: 'team-id' }, project: { id: 'project-id' }, deployment: { url: 'test.vercel.app' }, target: 'production', }, }; const hmac = crypto .createHmac('sha1', process.env.VERCEL_CLIENT_SECRET ?? '') .update(JSON.stringify(body)) .digest('hex'); const result = await processVercelWebhook.execute({ body, signatureHeader: hmac, }); expect(result).to.equal(true); assert.calledWith(organizationRepositoryMock.find, { 'partnerConfigurations.teamId': 'team-id', 'partnerConfigurations.projectIds': 'project-id', }); assert.calledWith(environmentRepositoryMock.findOne, { _organizationId: 'test-org-id', name: 'Production', }); assert.calledWith(memberRepositoryMock.getOrganizationOwnerAccount, 'test-org-id'); assert.calledWith(communityUserRepositoryMock.findOne, { externalId: 'test-user-id', }); assert.calledWith(syncUsecaseMock.execute, { organizationId: 'test-org-id', userId: 'test-internal-user-id', environmentId: 'test-env-id', bridgeUrl: 'https://test.vercel.app/api/novu', source: 'vercel', }); }); it('should throw error for invalid signature', async () => { const body = { type: 'deployment.succeeded', payload: { team: { id: 'team-id' }, project: { id: 'project-id' }, deployment: { url: 'test.vercel.app' }, target: 'production', }, }; try { await processVercelWebhook.execute({ body, signatureHeader: 'invalid-signature', }); throw new Error('Should not reach here'); } catch (error) { expect(error).to.be.instanceOf(BadRequestException); expect(error.message).to.equal('Invalid signature'); assert.notCalled(organizationRepositoryMock.find); } }); it('should throw error for missing signature', async () => { const body = { type: 'deployment.succeeded', payload: { team: { id: 'team-id' }, project: { id: 'project-id' }, deployment: { url: 'test.vercel.app' }, target: 'production', }, }; try { await processVercelWebhook.execute({ body, signatureHeader: '', }); throw new Error('Should not reach here'); } catch (error) { expect(error).to.be.instanceOf(BadRequestException); expect(error.message).to.equal('Missing signature or secret'); assert.notCalled(organizationRepositoryMock.find); } }); }); ================================================ FILE: apps/api/src/app/partner-integrations/usecases/process-vercel-webhook/process-vercel-webhook.usecase.ts ================================================ import crypto from 'node:crypto'; import { BadRequestException, HttpException, Injectable, InternalServerErrorException } from '@nestjs/common'; import { PinoLogger } from '@novu/application-generic'; import { CommunityOrganizationRepository, CommunityUserRepository, EnvironmentEntity, EnvironmentRepository, MemberRepository, } from '@novu/dal'; import { Sync } from '../../../bridge/usecases/sync'; import { ProcessVercelWebhookCommand } from './process-vercel-webhook.command'; @Injectable() export class ProcessVercelWebhook { constructor( private organizationRepository: CommunityOrganizationRepository, private environmentRepository: EnvironmentRepository, private syncUsecase: Sync, private memberRepository: MemberRepository, private communityUserRepository: CommunityUserRepository, private logger: PinoLogger ) { this.logger.setContext(this.constructor.name); } async execute(command: ProcessVercelWebhookCommand) { const eventType = command.body.type; if (eventType !== 'deployment.succeeded') { this.logger.info(`Skipping processing Vercel webhook event: ${eventType}`); return true; } this.verifySignature(command.signatureHeader, command.body); const payload = command.body.payload; if (!payload?.team?.id || !payload?.project?.id || !payload?.deployment?.url) { throw new BadRequestException('Invalid webhook payload: missing required fields'); } const teamId = payload.team.id; const projectId = payload.project.id; const deploymentUrl = payload.deployment.url; const vercelEnvironment = payload.target || 'preview'; this.logger.info( { teamId, projectId, vercelEnvironment, deploymentUrl, }, `Processing vercel webhook for ${vercelEnvironment}` ); const organizations = await this.organizationRepository.find( { 'partnerConfigurations.teamId': teamId, 'partnerConfigurations.projectIds': projectId, }, { 'partnerConfigurations.$': 1 } ); if (!organizations || organizations.length === 0) { throw new BadRequestException('Organization not found for vercel webhook integration'); } for (const organization of organizations) { let environment: EnvironmentEntity | null; // TODO: we should think about how to handle different Vercel environments that are not production or development if (vercelEnvironment === 'production') { environment = await this.environmentRepository.findOne({ _organizationId: organization._id, name: 'Production', }); } else { environment = await this.environmentRepository.findOne({ _organizationId: organization._id, name: 'Development', }); } if (!environment) { throw new BadRequestException('Environment Not Found'); } try { const orgOwner = await this.memberRepository.getOrganizationOwnerAccount(environment._organizationId); if (!orgOwner) { throw new BadRequestException('Organization owner not found'); } const internalUser = await this.communityUserRepository.findOne({ externalId: orgOwner?._userId }); if (!internalUser) { throw new BadRequestException('User not found'); } await this.syncUsecase.execute({ organizationId: environment._organizationId, userId: internalUser?._id as string, environmentId: environment._id, bridgeUrl: `https://${deploymentUrl}/api/novu`, source: 'vercel', }); } catch (error) { if (error instanceof HttpException) { throw error; } this.logger.error( { err: error, organizationId: organization._id, teamId, projectId, }, 'Failed to process Vercel webhook for organization' ); throw new InternalServerErrorException( `Failed to process Vercel webhook: ${error instanceof Error ? error.message : 'Unknown error'}` ); } } return true; } private verifySignature(signature: string, body: any): void { const secret = process.env.VERCEL_CLIENT_SECRET; if (!signature || !secret) { throw new BadRequestException('Missing signature or secret'); } const computedSignature = crypto.createHmac('sha1', secret).update(JSON.stringify(body)).digest('hex'); if (signature !== computedSignature) { throw new BadRequestException('Invalid signature'); } } } ================================================ FILE: apps/api/src/app/partner-integrations/usecases/update-vercel-integration/update-vercel-integration.command.ts ================================================ import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; export class UpdateVercelIntegrationCommand extends EnvironmentWithUserCommand { data: Record; configurationId: string; } ================================================ FILE: apps/api/src/app/partner-integrations/usecases/update-vercel-integration/update-vercel-integration.spec.ts ================================================ import { HttpService } from '@nestjs/axios'; import { BadRequestException } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { AnalyticsService, PinoLogger } from '@novu/application-generic'; import { CommunityUserRepository, EnvironmentRepository, MemberRepository, OrganizationRepository } from '@novu/dal'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { of } from 'rxjs'; import { assert, restore, stub } from 'sinon'; import { Sync } from '../../../bridge/usecases/sync'; import { UpdateVercelIntegration } from './update-vercel-integration.usecase'; describe('UpdateVercelIntegration', () => { let updateVercelIntegration: UpdateVercelIntegration; let session: UserSession; let httpServiceMock; let environmentRepositoryMock; let organizationRepositoryMock; let analyticsServiceMock; let syncMock; let memberRepositoryMock; let communityUserRepositoryMock; let loggerMock; beforeEach(async () => { // @ts-ignore process.env.VERCEL_BASE_URL = 'https://api.vercel.com'; httpServiceMock = { get: stub().callsFake((url, config) => { if (url.includes('/v4/projects') && url.includes('teamId=test-team-id')) { return of({ data: { projects: [ { id: 'project-1', env: [ { id: 'env-1', key: 'NEXT_PUBLIC_NOVU_CLIENT_APP_ID', target: ['production'] }, { id: 'env-2', key: 'NOVU_CLIENT_APP_ID', target: ['production'] }, { id: 'env-3', key: 'NOVU_SECRET_KEY', target: ['production'] }, { id: 'env-4', key: 'NEXT_PUBLIC_NOVU_APPLICATION_IDENTIFIER', target: ['production'] }, ], }, ], }, }); } else if (url.includes('/v9/projects/project-1') && url.includes('teamId=test-team-id')) { return of({ data: { targets: { production: { alias: ['prod-alias.vercel.app'], }, development: { alias: ['dev-alias.vercel.app'], }, }, }, }); } // Default response for any other URLs return of({ data: {} }); }), post: stub().returns(of({ data: { success: true } })), delete: stub().returns(of({ data: { success: true } })), }; organizationRepositoryMock = { findByPartnerConfigurationId: stub().resolves([ { partnerConfigurations: [ { configurationId: 'test-config-id', accessToken: 'test-token', teamId: 'test-team-id', projectIds: ['project-1'], }, ], }, ]), bulkUpdatePartnerConfiguration: stub().resolves(true), }; analyticsServiceMock = { track: stub().resolves(), }; syncMock = { execute: stub().resolves(), }; environmentRepositoryMock = { find: stub().resolves([ { _id: 'env-id', name: 'Production', identifier: 'prod', _organizationId: 'org-id', apiKeys: [{ key: 'encrypted-key' }], }, { _id: 'env-id-2', name: 'Development', identifier: 'dev', _organizationId: 'org-id', apiKeys: [{ key: 'encrypted-key-2' }], }, ]), }; memberRepositoryMock = { getOrganizationOwnerAccount: stub().resolves({ _userId: 'admin-id' }), }; communityUserRepositoryMock = { findOne: stub().resolves({ _id: 'internal-user-id' }), }; loggerMock = { log: stub(), error: stub(), warn: stub(), debug: stub(), info: stub(), trace: stub(), setContext: stub(), }; const moduleRef = await Test.createTestingModule({ providers: [ UpdateVercelIntegration, { provide: HttpService, useValue: httpServiceMock }, { provide: EnvironmentRepository, useValue: environmentRepositoryMock }, { provide: OrganizationRepository, useValue: organizationRepositoryMock }, { provide: AnalyticsService, useValue: analyticsServiceMock }, { provide: Sync, useValue: syncMock }, { provide: MemberRepository, useValue: memberRepositoryMock }, { provide: CommunityUserRepository, useValue: communityUserRepositoryMock }, { provide: PinoLogger, useValue: loggerMock }, ], }).compile(); session = new UserSession(); await session.initialize(); updateVercelIntegration = moduleRef.get(UpdateVercelIntegration); }); afterEach(() => { restore(); }); it('should update vercel configuration successfully', async () => { const command = { userId: session.user._id, organizationId: session.organization._id, environmentId: session.environment._id, configurationId: 'test-config-id', data: { 'org-id': ['project-1'], }, }; const result = await updateVercelIntegration.execute(command); expect(result.success).to.equal(true); // Verify existing projects lookup assert.calledWith(organizationRepositoryMock.findByPartnerConfigurationId, { userId: command.userId, configurationId: command.configurationId, }); // Verify project environment variables lookup assert.calledWith(httpServiceMock.get, `${process.env.VERCEL_BASE_URL}/v4/projects?teamId=test-team-id`, { headers: { Authorization: 'Bearer test-token', }, }); // Verify environment variable deletion calls assert.calledWith( httpServiceMock.delete, `${process.env.VERCEL_BASE_URL}/v9/projects/project-1/env/env-1?teamId=test-team-id`, { headers: { Authorization: 'Bearer test-token', }, } ); assert.calledWith( httpServiceMock.delete, `${process.env.VERCEL_BASE_URL}/v9/projects/project-1/env/env-2?teamId=test-team-id`, { headers: { Authorization: 'Bearer test-token', }, } ); assert.calledWith( httpServiceMock.delete, `${process.env.VERCEL_BASE_URL}/v9/projects/project-1/env/env-3?teamId=test-team-id`, { headers: { Authorization: 'Bearer test-token', }, } ); assert.calledWith( httpServiceMock.delete, `${process.env.VERCEL_BASE_URL}/v9/projects/project-1/env/env-4?teamId=test-team-id`, { headers: { Authorization: 'Bearer test-token', }, } ); assert.calledWith(organizationRepositoryMock.bulkUpdatePartnerConfiguration, { userId: command.userId, data: command.data, configuration: { configurationId: 'test-config-id', accessToken: 'test-token', teamId: 'test-team-id', projectIds: ['project-1'], }, }); // Verify environment repository calls assert.calledWith(environmentRepositoryMock.find, { _organizationId: { $in: ['org-id'] }, }); // Verify environment variables setup assert.calledWith( httpServiceMock.post, 'https://api.vercel.com/v10/projects/project-1/env?upsert=true&teamId=test-team-id', [ { target: ['production'], type: 'encrypted', value: 'prod', key: 'NEXT_PUBLIC_NOVU_APPLICATION_IDENTIFIER', }, ], { headers: { Authorization: 'Bearer test-token', 'Content-Type': 'application/json', }, } ); // Verify bridge URL update assert.calledWith(httpServiceMock.get, 'https://api.vercel.com/v9/projects/project-1?teamId=test-team-id', { headers: { Authorization: 'Bearer test-token', 'Content-Type': 'application/json', }, }); // Verify sync execution assert.calledWith(syncMock.execute, { organizationId: 'org-id', userId: 'internal-user-id', environmentId: 'env-id', bridgeUrl: 'https://prod-alias.vercel.app/api/novu', source: 'vercel', }); // Verify analytics assert.calledWith( analyticsServiceMock.track, 'Update Vercel Integration - [Partner Integrations]', command.userId, { _organization: command.organizationId } ); }); it('should handle projects with no environment variables', async () => { // Reset the stub before creating a new behavior httpServiceMock.get.reset(); httpServiceMock.get.callsFake((url) => { if (url.includes('/v4/projects')) { return of({ data: { projects: [ { id: 'project-1', env: [], // Empty env array }, ], }, }); } return of({ data: {} }); }); const command = { userId: session.user._id, organizationId: session.organization._id, environmentId: session.environment._id, configurationId: 'test-config-id', data: { 'org-id': ['project-1'], }, }; const result = await updateVercelIntegration.execute(command); expect(result.success).to.equal(true); assert.notCalled(httpServiceMock.delete); }); it('should handle projects with missing Novu environment variables', async () => { // Reset the stub before creating a new behavior httpServiceMock.get.reset(); httpServiceMock.get.callsFake((url) => { if (url.includes('/v4/projects')) { return of({ data: { projects: [ { id: 'project-1', env: [{ id: 'env-1', key: 'OTHER_ENV_VAR' }], // Only non-Novu env var }, ], }, }); } return of({ data: {} }); }); const command = { userId: session.user._id, organizationId: session.organization._id, environmentId: session.environment._id, configurationId: 'test-config-id', data: { 'org-id': ['project-1'], }, }; const result = await updateVercelIntegration.execute(command); expect(result.success).to.equal(true); assert.notCalled(httpServiceMock.delete); }); it('should throw BadRequestException when configuration not found', async () => { organizationRepositoryMock.findByPartnerConfigurationId.resolves([]); try { await updateVercelIntegration.execute({ userId: session.user._id, organizationId: session.organization._id, environmentId: session.environment._id, configurationId: 'test-config-id', data: {}, }); throw new Error('Should not reach here'); } catch (error) { expect(error).to.be.instanceOf(BadRequestException); expect(error.message).to.equal('No partner configuration found.'); assert.notCalled(httpServiceMock.get); assert.notCalled(httpServiceMock.delete); } }); it('should handle errors during project fetch', async () => { httpServiceMock.get.throws(new Error('HTTP Error')); try { await updateVercelIntegration.execute({ userId: session.user._id, organizationId: session.organization._id, environmentId: session.environment._id, configurationId: 'test-config-id', data: { 'org-id': ['project-1'], }, }); throw new Error('Should not reach here'); } catch (error) { expect(error).to.be.instanceOf(BadRequestException); expect(error.message).to.equal('HTTP Error'); assert.notCalled(httpServiceMock.delete); } }); it('should handle errors during environment variable deletion', async () => { httpServiceMock.delete.onCall(0).throws(new Error('Delete Error')); try { await updateVercelIntegration.execute({ userId: session.user._id, organizationId: session.organization._id, environmentId: session.environment._id, configurationId: 'test-config-id', data: { 'org-id': ['project-1'], }, }); throw new Error('Should not reach here'); } catch (error) { expect(error).to.be.instanceOf(BadRequestException); expect(error.message).to.equal('Delete Error'); assert.called(httpServiceMock.get); assert.called(httpServiceMock.delete); } }); it('should handle multiple projects with environment variables', async () => { // Reset the stub before creating a new behavior httpServiceMock.get.reset(); httpServiceMock.get.callsFake((url) => { if (url.includes('/v4/projects')) { return of({ data: { projects: [ { id: 'project-1', env: [{ id: 'env-1', key: 'NEXT_PUBLIC_NOVU_CLIENT_APP_ID', target: ['production'] }], }, { id: 'project-2', env: [{ id: 'env-2', key: 'NOVU_SECRET_KEY', target: ['production'] }], }, ], }, }); } else if (url.includes('/v9/projects/')) { return of({ data: { targets: { production: { alias: ['prod-alias.vercel.app'], }, development: { alias: ['dev-alias.vercel.app'], }, }, }, }); } return of({ data: {} }); }); organizationRepositoryMock.findByPartnerConfigurationId.resolves([ { partnerConfigurations: [{ configurationId: 'test-config-id', projectIds: ['project-1', 'project-2'] }], }, ]); const command = { userId: session.user._id, organizationId: session.organization._id, environmentId: session.environment._id, configurationId: 'test-config-id', data: { 'org-id': ['project-1', 'project-2'], }, }; const result = await updateVercelIntegration.execute(command); expect(result.success).to.equal(true); assert.calledTwice(httpServiceMock.delete); }); }); ================================================ FILE: apps/api/src/app/partner-integrations/usecases/update-vercel-integration/update-vercel-integration.usecase.ts ================================================ import { HttpService } from '@nestjs/axios'; import { BadRequestException, Injectable } from '@nestjs/common'; import { AnalyticsService, decryptApiKey, PinoLogger } from '@novu/application-generic'; import { CommunityUserRepository, EnvironmentEntity, EnvironmentRepository, MemberRepository, OrganizationRepository, } from '@novu/dal'; import { lastValueFrom } from 'rxjs'; import { Sync } from '../../../bridge/usecases/sync'; import { UpdateVercelIntegrationCommand } from './update-vercel-integration.command'; interface ISetEnvironment { name: string; token: string; projectIds: string[]; teamId: string | null; applicationIdentifier: string; privateKey: string; } interface IRemoveEnvironment { token: string; teamId: string | null; userId: string; configurationId: string; } type ProjectDetails = { projectId: string; clientAppIdEnv?: string; secretKeyEnv?: string; nextClientAppIdEnv?: string; nextApplicationIdentifierEnv?: string; }; @Injectable() export class UpdateVercelIntegration { constructor( private httpService: HttpService, private organizationRepository: OrganizationRepository, private memberRepository: MemberRepository, private communityUserRepository: CommunityUserRepository, private environmentRepository: EnvironmentRepository, private syncUsecase: Sync, private analyticsService: AnalyticsService, private logger: PinoLogger ) { this.logger.setContext(this.constructor.name); } async execute(command: UpdateVercelIntegrationCommand): Promise<{ success: boolean }> { try { const { userId, organizationId, configurationId, data: orgIdsToProjectIds } = command; const configuration = await this.getCurrentOrgPartnerConfiguration({ userId, configurationId, }); await this.removeEnvVariablesFromProjects({ teamId: configuration.teamId, token: configuration.accessToken, userId, configurationId, }); await this.organizationRepository.bulkUpdatePartnerConfiguration({ userId, data: orgIdsToProjectIds, configuration, }); const organizationIds = Object.keys(orgIdsToProjectIds); const environments = await this.getEnvironments(organizationIds); for (const env of environments) { const projectIds = orgIdsToProjectIds[env._organizationId]; await this.setEnvVariablesOnProjects({ name: env.name, applicationIdentifier: env.identifier, privateKey: decryptApiKey(env.apiKeys[0].key), projectIds, teamId: configuration.teamId, token: configuration.accessToken, }); try { await this.updateBridgeUrl( env._id, env.name, projectIds[0], configuration.accessToken, configuration.teamId, env._organizationId ); } catch (error) { this.logger.error({ err: error }, 'Error updating bridge url'); } } this.analyticsService.track('Update Vercel Integration - [Partner Integrations]', userId, { _organization: organizationId, }); return { success: true }; } catch (error) { throw new BadRequestException(error.message); } } private async updateBridgeUrl( environmentId: string, environmentName: string, projectId: string, accessToken: string, teamId: string, organizationId: string ) { try { const getDomainsResponse = await lastValueFrom( this.httpService.get(`${process.env.VERCEL_BASE_URL}/v9/projects/${projectId}?teamId=${teamId}`, { headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, }) ); const vercelAvailableTargets = getDomainsResponse.data?.targets; let vercelTarget; if (environmentName.toLowerCase() === 'production') { vercelTarget = vercelAvailableTargets?.production; } else { vercelTarget = vercelAvailableTargets?.development; } const alias = vercelTarget?.alias?.sort((a, b) => a.length - b.length)[0]; const bridgeAlias = alias || vercelTarget?.meta?.branchAlias || vercelTarget?.automaticAliases[0]; if (!bridgeAlias) { return; } const orgOwner = await this.memberRepository.getOrganizationOwnerAccount(organizationId); if (!orgOwner) { throw new BadRequestException('Organization owner not found'); } const internalUser = await this.communityUserRepository.findOne({ externalId: orgOwner?._userId }); if (!internalUser) { throw new BadRequestException('User not found'); } await this.syncUsecase.execute({ organizationId, userId: internalUser?._id as string, environmentId, bridgeUrl: `https://${bridgeAlias}/api/novu`, source: 'vercel', }); } catch (error) { this.logger.error({ err: error }, 'Error updating bridge url'); } } private async getEnvironments(organizationIds: string[]): Promise { return await this.environmentRepository.find( { _organizationId: { $in: organizationIds }, }, 'apiKeys identifier name _organizationId _id' ); } private async setEnvVariablesOnProjects({ name, applicationIdentifier, projectIds, privateKey, teamId, token, }: ISetEnvironment): Promise { const target = name?.toLowerCase() === 'production' ? ['production'] : ['preview', 'development']; const type = 'encrypted'; const environmentVariables = [ { target, type, value: applicationIdentifier, key: 'NEXT_PUBLIC_NOVU_CLIENT_APP_ID', legacy: true, }, { target, type, value: applicationIdentifier, key: 'NOVU_CLIENT_APP_ID', legacy: true, }, { target, type, value: applicationIdentifier, key: 'NEXT_PUBLIC_NOVU_APPLICATION_IDENTIFIER', }, { target, type, value: privateKey, key: 'NOVU_SECRET_KEY', }, ]; const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }; const setEnvVariable = async (projectId: string, variable: (typeof environmentVariables)[0]) => { if (variable.legacy) { return; } try { const queryParams = new URLSearchParams(); queryParams.set('upsert', 'true'); if (teamId) { queryParams.set('teamId', teamId); } await lastValueFrom( this.httpService.post( `${process.env.VERCEL_BASE_URL}/v10/projects/${projectId}/env?${queryParams.toString()}`, [variable], { headers } ) ); } catch (error) { throw new BadRequestException(error.response?.data?.error || error.response?.data); } }; await Promise.all( projectIds.flatMap((projectId) => environmentVariables.map((variable) => setEnvVariable(projectId, variable))) ); } async getCurrentOrgPartnerConfiguration({ userId, configurationId }: { userId: string; configurationId: string }) { const orgsWithIntegration = await this.organizationRepository.findByPartnerConfigurationId({ userId, configurationId, }); if (orgsWithIntegration.length === 0) { throw new BadRequestException({ message: 'No partner configuration found.', type: 'vercel', }); } const firstOrg = orgsWithIntegration[0]; const configuration = firstOrg.partnerConfigurations?.find((config) => config.configurationId === configurationId); if (!firstOrg.partnerConfigurations?.length || !configuration) { throw new BadRequestException({ message: 'No partner configuration found.', type: 'vercel', }); } return configuration; } private async getVercelLinkedProjects( accessToken: string, teamId: string | null, projectIds: string[] ): Promise { const response = await lastValueFrom( this.httpService.get(`${process.env.VERCEL_BASE_URL}/v4/projects${teamId ? `?teamId=${teamId}` : ''}`, { headers: { Authorization: `Bearer ${accessToken}`, }, }) ); const vercelProjects = response.data.projects as any[]; const filteredVercelProjects = vercelProjects.filter((project) => projectIds.includes(project.id)); return ['production', 'development'].flatMap((vercelEnvironment) => filteredVercelProjects.map((project) => { const { id } = project; const vercelEnvs = project?.env; const nextApplicationIdentifierEnv = vercelEnvs?.find( (e) => e.key === 'NEXT_PUBLIC_NOVU_APPLICATION_IDENTIFIER' && e.target.includes(vercelEnvironment) ); // Legacy env variable for existing Vercel integrations const nextClientAppIdEnv = vercelEnvs?.find( (e) => e.key === 'NEXT_PUBLIC_NOVU_CLIENT_APP_ID' && e.target.includes(vercelEnvironment) ); // Legacy env variable for existing Vercel integrations const clientAppIdEnv = vercelEnvs?.find( (e) => e.key === 'NOVU_CLIENT_APP_ID' && e.target.includes(vercelEnvironment) ); const secretKeyEnv = vercelEnvs?.find( (e) => e.key === 'NOVU_SECRET_KEY' && e.target.includes(vercelEnvironment) ); return { projectId: id, clientAppIdEnv: clientAppIdEnv?.id, secretKeyEnv: secretKeyEnv?.id, nextClientAppIdEnv: nextClientAppIdEnv?.id, nextApplicationIdentifierEnv: nextApplicationIdentifierEnv?.id, }; }) ); } private async removeEnvVariablesFromProjects({ teamId, token, userId, configurationId, }: IRemoveEnvironment): Promise { const orgsWithIntegration = await this.organizationRepository.findByPartnerConfigurationId({ userId, configurationId, }); const allOldProjectIds = [ ...new Set( orgsWithIntegration.reduce((acc, org) => { return acc.concat(org.partnerConfigurations?.[0].projectIds || []); }, []) ), ]; if (allOldProjectIds.length === 0) { return; } const vercelLinkedProjects = await this.getVercelLinkedProjects(token, teamId, allOldProjectIds); const projectApiUrl = `${process.env.VERCEL_BASE_URL}/v9/projects`; await Promise.all( vercelLinkedProjects.map((detail) => { const urls: string[] = []; if (detail.nextApplicationIdentifierEnv) { urls.push( `${projectApiUrl}/${detail.projectId}/env/${detail.nextApplicationIdentifierEnv}${teamId ? `?teamId=${teamId}` : ''}` ); } if (detail.nextClientAppIdEnv) { urls.push( `${projectApiUrl}/${detail.projectId}/env/${detail.nextClientAppIdEnv}${teamId ? `?teamId=${teamId}` : ''}` ); } if (detail.clientAppIdEnv) { urls.push( `${projectApiUrl}/${detail.projectId}/env/${detail.clientAppIdEnv}${teamId ? `?teamId=${teamId}` : ''}` ); } if (detail.secretKeyEnv) { urls.push( `${projectApiUrl}/${detail.projectId}/env/${detail.secretKeyEnv}${teamId ? `?teamId=${teamId}` : ''}` ); } const requests = urls.map((url) => lastValueFrom( this.httpService.delete(url, { headers: { Authorization: `Bearer ${token}`, }, }) ) ); return Promise.all(requests); }) ); } } ================================================ FILE: apps/api/src/app/preferences/dtos/preferences.dto.ts ================================================ import { ChannelTypeEnum } from '@novu/shared'; import { Type } from 'class-transformer'; import { IsBoolean, ValidateNested } from 'class-validator'; /** * @deprecated Use an updated preference structure. * This class will be removed in future versions. */ export class WorkflowPreference { /** * @deprecated Use alternative enablement mechanism. */ @IsBoolean() enabled: boolean; /** * @deprecated Read-only flag is no longer supported. */ @IsBoolean() readOnly: boolean; } /** * @deprecated Use an updated channel preference structure. * Will be removed in future versions. */ export class ChannelPreference { /** * @deprecated Use alternative channel enablement method. */ @IsBoolean() enabled: boolean; } /** * @deprecated Channels configuration is being restructured. * Use the new channel management approach. */ export class Channels { /** * @deprecated In-app channel preference is deprecated. */ @ValidateNested({ each: true }) @Type(() => ChannelPreference) [ChannelTypeEnum.IN_APP]: ChannelPreference; /** * @deprecated Email channel preference is deprecated. */ @ValidateNested({ each: true }) @Type(() => ChannelPreference) [ChannelTypeEnum.EMAIL]: ChannelPreference; /** * @deprecated SMS channel preference is deprecated. */ @ValidateNested({ each: true }) @Type(() => ChannelPreference) [ChannelTypeEnum.SMS]: ChannelPreference; /** * @deprecated Chat channel preference is deprecated. */ @ValidateNested({ each: true }) @Type(() => ChannelPreference) [ChannelTypeEnum.CHAT]: ChannelPreference; /** * @deprecated Push channel preference is deprecated. */ @ValidateNested({ each: true }) @Type(() => ChannelPreference) [ChannelTypeEnum.PUSH]: ChannelPreference; } /** * @deprecated Preferences DTO is being replaced. * Use the new preferences management approach. */ export class PreferencesDto { /** * @deprecated Global workflow preference is no longer used. */ @ValidateNested({ each: true }) @Type(() => WorkflowPreference) all: WorkflowPreference; /** * @deprecated Channels configuration is deprecated. */ @ValidateNested({ each: true }) @Type(() => Channels) channels: Channels; } // Optional: Runtime deprecation warning if (process.env.NODE_ENV !== 'production' && !process.env.CI) { console.warn( 'DEPRECATION WARNING: PreferencesDto and related classes are deprecated ' + 'and will be removed in future versions. Please migrate to the new preferences structure.' ); } ================================================ FILE: apps/api/src/app/preferences/dtos/upsert-preferences.dto.ts ================================================ import { Type } from 'class-transformer'; import { IsString, ValidateNested } from 'class-validator'; import { PreferencesDto } from './preferences.dto'; /** * @deprecated This DTO is no longer recommended for use. * Consider using an alternative implementation or updated data transfer object. */ export class UpsertPreferencesDto { /** * @deprecated Use an alternative workflow identification method. */ @IsString() workflowId: string; /** * @deprecated Preferences structure is outdated. */ @ValidateNested({ each: true }) @Type(() => PreferencesDto) preferences: PreferencesDto; } ================================================ FILE: apps/api/src/app/preferences/index.ts ================================================ export { PreferencesModule } from './preferences.module'; ================================================ FILE: apps/api/src/app/preferences/preferences.controller.ts ================================================ import { Body, ClassSerializerInterceptor, Controller, Delete, Get, Post, Query, UseInterceptors, } from '@nestjs/common'; import { ApiExcludeController } from '@nestjs/swagger'; import { DeletePreferencesCommand, DeletePreferencesUseCase, GetPreferences, GetPreferencesCommand, UpsertPreferences, UpsertUserWorkflowPreferencesCommand, UserSession, } from '@novu/application-generic'; import { PreferencesTypeEnum, UserSessionData } from '@novu/shared'; import { RequireAuthentication } from '../auth/framework/auth.decorator'; import { UpsertPreferencesDto } from './dtos/upsert-preferences.dto'; /** * @deprecated - set workflow preferences using the `/workflows` endpoint instead */ @Controller('/preferences') @UseInterceptors(ClassSerializerInterceptor) @RequireAuthentication() @ApiExcludeController() export class PreferencesController { constructor( private upsertPreferences: UpsertPreferences, private getPreferences: GetPreferences, private deletePreferences: DeletePreferencesUseCase ) {} @Get('/') async get(@UserSession() user: UserSessionData, @Query('workflowId') workflowId: string) { return this.getPreferences.execute( GetPreferencesCommand.create({ templateId: workflowId, environmentId: user.environmentId, organizationId: user.organizationId, }) ); } @Post('/') async upsert(@Body() data: UpsertPreferencesDto, @UserSession() user: UserSessionData) { return this.upsertPreferences.upsertUserWorkflowPreferences( UpsertUserWorkflowPreferencesCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, userId: user._id, preferences: data.preferences, templateId: data.workflowId, }) ); } @Delete('/') async delete(@UserSession() user: UserSessionData, @Query('workflowId') workflowId: string) { return this.deletePreferences.execute( DeletePreferencesCommand.create({ templateId: workflowId, environmentId: user.environmentId, organizationId: user.organizationId, userId: user._id, type: PreferencesTypeEnum.USER_WORKFLOW, }) ); } } ================================================ FILE: apps/api/src/app/preferences/preferences.module.ts ================================================ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { DeletePreferencesUseCase, GetPreferences, UpsertPreferences } from '@novu/application-generic'; import { PreferencesRepository } from '@novu/dal'; import { SharedModule } from '../shared/shared.module'; import { PreferencesController } from './preferences.controller'; const PROVIDERS = [PreferencesRepository, UpsertPreferences, GetPreferences, DeletePreferencesUseCase]; @Module({ imports: [SharedModule], providers: [...PROVIDERS], controllers: [PreferencesController], exports: [...PROVIDERS], }) export class PreferencesModule implements NestModule { public configure(consumer: MiddlewareConsumer) {} } ================================================ FILE: apps/api/src/app/preferences/preferences.spec.ts ================================================ import { Test } from '@nestjs/testing'; import { GetPreferences, UpsertPreferences, UpsertSubscriberGlobalPreferencesCommand, UpsertSubscriberWorkflowPreferencesCommand, UpsertUserWorkflowPreferencesCommand, UpsertWorkflowPreferencesCommand, } from '@novu/application-generic'; import { PreferencesRepository, SubscriberRepository } from '@novu/dal'; import { FeatureFlagsKeysEnum, PreferencesTypeEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { AuthModule } from '../auth/auth.module'; import { PreferencesModule } from './preferences.module'; describe('Preferences', () => { let getPreferences: GetPreferences; const subscriberId = SubscriberRepository.createObjectId(); const workflowId = PreferencesRepository.createObjectId(); let upsertPreferences: UpsertPreferences; let session: UserSession; beforeEach(async () => { const moduleRef = await Test.createTestingModule({ imports: [PreferencesModule, AuthModule], providers: [], }).compile(); session = new UserSession(); await session.initialize(); getPreferences = moduleRef.get(GetPreferences); upsertPreferences = moduleRef.get(UpsertPreferences); }); describe('Upsert preferences', () => { it('should create workflow preferences', async () => { const workflowPreferences = await upsertPreferences.upsertWorkflowPreferences( UpsertWorkflowPreferencesCommand.create({ preferences: { all: { enabled: false, readOnly: false, }, channels: { in_app: { enabled: false, }, sms: { enabled: false, }, email: { enabled: false, }, push: { enabled: false, }, chat: { enabled: false, }, }, }, environmentId: session.environment._id, organizationId: session.organization._id, templateId: workflowId, }) ); expect(workflowPreferences._environmentId).to.equal(session.environment._id); expect(workflowPreferences._organizationId).to.equal(session.organization._id); expect(workflowPreferences._templateId).to.equal(workflowId); expect(workflowPreferences._userId).to.be.undefined; expect(workflowPreferences._subscriberId).to.be.undefined; expect(workflowPreferences.type).to.equal(PreferencesTypeEnum.WORKFLOW_RESOURCE); }); it('should create user workflow preferences', async () => { const userPreferences = await upsertPreferences.upsertUserWorkflowPreferences( UpsertUserWorkflowPreferencesCommand.create({ preferences: { all: { enabled: false, readOnly: false, }, channels: { in_app: { enabled: false, }, sms: { enabled: false, }, email: { enabled: false, }, push: { enabled: false, }, chat: { enabled: false, }, }, }, environmentId: session.environment._id, organizationId: session.organization._id, templateId: workflowId, userId: session.user._id, }) ); expect(userPreferences._environmentId).to.equal(session.environment._id); expect(userPreferences._organizationId).to.equal(session.organization._id); expect(userPreferences._templateId).to.equal(workflowId); expect(userPreferences._userId).to.equal(session.user._id); expect(userPreferences._subscriberId).to.be.undefined; expect(userPreferences.type).to.equal(PreferencesTypeEnum.USER_WORKFLOW); }); it('should create global subscriber preferences', async () => { const subscriberGlobalPreferences = await upsertPreferences.upsertSubscriberGlobalPreferences( UpsertSubscriberGlobalPreferencesCommand.create({ preferences: { all: { enabled: false, readOnly: false, }, channels: { in_app: { enabled: false, }, sms: { enabled: false, }, email: { enabled: false, }, push: { enabled: false, }, chat: { enabled: false, }, }, }, environmentId: session.environment._id, organizationId: session.organization._id, _subscriberId: subscriberId, }) ); expect(subscriberGlobalPreferences._environmentId).to.equal(session.environment._id); expect(subscriberGlobalPreferences._organizationId).to.equal(session.organization._id); expect(subscriberGlobalPreferences._templateId).to.be.undefined; expect(subscriberGlobalPreferences._userId).to.be.undefined; expect(subscriberGlobalPreferences._subscriberId).to.equal(subscriberId); expect(subscriberGlobalPreferences.type).to.equal(PreferencesTypeEnum.SUBSCRIBER_GLOBAL); }); it('should create subscriber workflow preferences', async () => { const subscriberWorkflowPreferences = await upsertPreferences.upsertSubscriberWorkflowPreferences( UpsertSubscriberWorkflowPreferencesCommand.create({ preferences: { all: { enabled: false, readOnly: false, }, channels: { in_app: { enabled: false, }, sms: { enabled: false, }, email: { enabled: false, }, push: { enabled: false, }, chat: { enabled: false, }, }, }, environmentId: session.environment._id, organizationId: session.organization._id, templateId: workflowId, _subscriberId: subscriberId, }) ); expect(subscriberWorkflowPreferences._environmentId).to.equal(session.environment._id); expect(subscriberWorkflowPreferences._organizationId).to.equal(session.organization._id); expect(subscriberWorkflowPreferences._templateId).to.equal(workflowId); expect(subscriberWorkflowPreferences._userId).to.be.undefined; expect(subscriberWorkflowPreferences._subscriberId).to.equal(subscriberId); expect(subscriberWorkflowPreferences.type).to.equal(PreferencesTypeEnum.SUBSCRIBER_WORKFLOW); }); it('should update preferences', async () => { let workflowPreferences = await upsertPreferences.upsertWorkflowPreferences( UpsertWorkflowPreferencesCommand.create({ preferences: { all: { enabled: false, readOnly: false, }, channels: { in_app: { enabled: false, }, sms: { enabled: false, }, email: { enabled: false, }, push: { enabled: false, }, chat: { enabled: false, }, }, }, environmentId: session.environment._id, organizationId: session.organization._id, templateId: workflowId, }) ); expect(workflowPreferences._environmentId).to.equal(session.environment._id); expect(workflowPreferences._organizationId).to.equal(session.organization._id); expect(workflowPreferences._templateId).to.equal(workflowId); expect(workflowPreferences._userId).to.be.undefined; expect(workflowPreferences._subscriberId).to.be.undefined; expect(workflowPreferences.type).to.equal(PreferencesTypeEnum.WORKFLOW_RESOURCE); workflowPreferences = await upsertPreferences.upsertWorkflowPreferences( UpsertWorkflowPreferencesCommand.create({ preferences: { all: { enabled: false, readOnly: true, }, channels: { in_app: { enabled: false, }, sms: { enabled: false, }, email: { enabled: false, }, push: { enabled: false, }, chat: { enabled: false, }, }, }, environmentId: session.environment._id, organizationId: session.organization._id, templateId: workflowId, }) ); expect(workflowPreferences.preferences.all.readOnly).to.be.true; }); }); describe('Get preferences', () => { it('should merge preferences when get preferences', async () => { // Workflow preferences await upsertPreferences.upsertWorkflowPreferences( UpsertWorkflowPreferencesCommand.create({ preferences: { all: { enabled: false, readOnly: false, }, channels: { in_app: { enabled: false, }, sms: { enabled: false, }, email: { enabled: false, }, push: { enabled: false, }, chat: { enabled: false, }, }, }, environmentId: session.environment._id, organizationId: session.organization._id, templateId: workflowId, }) ); let preferences = await getPreferences.execute({ environmentId: session.environment._id, organizationId: session.organization._id, templateId: workflowId, }); expect(preferences).to.deep.equal({ preferences: { all: { enabled: false, readOnly: false, }, channels: { in_app: { enabled: false, }, sms: { enabled: false, }, email: { enabled: false, }, push: { enabled: false, }, chat: { enabled: false, }, }, }, schedule: undefined, type: PreferencesTypeEnum.WORKFLOW_RESOURCE, source: { [PreferencesTypeEnum.WORKFLOW_RESOURCE]: { all: { enabled: false, readOnly: false, }, channels: { in_app: { enabled: false, }, sms: { enabled: false, }, email: { enabled: false, }, push: { enabled: false, }, chat: { enabled: false, }, }, }, [PreferencesTypeEnum.USER_WORKFLOW]: null, [PreferencesTypeEnum.SUBSCRIBER_GLOBAL]: null, [PreferencesTypeEnum.SUBSCRIBER_WORKFLOW]: null, }, }); // User Workflow preferences await upsertPreferences.upsertUserWorkflowPreferences( UpsertUserWorkflowPreferencesCommand.create({ preferences: { all: { enabled: false, readOnly: true, }, channels: { in_app: { enabled: false, }, sms: { enabled: false, }, email: { enabled: false, }, push: { enabled: false, }, chat: { enabled: false, }, }, }, environmentId: session.environment._id, organizationId: session.organization._id, templateId: workflowId, userId: session.user._id, }) ); preferences = await getPreferences.execute({ environmentId: session.environment._id, organizationId: session.organization._id, templateId: workflowId, }); expect(preferences).to.deep.equal({ preferences: { all: { enabled: false, readOnly: true, }, channels: { in_app: { enabled: false, }, sms: { enabled: false, }, email: { enabled: false, }, push: { enabled: false, }, chat: { enabled: false, }, }, }, schedule: undefined, type: PreferencesTypeEnum.USER_WORKFLOW, source: { [PreferencesTypeEnum.WORKFLOW_RESOURCE]: { all: { enabled: false, readOnly: false, }, channels: { in_app: { enabled: false, }, sms: { enabled: false, }, email: { enabled: false, }, push: { enabled: false, }, chat: { enabled: false, }, }, }, [PreferencesTypeEnum.USER_WORKFLOW]: { all: { enabled: false, readOnly: true, }, channels: { in_app: { enabled: false, }, sms: { enabled: false, }, email: { enabled: false, }, push: { enabled: false, }, chat: { enabled: false, }, }, }, [PreferencesTypeEnum.SUBSCRIBER_GLOBAL]: null, [PreferencesTypeEnum.SUBSCRIBER_WORKFLOW]: null, }, }); // Subscriber global preferences await upsertPreferences.upsertSubscriberGlobalPreferences( UpsertSubscriberGlobalPreferencesCommand.create({ preferences: { all: { enabled: false, readOnly: true, }, channels: { in_app: { enabled: false, }, sms: { enabled: false, }, email: { enabled: false, }, push: { enabled: false, }, chat: { enabled: false, }, }, }, environmentId: session.environment._id, organizationId: session.organization._id, _subscriberId: subscriberId, }) ); preferences = await getPreferences.execute({ environmentId: session.environment._id, organizationId: session.organization._id, templateId: workflowId, subscriberId, }); expect(preferences).to.deep.equal({ preferences: { all: { enabled: false, readOnly: true, }, channels: { in_app: { enabled: false, }, sms: { enabled: false, }, email: { enabled: false, }, push: { enabled: false, }, chat: { enabled: false, }, }, }, schedule: undefined, type: PreferencesTypeEnum.USER_WORKFLOW, source: { [PreferencesTypeEnum.WORKFLOW_RESOURCE]: { all: { enabled: false, readOnly: false, }, channels: { in_app: { enabled: false, }, sms: { enabled: false, }, email: { enabled: false, }, push: { enabled: false, }, chat: { enabled: false, }, }, }, [PreferencesTypeEnum.USER_WORKFLOW]: { all: { enabled: false, readOnly: true, }, channels: { in_app: { enabled: false, }, sms: { enabled: false, }, email: { enabled: false, }, push: { enabled: false, }, chat: { enabled: false, }, }, }, [PreferencesTypeEnum.SUBSCRIBER_GLOBAL]: { all: { enabled: false, readOnly: true, }, channels: { in_app: { enabled: false, }, sms: { enabled: false, }, email: { enabled: false, }, push: { enabled: false, }, chat: { enabled: false, }, }, }, [PreferencesTypeEnum.SUBSCRIBER_WORKFLOW]: null, }, }); // Subscriber Workflow preferences await upsertPreferences.upsertSubscriberWorkflowPreferences( UpsertSubscriberWorkflowPreferencesCommand.create({ preferences: { all: { enabled: false, readOnly: true, }, channels: { in_app: { enabled: false, }, sms: { enabled: false, }, email: { enabled: false, }, push: { enabled: false, }, chat: { enabled: false, }, }, }, environmentId: session.environment._id, organizationId: session.organization._id, templateId: workflowId, _subscriberId: subscriberId, }) ); preferences = await getPreferences.execute({ environmentId: session.environment._id, organizationId: session.organization._id, templateId: workflowId, subscriberId, }); expect(preferences).to.deep.equal({ preferences: { all: { enabled: false, readOnly: true, }, channels: { in_app: { enabled: false, }, sms: { enabled: false, }, email: { enabled: false, }, push: { enabled: false, }, chat: { enabled: false, }, }, }, schedule: undefined, type: PreferencesTypeEnum.USER_WORKFLOW, source: { [PreferencesTypeEnum.WORKFLOW_RESOURCE]: { all: { enabled: false, readOnly: false, }, channels: { in_app: { enabled: false, }, sms: { enabled: false, }, email: { enabled: false, }, push: { enabled: false, }, chat: { enabled: false, }, }, }, [PreferencesTypeEnum.USER_WORKFLOW]: { all: { enabled: false, readOnly: true, }, channels: { in_app: { enabled: false, }, sms: { enabled: false, }, email: { enabled: false, }, push: { enabled: false, }, chat: { enabled: false, }, }, }, [PreferencesTypeEnum.SUBSCRIBER_GLOBAL]: { all: { enabled: false, readOnly: true, }, channels: { in_app: { enabled: false, }, sms: { enabled: false, }, email: { enabled: false, }, push: { enabled: false, }, chat: { enabled: false, }, }, }, [PreferencesTypeEnum.SUBSCRIBER_WORKFLOW]: { all: { enabled: false, readOnly: true, }, channels: { in_app: { enabled: false, }, sms: { enabled: false, }, email: { enabled: false, }, push: { enabled: false, }, chat: { enabled: false, }, }, }, }, }); }); }); describe('Preferences endpoints', () => { it('should get preferences', async () => { const useCase: UpsertPreferences = session.testServer?.getService(UpsertPreferences); await useCase.upsertWorkflowPreferences( UpsertWorkflowPreferencesCommand.create({ preferences: { all: { enabled: false, readOnly: false, }, channels: { in_app: { enabled: false, }, sms: { enabled: false, }, email: { enabled: false, }, push: { enabled: false, }, chat: { enabled: false, }, }, }, environmentId: session.environment._id, organizationId: session.organization._id, templateId: workflowId, }) ); const { body } = await session.testAgent.get(`/v1/preferences?workflowId=${workflowId}`).send(); expect(body.data).to.deep.equal({ preferences: { all: { enabled: false, readOnly: false, }, channels: { in_app: { enabled: false, }, sms: { enabled: false, }, email: { enabled: false, }, push: { enabled: false, }, chat: { enabled: false, }, }, }, type: PreferencesTypeEnum.WORKFLOW_RESOURCE, source: { [PreferencesTypeEnum.WORKFLOW_RESOURCE]: { all: { enabled: false, readOnly: false, }, channels: { in_app: { enabled: false, }, sms: { enabled: false, }, email: { enabled: false, }, push: { enabled: false, }, chat: { enabled: false, }, }, }, [PreferencesTypeEnum.USER_WORKFLOW]: null, [PreferencesTypeEnum.SUBSCRIBER_GLOBAL]: null, [PreferencesTypeEnum.SUBSCRIBER_WORKFLOW]: null, }, }); }); it('should upsert preferences', async () => { const { body } = await session.testAgent.post('/v1/preferences').send({ workflowId, preferences: { all: { enabled: false, readOnly: false, }, channels: { in_app: { enabled: false, }, sms: { enabled: false, }, email: { enabled: false, }, push: { enabled: false, }, chat: { enabled: false, }, }, }, }); expect(body.data.preferences).to.deep.equal({ all: { enabled: false, readOnly: false, }, channels: { in_app: { enabled: false, }, sms: { enabled: false, }, email: { enabled: false, }, push: { enabled: false, }, chat: { enabled: false, }, }, }); }); }); }); ================================================ FILE: apps/api/src/app/rate-limiting/e2e/throttler.guard.e2e.ts ================================================ import { HttpResponseHeaderKeysEnum } from '@novu/application-generic'; import { ApiRateLimitCategoryEnum, ApiRateLimitCostEnum, ApiServiceLevelEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; const mockSingleCost = 1; const mockBulkCost = 5; const mockWindowDuration = 5; const mockBurstAllowance = 1; const mockMaximumFreeTrigger = 5; const mockMaximumFreeGlobal = 3; const mockMaximumUnlimitedTrigger = 10; const mockMaximumUnlimitedGlobal = 5; process.env.API_RATE_LIMIT_COST_SINGLE = `${mockSingleCost}`; process.env.API_RATE_LIMIT_COST_BULK = `${mockBulkCost}`; process.env.API_RATE_LIMIT_ALGORITHM_WINDOW_DURATION = `${mockWindowDuration}`; process.env.API_RATE_LIMIT_ALGORITHM_BURST_ALLOWANCE = `${mockBurstAllowance}`; process.env.API_RATE_LIMIT_MAXIMUM_FREE_TRIGGER = `${mockMaximumFreeTrigger}`; process.env.API_RATE_LIMIT_MAXIMUM_FREE_GLOBAL = `${mockMaximumFreeGlobal}`; process.env.API_RATE_LIMIT_MAXIMUM_UNLIMITED_TRIGGER = `${mockMaximumUnlimitedTrigger}`; process.env.API_RATE_LIMIT_MAXIMUM_UNLIMITED_GLOBAL = `${mockMaximumUnlimitedGlobal}`; // Disable Launch Darkly to allow test to define FF state (process.env as Record).LAUNCH_DARKLY_SDK_KEY = ''; describe('API Rate Limiting #novu-v2', () => { let session: UserSession; const pathPrefix = '/v1/rate-limiting'; let request: ( path: string, authHeader?: string ) => Promise>>; describe('Guard logic', () => { beforeEach(async () => { (process.env as Record).IS_API_RATE_LIMITING_ENABLED = 'true'; session = new UserSession(); await session.initialize(); await session.updateOrganizationServiceLevel(ApiServiceLevelEnum.UNLIMITED); request = (path: string, authHeader = `ApiKey ${session.apiKey}`) => session.testAgent.get(path).set('authorization', authHeader); }); describe('Feature Flag', () => { it('should set rate limit headers when the Feature Flag is enabled', async () => { (process.env as Record).IS_API_RATE_LIMITING_ENABLED = 'true'; const response = await request(`${pathPrefix}/no-category-no-cost`); expect(response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_LIMIT.toLowerCase()]).to.exist; }); it('should NOT set rate limit headers when the Feature Flag is disabled', async () => { (process.env as Record).IS_API_RATE_LIMITING_ENABLED = 'false'; const response = await request(`${pathPrefix}/no-category-no-cost`); expect(response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_LIMIT.toLowerCase()]).not.to.exist; }); }); describe('Allowed Authentication Security Schemes', () => { it('should set rate limit headers when ApiKey security scheme is used to authenticate', async () => { const response = await request(`${pathPrefix}/no-category-no-cost`, `ApiKey ${session.apiKey}`); expect(response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_LIMIT.toLowerCase()]).to.exist; }); it('should NOT set rate limit headers when a Bearer security scheme is used to authenticate', async () => { const response = await request(`${pathPrefix}/no-category-no-cost`, session.token); expect(response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_LIMIT.toLowerCase()]).not.to.exist; }); it('should NOT set rate limit headers when NO authorization header is present', async () => { const response = await request(`${pathPrefix}/no-category-no-cost`, ''); expect(response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_LIMIT.toLowerCase()]).not.to.exist; }); }); describe('RateLimit-Policy', () => { const testParams: Array<{ name: string; expectedRegex: string }> = [ { name: 'limit', expectedRegex: `${mockMaximumUnlimitedGlobal * mockWindowDuration}` }, { name: 'w', expectedRegex: `w=${mockWindowDuration}` }, { name: 'burst', expectedRegex: `burst=${mockMaximumUnlimitedGlobal * (1 + mockBurstAllowance) * mockWindowDuration}`, }, { name: 'comment', expectedRegex: `comment="[a-zA-Z ]*"` }, { name: 'category', expectedRegex: `category="(${Object.values(ApiRateLimitCategoryEnum).join('|')})"` }, { name: 'cost', expectedRegex: `cost="(${Object.values(ApiRateLimitCostEnum).join('|')})"` }, { name: 'serviceLevel', expectedRegex: `serviceLevel="[a-zA-Z]*"`, }, ]; testParams.forEach(({ name, expectedRegex }) => { it(`should include the ${name} parameter`, async () => { const response = await request(`${pathPrefix}/no-category-no-cost`); const policyHeader = response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_POLICY.toLowerCase()]; expect(policyHeader).to.match(new RegExp(expectedRegex)); }); }); it('should separate the params with a semicolon', async () => { const response = await request(`${pathPrefix}/no-category-no-cost`); const policyHeader = response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_POLICY.toLowerCase()]; expect(policyHeader.split(';')).to.have.lengthOf(testParams.length); }); }); describe('Rate Limit Decorators', () => { describe('Controller WITHOUT Decorators', () => { const controllerPathPrefix = '/v1/rate-limiting'; it('should use the global category for an endpoint WITHOUT category decorator', async () => { const response = await request(`${controllerPathPrefix}/no-category-no-cost`); const policyHeader = response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_POLICY.toLowerCase()]; expect(policyHeader).to.contain(`category="${ApiRateLimitCategoryEnum.GLOBAL}"`); }); it('should use the single cost for an endpoint WITHOUT cost decorator', async () => { const response = await request(`${controllerPathPrefix}/no-category-no-cost`); const policyHeader = response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_POLICY.toLowerCase()]; expect(policyHeader).to.contain(`cost="${ApiRateLimitCostEnum.SINGLE}"`); }); }); describe('Controller WITH Decorators', () => { const controllerPathPrefix = '/v1/rate-limiting-trigger-bulk'; it('should use the category decorator defined on the controller for an endpoint WITHOUT category decorator', async () => { const response = await request(`${controllerPathPrefix}/no-category-no-cost-override`); const policyHeader = response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_POLICY.toLowerCase()]; expect(policyHeader).to.contain(`category="${ApiRateLimitCategoryEnum.TRIGGER}"`); }); it('should use the cost decorator defined on the controller for an endpoint WITHOUT cost decorator', async () => { const response = await request(`${controllerPathPrefix}/no-category-no-cost-override`); const policyHeader = response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_POLICY.toLowerCase()]; expect(policyHeader).to.contain(`cost="${ApiRateLimitCostEnum.BULK}"`); }); it('should override the cost decorator defined on the controller for an endpoint WITH cost decorator', async () => { const response = await request(`${controllerPathPrefix}/no-category-single-cost-override`); const policyHeader = response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_POLICY.toLowerCase()]; expect(policyHeader).to.contain(`cost="${ApiRateLimitCostEnum.SINGLE}"`); }); it('should override the category decorator defined on the controller for an endpoint WITH category decorator', async () => { const response = await request(`${controllerPathPrefix}/global-category-no-cost-override`); const policyHeader = response.headers[HttpResponseHeaderKeysEnum.RATELIMIT_POLICY.toLowerCase()]; expect(policyHeader).to.contain(`category="${ApiRateLimitCategoryEnum.GLOBAL}"`); }); }); }); }); describe('Scenarios', () => { type TestCase = { name: string; requests: { path: string; count: number }[]; expectedStatus: number; expectedLimit: number; expectedCost: number; expectedReset: number; expectedRetryAfter?: number; expectedThrottledRequests: number; setupTest?: (userSession: UserSession) => Promise; }; const testCases: TestCase[] = [ { name: 'single trigger endpoint request', requests: [{ path: '/trigger-category-single-cost', count: 1 }], expectedStatus: 200, expectedLimit: mockMaximumUnlimitedTrigger, expectedCost: mockSingleCost * 1, expectedReset: 1, expectedThrottledRequests: 0, async setupTest(userSession) { await userSession.updateOrganizationServiceLevel(ApiServiceLevelEnum.UNLIMITED); }, }, { name: 'no category no cost endpoint request', requests: [{ path: '/no-category-no-cost', count: 1 }], expectedStatus: 200, expectedLimit: mockMaximumUnlimitedGlobal, expectedCost: mockSingleCost * 1, expectedReset: 1, expectedThrottledRequests: 0, async setupTest(userSession) { await userSession.updateOrganizationServiceLevel(ApiServiceLevelEnum.UNLIMITED); }, }, { name: 'single trigger request with service level specified on organization ', requests: [{ path: '/trigger-category-single-cost', count: 1 }], expectedStatus: 200, expectedLimit: mockMaximumFreeTrigger, expectedCost: mockSingleCost * 1, expectedReset: 1, expectedThrottledRequests: 0, async setupTest(userSession) { await userSession.updateOrganizationServiceLevel(ApiServiceLevelEnum.FREE); }, }, { name: 'single trigger request with maximum rate limit specified on environment', requests: [{ path: '/trigger-category-single-cost', count: 1 }], expectedStatus: 200, expectedLimit: 60, expectedCost: mockSingleCost * 1, expectedReset: 1, expectedThrottledRequests: 0, async setupTest(userSession) { await userSession.updateOrganizationServiceLevel(ApiServiceLevelEnum.UNLIMITED); await userSession.updateEnvironmentApiRateLimits({ [ApiRateLimitCategoryEnum.TRIGGER]: 60 }); }, }, { name: 'combination of single trigger and single global endpoint request', requests: [ { path: '/trigger-category-single-cost', count: 20 }, { path: '/global-category-single-cost', count: 100 }, ], expectedStatus: 429, expectedLimit: mockMaximumUnlimitedGlobal, expectedCost: mockSingleCost * 100, expectedReset: 1, expectedRetryAfter: 1, expectedThrottledRequests: 50, async setupTest(userSession) { await userSession.updateOrganizationServiceLevel(ApiServiceLevelEnum.UNLIMITED); }, }, ]; testCases .map( ({ name, requests, expectedStatus, expectedLimit, expectedCost, expectedReset, expectedRetryAfter, expectedThrottledRequests, setupTest, }) => { return () => { describe(`${expectedStatus === 429 ? 'Throttled' : 'Allowed'} ${name}`, () => { let lastResponse; let throttledResponseCount = 0; const throttledResponseCountTolerance = 0.5; const expectedWindowLimit = expectedLimit * mockWindowDuration; const expectedBurstLimit = expectedWindowLimit * (1 + mockBurstAllowance); const expectedRemaining = Math.max(0, expectedBurstLimit - expectedCost); before(async () => { (process.env as Record).IS_API_RATE_LIMITING_ENABLED = 'true'; session = new UserSession(); await session.initialize(); request = (path: string, authHeader = `ApiKey ${session.apiKey}`) => session.testAgent.get(path).set('authorization', authHeader); setupTest && (await setupTest(session)); for (const { path, count } of requests) { for (let index = 0; index < count; index += 1) { const response = await request(pathPrefix + path); lastResponse = response; if (response.statusCode === 429) { throttledResponseCount += 1; } } } }); it(`should return a ${expectedStatus} status code`, async () => { expect(lastResponse.statusCode).to.equal(expectedStatus); }); it(`should return a ${HttpResponseHeaderKeysEnum.RATELIMIT_LIMIT} header of ${expectedWindowLimit}`, async () => { expect(lastResponse.headers[HttpResponseHeaderKeysEnum.RATELIMIT_LIMIT.toLowerCase()]).to.equal( `${expectedWindowLimit}` ); }); it(`should return a ${HttpResponseHeaderKeysEnum.RATELIMIT_REMAINING} header of ${expectedRemaining}`, async () => { expect(lastResponse.headers[HttpResponseHeaderKeysEnum.RATELIMIT_REMAINING.toLowerCase()]).to.equal( `${expectedRemaining}` ); }); it(`should return a ${HttpResponseHeaderKeysEnum.RATELIMIT_RESET} header of ${expectedReset}`, async () => { expect(lastResponse.headers[HttpResponseHeaderKeysEnum.RATELIMIT_RESET.toLowerCase()]).to.equal( `${expectedReset}` ); }); it(`should return a ${HttpResponseHeaderKeysEnum.RETRY_AFTER} header of ${expectedRetryAfter}`, async () => { expect(lastResponse.headers[HttpResponseHeaderKeysEnum.RETRY_AFTER.toLowerCase()]).to.equal( expectedRetryAfter && `${expectedRetryAfter}` ); }); const expectedMinThrottled = Math.floor( expectedThrottledRequests * (1 - throttledResponseCountTolerance) ); const expectedMaxThrottled = Math.ceil(expectedThrottledRequests * (1 + throttledResponseCountTolerance)); it(`should have between ${expectedMinThrottled} and ${expectedMaxThrottled} requests throttled`, async () => { expect(throttledResponseCount).to.be.greaterThanOrEqual(expectedMinThrottled); expect(throttledResponseCount).to.be.lessThanOrEqual(expectedMaxThrottled); }); }); }; } ) .forEach((testCase) => { testCase(); }); }); }); ================================================ FILE: apps/api/src/app/rate-limiting/guards/index.ts ================================================ export * from './throttler.decorator'; export * from './throttler.guard'; ================================================ FILE: apps/api/src/app/rate-limiting/guards/throttler.decorator.ts ================================================ import { Reflector } from '@nestjs/core'; import { ApiRateLimitCategoryEnum, ApiRateLimitCostEnum } from '@novu/shared'; export const ThrottlerCategory = Reflector.createDecorator(); export const ThrottlerCost = Reflector.createDecorator(); ================================================ FILE: apps/api/src/app/rate-limiting/guards/throttler.guard.ts ================================================ import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { InjectThrottlerOptions, InjectThrottlerStorage, ThrottlerException, ThrottlerGuard, ThrottlerModuleOptions, ThrottlerRequest, ThrottlerStorage, } from '@nestjs/throttler'; import { FeatureFlagsService, HttpRequestHeaderKeysEnum, HttpResponseHeaderKeysEnum, Instrument, PinoLogger, } from '@novu/application-generic'; import { EnvironmentEntity, OrganizationEntity, UserEntity } from '@novu/dal'; import { ApiAuthSchemeEnum, ApiRateLimitCategoryEnum, ApiRateLimitCostEnum, FeatureFlagsKeysEnum, UserSessionData, } from '@novu/shared'; import { getClientIp } from 'request-ip'; import { checkIsKeylessHeader } from '../../shared/utils/auth.utils'; import { EvaluateApiRateLimit, EvaluateApiRateLimitCommand } from '../usecases/evaluate-api-rate-limit'; import { ThrottlerCategory, ThrottlerCost } from './throttler.decorator'; export const THROTTLED_EXCEPTION_MESSAGE = 'API rate limit exceeded'; export const ALLOWED_AUTH_SCHEMES = [ApiAuthSchemeEnum.API_KEY, ApiAuthSchemeEnum.KEYLESS]; const defaultApiRateLimitCategory = ApiRateLimitCategoryEnum.GLOBAL; const defaultApiRateLimitCost = ApiRateLimitCostEnum.SINGLE; /** * An interceptor is used instead of a guard to ensure that Auth context is available. * This is currently necessary because we do not currently have a global guard configured for Auth, * therefore the Auth context is not guaranteed to be available in the guard. */ @Injectable() export class ApiRateLimitInterceptor extends ThrottlerGuard implements NestInterceptor { constructor( @InjectThrottlerOptions() protected readonly options: ThrottlerModuleOptions, @InjectThrottlerStorage() protected readonly storageService: ThrottlerStorage, reflector: Reflector, private evaluateApiRateLimit: EvaluateApiRateLimit, private featureFlagService: FeatureFlagsService, private logger: PinoLogger ) { super(options, storageService, reflector); this.logger.setContext(this.constructor.name); } /** * Thin wrapper around the ThrottlerGuard's canActivate method. */ async intercept(context: ExecutionContext, next: CallHandler) { await this.canActivate(context); return next.handle(); } @Instrument() canActivate(context: ExecutionContext): Promise { return super.canActivate(context); } protected async shouldSkip(context: ExecutionContext): Promise { const req = context.switchToHttp().getRequest(); const isAllowedAuthScheme = this.isAllowedAuthScheme(context); const isAllowedEnvironment = this.isAllowedEnvironment(context); const isAllowedRoute = this.isAllowedRoute(context); if (!isAllowedAuthScheme && !isAllowedEnvironment && !isAllowedRoute) { this.logger.debug( { _nv: { isAllowedAuthScheme, isAllowedEnvironment, isAllowedRoute, path: req.path, authScheme: req.authScheme, }, }, 'Rate limiting skipped - request criteria not met' ); return true; } const user = this.getReqUser(context); // Indicates whether the request originates from a Inbox session initialization if (!user) { return false; } const { organizationId, environmentId, _id } = user; const isEnabled = await this.featureFlagService.getFlag({ key: FeatureFlagsKeysEnum.IS_API_RATE_LIMITING_ENABLED, defaultValue: false, environment: { _id: environmentId } as EnvironmentEntity, organization: { _id: organizationId } as OrganizationEntity, user: { _id } as UserEntity, }); if (!isEnabled) { this.logger.debug({ message: 'Rate limiting skipped - feature flag disabled', _event: { organizationId, environmentId, }, }); } return !isEnabled; } /** * Throttles incoming HTTP requests. * All the outgoing requests will contain RFC-compatible RateLimit headers. * @see https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/ * @throws {ThrottlerException} */ protected async handleRequest({ context, throttler }: ThrottlerRequest): Promise { const { req, res } = this.getRequestResponse(context); const clientIp = getClientIp(req) || undefined; const ignoreUserAgents = throttler.ignoreUserAgents ?? this.commonOptions.ignoreUserAgents; // Return early if the current user agent should be ignored. if (Array.isArray(ignoreUserAgents)) { for (const pattern of ignoreUserAgents) { if (pattern.test(req.headers[HttpRequestHeaderKeysEnum.USER_AGENT.toLowerCase()])) { return true; } } } const handler = context.getHandler(); const classRef = context.getClass(); const isKeylessHeader = checkIsKeylessHeader(req.headers.authorization) || checkIsKeylessHeader(req.headers['novu-application-identifier']); const isKeylessRequest = isKeylessHeader || this.isKeylessRoute(context); const apiRateLimitCategory = this.reflector.getAllAndOverride(ThrottlerCategory, [handler, classRef]) || defaultApiRateLimitCategory; const user = this.getReqUser(context); const organizationId = user?.organizationId; const _id = user?._id; const environmentId = user?.environmentId || req.headers['novu-application-identifier']; const apiRateLimitCost = isKeylessRequest ? getKeylessCost() : this.reflector.getAllAndOverride(ThrottlerCost, [handler, classRef]) || defaultApiRateLimitCost; const evaluateCommand = EvaluateApiRateLimitCommand.create({ organizationId, environmentId, apiRateLimitCategory, apiRateLimitCost, ip: isKeylessRequest ? clientIp : undefined, }); const { success, limit, remaining, reset, windowDuration, burstLimit, algorithm, apiServiceLevel } = await this.evaluateApiRateLimit.execute(evaluateCommand); const secondsToReset = Math.max(Math.ceil((reset - Date.now()) / 1e3), 0); this.logger.debug({ message: 'Rate limit evaluated', _event: { success, limit, remaining, category: apiRateLimitCategory, cost: apiRateLimitCost, isKeyless: isKeylessRequest, organizationId, environmentId, ip: clientIp, }, }); /** * The purpose of the dry run is to allow us to observe how * the rate limiting would behave without actually enforcing it. */ const isDryRun = await this.featureFlagService.getFlag({ environment: { _id: environmentId } as EnvironmentEntity, organization: { _id: organizationId } as OrganizationEntity, user: { _id } as UserEntity, key: FeatureFlagsKeysEnum.IS_API_RATE_LIMITING_DRY_RUN_ENABLED, defaultValue: false, }); const isKeylessDryRunFlag = await this.featureFlagService.getFlag({ environment: { _id: environmentId } as EnvironmentEntity, organization: { _id: organizationId } as OrganizationEntity, user: { _id, email: user?.email } as UserEntity, key: FeatureFlagsKeysEnum.IS_API_RATE_LIMITING_KEYLESS_DRY_RUN_ENABLED, defaultValue: false, }); const isKeylessDryRun = isKeylessRequest && isKeylessDryRunFlag; res.header(HttpResponseHeaderKeysEnum.RATELIMIT_REMAINING, remaining); res.header(HttpResponseHeaderKeysEnum.RATELIMIT_LIMIT, limit); res.header(HttpResponseHeaderKeysEnum.RATELIMIT_RESET, secondsToReset); res.header( HttpResponseHeaderKeysEnum.RATELIMIT_POLICY, this.createPolicyHeader( limit, windowDuration, burstLimit, algorithm, apiRateLimitCategory, apiRateLimitCost, apiServiceLevel ) ); res.rateLimitPolicy = { limit, windowDuration, burstLimit, algorithm, apiRateLimitCategory, apiRateLimitCost, apiServiceLevel, }; if (isDryRun || isKeylessDryRun) { if (!success) { this.logger.warn({ message: `${isKeylessRequest ? '[Dry run] [Keyless]' : '[Dry run]'} Rate limit would be exceeded`, _event: { limit, remaining, organizationId, environmentId, ip: clientIp, }, }); } return true; } if (success) { return true; } else { res.header(HttpResponseHeaderKeysEnum.RETRY_AFTER, secondsToReset); this.logger.debug({ message: 'Rate limit exceeded', _event: { limit, remaining, retryAfter: secondsToReset, category: apiRateLimitCategory, organizationId, environmentId, ip: clientIp, isKeyless: isKeylessRequest, }, }); throw new ThrottlerException(THROTTLED_EXCEPTION_MESSAGE); } } private createPolicyHeader( limit: number, windowDuration: number, burstLimit: number, algorithm: string, apiRateLimitCategory: ApiRateLimitCategoryEnum, apiRateLimitCost: ApiRateLimitCostEnum, apiServiceLevel: string ): string { const policyMap = { w: windowDuration, burst: burstLimit, comment: `"${algorithm}"`, category: `"${apiRateLimitCategory}"`, cost: `"${apiRateLimitCost}"`, serviceLevel: `"${apiServiceLevel}"`, }; const policy = Object.entries(policyMap).reduce((acc, [key, value]) => { return `${acc};${key}=${value}`; }, `${limit}`); return policy; } private isAllowedAuthScheme(context: ExecutionContext): boolean { const { authScheme } = context.switchToHttp().getRequest(); return ALLOWED_AUTH_SCHEMES.some((scheme) => authScheme === scheme); } private isAllowedEnvironment(context: ExecutionContext): boolean { const req = context.switchToHttp().getRequest(); const applicationIdentifier = req.headers['novu-application-identifier']; if (!applicationIdentifier) { return false; } return applicationIdentifier.startsWith('pk_keyless_'); } private isAllowedRoute(context: ExecutionContext): boolean { return this.isKeylessRoute(context); } private isKeylessRoute(context: ExecutionContext): boolean { const req = context.switchToHttp().getRequest(); return req.path === '/v1/inbox/session' && req.method === 'POST'; } private getReqUser(context: ExecutionContext): UserSessionData | undefined { const req = context.switchToHttp().getRequest(); return req.user; } } function getKeylessCost() { // For test environment, we use a higher cost to ensure tests can run without rate limiting issues return process.env.NODE_ENV === 'test' ? defaultApiRateLimitCost : ApiRateLimitCostEnum.KEYLESS; } ================================================ FILE: apps/api/src/app/rate-limiting/rate-limiting.module.ts ================================================ import { Module } from '@nestjs/common'; import { ThrottlerModule } from '@nestjs/throttler'; import { CommunityOrganizationRepository } from '@novu/dal'; import { SharedModule } from '../shared/shared.module'; import { ApiRateLimitInterceptor } from './guards'; import { USE_CASES } from './usecases'; @Module({ imports: [ SharedModule, ThrottlerModule.forRoot([ // The following configuration is required for the NestJS ThrottlerModule to work. It has no effect. { ttl: 60000, limit: 10, }, ]), ], providers: [...USE_CASES, ApiRateLimitInterceptor, CommunityOrganizationRepository], exports: [...USE_CASES, ApiRateLimitInterceptor], }) export class RateLimitingModule {} ================================================ FILE: apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/evaluate-api-rate-limit.command.ts ================================================ import { BaseCommand } from '@novu/application-generic'; import { ApiRateLimitCategoryEnum, ApiRateLimitCostEnum } from '@novu/shared'; import { IsDefined, IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; export class EvaluateApiRateLimitCommand extends BaseCommand { @IsOptional() @IsString() readonly environmentId?: string; @IsOptional() @IsString() readonly organizationId?: string; @IsDefined() @IsEnum(ApiRateLimitCategoryEnum) apiRateLimitCategory: ApiRateLimitCategoryEnum; @IsDefined() @IsEnum(ApiRateLimitCostEnum) apiRateLimitCost: ApiRateLimitCostEnum; @IsOptional() @IsString() ip?: string; } ================================================ FILE: apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/evaluate-api-rate-limit.spec.ts ================================================ import { Test } from '@nestjs/testing'; import { ApiRateLimitAlgorithmEnum, ApiRateLimitCategoryEnum, ApiRateLimitCostEnum, ApiServiceLevelEnum, IApiRateLimitAlgorithm, IApiRateLimitCost, } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import sinon from 'sinon'; import { SharedModule } from '../../../shared/shared.module'; import { RateLimitingModule } from '../../rate-limiting.module'; import { EvaluateTokenBucketRateLimit } from '../evaluate-token-bucket-rate-limit'; import { GetApiRateLimitAlgorithmConfig } from '../get-api-rate-limit-algorithm-config'; import { GetApiRateLimitCostConfig } from '../get-api-rate-limit-cost-config'; import { GetApiRateLimitMaximum } from '../get-api-rate-limit-maximum'; import { EvaluateApiRateLimit, EvaluateApiRateLimitCommand } from './index'; const mockApiRateLimitAlgorithm: IApiRateLimitAlgorithm = { [ApiRateLimitAlgorithmEnum.BURST_ALLOWANCE]: 0.2, [ApiRateLimitAlgorithmEnum.WINDOW_DURATION]: 2, }; const mockApiRateLimitCost = ApiRateLimitCostEnum.SINGLE; const mockApiServiceLevel = ApiServiceLevelEnum.FREE; const mockCost = 1; const mockApiRateLimitCostConfig: Partial = { [mockApiRateLimitCost]: mockCost, }; const mockMaxLimit = 10; const mockRemaining = 9; const mockReset = 1; const mockApiRateLimitCategory = ApiRateLimitCategoryEnum.GLOBAL; describe('EvaluateApiRateLimit', async () => { let useCase: EvaluateApiRateLimit; let session: UserSession; let getApiRateLimitMaximum: GetApiRateLimitMaximum; let getApiRateLimitAlgorithmConfig: GetApiRateLimitAlgorithmConfig; let getApiRateLimitCostConfig: GetApiRateLimitCostConfig; let evaluateTokenBucketRateLimit: EvaluateTokenBucketRateLimit; let getApiRateLimitMaximumStub: sinon.SinonStub; let getApiRateLimitAlgorithmConfigStub: sinon.SinonStub; let getApiRateLimitCostConfigStub: sinon.SinonStub; let evaluateTokenBucketRateLimitStub: sinon.SinonStub; beforeEach(async () => { const moduleRef = await Test.createTestingModule({ imports: [SharedModule, RateLimitingModule], }).compile(); session = new UserSession(); await session.initialize(); useCase = moduleRef.get(EvaluateApiRateLimit); getApiRateLimitMaximum = moduleRef.get(GetApiRateLimitMaximum); getApiRateLimitAlgorithmConfig = moduleRef.get(GetApiRateLimitAlgorithmConfig); getApiRateLimitCostConfig = moduleRef.get(GetApiRateLimitCostConfig); evaluateTokenBucketRateLimit = moduleRef.get(EvaluateTokenBucketRateLimit); getApiRateLimitMaximumStub = sinon .stub(getApiRateLimitMaximum, 'execute') .resolves([mockMaxLimit, mockApiServiceLevel]); getApiRateLimitAlgorithmConfigStub = sinon .stub(getApiRateLimitAlgorithmConfig, 'default') .value(mockApiRateLimitAlgorithm); getApiRateLimitCostConfigStub = sinon.stub(getApiRateLimitCostConfig, 'default').value(mockApiRateLimitCostConfig); evaluateTokenBucketRateLimitStub = sinon.stub(evaluateTokenBucketRateLimit, 'execute').resolves({ success: true, limit: mockMaxLimit, remaining: mockRemaining, reset: mockReset, }); }); afterEach(() => { getApiRateLimitMaximumStub.restore(); getApiRateLimitAlgorithmConfigStub.restore(); getApiRateLimitCostConfigStub.restore(); }); describe('Evaluation Values', () => { it('should return a boolean success value', async () => { const result = await useCase.execute( EvaluateApiRateLimitCommand.create({ organizationId: session.organization._id, environmentId: session.environment._id, apiRateLimitCategory: mockApiRateLimitCategory, apiRateLimitCost: mockApiRateLimitCost, }) ); expect(typeof result.success).to.equal('boolean'); }); it('should return a positive limit', async () => { const result = await useCase.execute( EvaluateApiRateLimitCommand.create({ organizationId: session.organization._id, environmentId: session.environment._id, apiRateLimitCategory: mockApiRateLimitCategory, apiRateLimitCost: mockApiRateLimitCost, }) ); expect(result.limit).to.be.greaterThan(0); }); it('should return a positive remaining tokens ', async () => { const result = await useCase.execute( EvaluateApiRateLimitCommand.create({ organizationId: session.organization._id, environmentId: session.environment._id, apiRateLimitCategory: mockApiRateLimitCategory, apiRateLimitCost: mockApiRateLimitCost, }) ); expect(result.remaining).to.be.greaterThan(0); }); it('should return a positive reset', async () => { const result = await useCase.execute( EvaluateApiRateLimitCommand.create({ organizationId: session.organization._id, environmentId: session.environment._id, apiRateLimitCategory: mockApiRateLimitCategory, apiRateLimitCost: mockApiRateLimitCost, }) ); expect(result.reset).to.be.greaterThan(0); }); }); describe('Static Values', () => { it('should return a string type algorithm value', async () => { const result = await useCase.execute( EvaluateApiRateLimitCommand.create({ organizationId: session.organization._id, environmentId: session.environment._id, apiRateLimitCategory: mockApiRateLimitCategory, apiRateLimitCost: mockApiRateLimitCost, }) ); expect(typeof result.algorithm).to.equal('string'); }); it('should return the correct window duration', async () => { const result = await useCase.execute( EvaluateApiRateLimitCommand.create({ organizationId: session.organization._id, environmentId: session.environment._id, apiRateLimitCategory: mockApiRateLimitCategory, apiRateLimitCost: mockApiRateLimitCost, }) ); expect(result.windowDuration).to.equal(mockApiRateLimitAlgorithm[ApiRateLimitAlgorithmEnum.WINDOW_DURATION]); }); }); describe('Computed Values', () => { it('should return the correct cost', async () => { const result = await useCase.execute( EvaluateApiRateLimitCommand.create({ organizationId: session.organization._id, environmentId: session.environment._id, apiRateLimitCategory: mockApiRateLimitCategory, apiRateLimitCost: mockApiRateLimitCost, }) ); expect(result.cost).to.equal(mockApiRateLimitCostConfig[mockApiRateLimitCost]); }); it('should return the correct refill rate', async () => { const result = await useCase.execute( EvaluateApiRateLimitCommand.create({ organizationId: session.organization._id, environmentId: session.environment._id, apiRateLimitCategory: mockApiRateLimitCategory, apiRateLimitCost: mockApiRateLimitCost, }) ); expect(result.refillRate).to.equal( mockMaxLimit * mockApiRateLimitAlgorithm[ApiRateLimitAlgorithmEnum.WINDOW_DURATION] ); }); it('should return the correct burst limit', async () => { const result = await useCase.execute( EvaluateApiRateLimitCommand.create({ organizationId: session.organization._id, environmentId: session.environment._id, apiRateLimitCategory: mockApiRateLimitCategory, apiRateLimitCost: mockApiRateLimitCost, }) ); expect(result.burstLimit).to.equal( mockMaxLimit * mockApiRateLimitAlgorithm[ApiRateLimitAlgorithmEnum.WINDOW_DURATION] * (1 + mockApiRateLimitAlgorithm[ApiRateLimitAlgorithmEnum.BURST_ALLOWANCE]) ); }); }); }); ================================================ FILE: apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/evaluate-api-rate-limit.types.ts ================================================ export type EvaluateApiRateLimitResponseDto = { /** * Whether the request may pass(true) or exceeded the limit(false) */ success: boolean; /** * Maximum number of requests allowed within a window. */ limit: number; /** * How many requests the client has left within the current window. */ remaining: number; /** * Unix timestamp in milliseconds when the limits are reset. */ reset: number; /** * The duration of the window in seconds. */ windowDuration: number; /** * The maximum number of requests allowed within a window, including the burst allowance. */ burstLimit: number; /** * The number of requests that will be refilled per window. */ refillRate: number; /** * The name of the algorithm used to calculate the rate limit. */ algorithm: string; /** * The cost of the request. */ cost: number; /** * The API service level used to evaluate the request. */ apiServiceLevel: string; }; ================================================ FILE: apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/evaluate-api-rate-limit.usecase.ts ================================================ import { Injectable } from '@nestjs/common'; import { buildEvaluateApiRateLimitKey, InstrumentUsecase } from '@novu/application-generic'; import { ApiRateLimitAlgorithmEnum, ApiServiceLevelEnum, FeatureNameEnum, getFeatureForTierAsNumber, } from '@novu/shared'; import { EvaluateTokenBucketRateLimitCommand } from '../evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.command'; import { EvaluateTokenBucketRateLimit } from '../evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.usecase'; import { GetApiRateLimitAlgorithmConfig } from '../get-api-rate-limit-algorithm-config'; import { GetApiRateLimitCostConfig } from '../get-api-rate-limit-cost-config'; import { GetApiRateLimitMaximum, GetApiRateLimitMaximumCommand } from '../get-api-rate-limit-maximum'; import type { ApiServiceLevel } from '../get-api-rate-limit-maximum/get-api-rate-limit-maximum.dto'; import { EvaluateApiRateLimitCommand } from './evaluate-api-rate-limit.command'; import { EvaluateApiRateLimitResponseDto } from './evaluate-api-rate-limit.types'; @Injectable() export class EvaluateApiRateLimit { constructor( private getApiRateLimitMaximum: GetApiRateLimitMaximum, private getApiRateLimitAlgorithmConfig: GetApiRateLimitAlgorithmConfig, private getApiRateLimitCostConfig: GetApiRateLimitCostConfig, private evaluateTokenBucketRateLimit: EvaluateTokenBucketRateLimit ) {} @InstrumentUsecase() async execute(command: EvaluateApiRateLimitCommand): Promise { let maxLimitPerSecond: number; let apiServiceLevel: ApiServiceLevel; // For keyless environments, we implement strict rate limiting to prevent abuse: if (!command.organizationId || !command.environmentId) { maxLimitPerSecond = 3000; apiServiceLevel = ApiServiceLevelEnum.ENTERPRISE; } else { [maxLimitPerSecond, apiServiceLevel] = await this.getApiRateLimitMaximum.execute( GetApiRateLimitMaximumCommand.create({ apiRateLimitCategory: command.apiRateLimitCategory, environmentId: command.environmentId, organizationId: command.organizationId, }) ); } const windowDuration = this.getApiRateLimitAlgorithmConfig.default[ApiRateLimitAlgorithmEnum.WINDOW_DURATION]; const burstAllowance = this.getApiRateLimitAlgorithmConfig.default[ApiRateLimitAlgorithmEnum.BURST_ALLOWANCE]; const cost = this.getApiRateLimitCostConfig.default[command.apiRateLimitCost]; const maxTokensPerWindow = this.getMaxTokensPerWindow(maxLimitPerSecond, windowDuration); const refillRate = this.getRefillRate(maxLimitPerSecond, windowDuration); const burstLimit = this.getBurstLimit(maxTokensPerWindow, burstAllowance); // For keyless authentication, we'll use both environment and IP-based rate limiting const identifier = buildEvaluateApiRateLimitKey({ _environmentId: command.environmentId || 'keyless_env', apiRateLimitCategory: command.ip ? `${command.apiRateLimitCategory}:ip=${command.ip}` : command.apiRateLimitCategory, }); const { success, remaining, reset } = await this.evaluateTokenBucketRateLimit.execute( EvaluateTokenBucketRateLimitCommand.create({ identifier, maxTokens: burstLimit, windowDuration, cost, refillRate, }) ); return { success, limit: maxTokensPerWindow, remaining, reset, windowDuration, burstLimit, refillRate, algorithm: this.evaluateTokenBucketRateLimit.algorithm, cost, apiServiceLevel, }; } private getMaxTokensPerWindow(maxLimit: number, windowDuration: number): number { return maxLimit * windowDuration; } private getRefillRate(maxLimit: number, windowDuration: number): number { /* * Refill rate is currently set to the max tokens per window. * This can be changed to a different value to implement adaptive rate limiting. */ return this.getMaxTokensPerWindow(maxLimit, windowDuration); } private getBurstLimit(maxTokensPerWindow: number, burstAllowance: number): number { return Math.floor(maxTokensPerWindow * (1 + burstAllowance)); } } ================================================ FILE: apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/index.ts ================================================ export * from './evaluate-api-rate-limit.command'; export * from './evaluate-api-rate-limit.types'; export * from './evaluate-api-rate-limit.usecase'; ================================================ FILE: apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.command.ts ================================================ import { BaseCommand } from '@novu/application-generic'; import { IsDefined, IsNumber, IsString } from 'class-validator'; export class EvaluateTokenBucketRateLimitCommand extends BaseCommand { @IsDefined() @IsString() identifier: string; @IsDefined() @IsNumber() maxTokens: number; @IsDefined() @IsNumber() windowDuration: number; @IsDefined() @IsNumber() cost: number; @IsDefined() @IsNumber() refillRate: number; } ================================================ FILE: apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.spec.ts ================================================ import { Test } from '@nestjs/testing'; import { CacheService, cacheService as inMemoryCacheService } from '@novu/application-generic'; import { expect } from 'chai'; import sinon from 'sinon'; import { v4 as uuid } from 'uuid'; import { SharedModule } from '../../../shared/shared.module'; import { RateLimitingModule } from '../../rate-limiting.module'; import { EvaluateTokenBucketRateLimitCommand } from './evaluate-token-bucket-rate-limit.command'; import { EvaluateTokenBucketRateLimit } from './evaluate-token-bucket-rate-limit.usecase'; describe('EvaluateTokenBucketRateLimit', () => { let useCase: EvaluateTokenBucketRateLimit; let cacheService: CacheService; const mockCommand = EvaluateTokenBucketRateLimitCommand.create({ identifier: 'test', maxTokens: 10, windowDuration: 1, cost: 1, refillRate: 1, }); beforeEach(async () => { const moduleRef = await Test.createTestingModule({ imports: [SharedModule, RateLimitingModule], }).compile(); useCase = moduleRef.get(EvaluateTokenBucketRateLimit); cacheService = moduleRef.get(CacheService); }); describe('Static values', () => { it('should have a static algorithm value', () => { expect(useCase.algorithm).to.equal('token bucket'); }); }); describe('Cache invocation', () => { let cacheServiceEvalStub: sinon.SinonStub; let cacheServiceSaddStub: sinon.SinonStub; let cacheServiceIsEnabledStub: sinon.SinonStub; beforeEach(async () => { cacheServiceEvalStub = sinon.stub(cacheService, 'eval'); cacheServiceSaddStub = sinon.stub(cacheService, 'sadd'); cacheServiceIsEnabledStub = sinon.stub(cacheService, 'cacheEnabled').returns(true); }); afterEach(() => { cacheServiceEvalStub.restore(); cacheServiceSaddStub.restore(); cacheServiceIsEnabledStub.restore(); }); describe('Cache Errors', () => { it('should throw error when a cache operation fails', async () => { cacheServiceEvalStub.resolves(new Error()); try { await useCase.execute(mockCommand); throw new Error('Should not reach here'); } catch (e) { expect(e.message).to.equal('Failed to evaluate rate limit'); } }); it('should throw error when cache is not enabled', async () => { cacheServiceIsEnabledStub.returns(false); try { await useCase.execute(mockCommand); throw new Error('Should not reach here'); } catch (e) { expect(e.message).to.equal('Rate limiting cache service is not available'); } }); }); describe('Cache Service Adapter', () => { it('should invoke the SADD method with members casted to string', async () => { const cacheClient = EvaluateTokenBucketRateLimit.getCacheClient(cacheService); const key = 'testKey'; const members = [1, 2]; await cacheClient.sadd(key, ...members); expect(cacheServiceSaddStub.calledWith(key, ...['1', '2'])).to.equal(true); }); it('should invoke the EVAL function with args casted to string', async () => { const cacheClient = EvaluateTokenBucketRateLimit.getCacheClient(cacheService); const script = 'return 1'; const keys = ['key1', 'key2']; const args = [1, 2]; await cacheClient.eval(script, keys, args); expect(cacheServiceEvalStub.calledWith(script, keys, ['1', '2'])).to.equal(true); }); }); describe.skip('Redis EVAL script benchmarks', () => { type TestCase = { /** * Test scenario description */ description: string; /** * Total number of requests to simulate */ totalRequests: number; /** * Proportion of requests that have a unique identifier */ proportionUniqueIds: number; /** * Proportion of requests that are throttled */ proportionThrottled: number; /** * Proportion of requests that are high cost */ proportionHighCost: number; /** * The proportion of the window duration to jitter the request duration by. * Low value to simulate burst request patterns. * High value to simulate sustained request patterns. */ proportionJitter: number; /** * Expected maximum total evaluation duration in milliseconds */ expectedTotalTimeMs: number; /** * Expected average evaluation duration in milliseconds */ expectedAverageTimeMs: number; /** * Expected nth percentile evaluation duration in milliseconds */ expectedNthPercentileTimeMs: number; }; const testCases: TestCase[] = [ { description: 'Low Load - 0% Throttled - Sustained Single Window', totalRequests: 5000, proportionUniqueIds: 0.5, proportionThrottled: 0, proportionHighCost: 0, proportionJitter: 0.8, expectedTotalTimeMs: 1000, expectedAverageTimeMs: 10, expectedNthPercentileTimeMs: 30, }, { description: 'Medium Load - 0% Throttled - Sustained Single Window', totalRequests: 10000, proportionUniqueIds: 0.5, proportionThrottled: 0, proportionHighCost: 0, proportionJitter: 0.8, expectedTotalTimeMs: 1000, expectedAverageTimeMs: 20, expectedNthPercentileTimeMs: 50, }, { description: 'High Load - 0% Throttled - Sustained Single Window', totalRequests: 20000, proportionUniqueIds: 0.5, proportionThrottled: 0, proportionHighCost: 0, proportionJitter: 0.8, expectedTotalTimeMs: 1000, expectedAverageTimeMs: 200, expectedNthPercentileTimeMs: 500, }, { description: 'Extreme Load - 0% Throttled - Sustained Single Window', totalRequests: 40000, proportionUniqueIds: 0.5, proportionThrottled: 0, proportionHighCost: 0, proportionJitter: 0.8, expectedTotalTimeMs: 2000, expectedAverageTimeMs: 500, expectedNthPercentileTimeMs: 2000, }, { description: 'High Load - 0% Throttled - Burst Single Window', totalRequests: 20000, proportionUniqueIds: 0.5, proportionThrottled: 0, proportionHighCost: 0, proportionJitter: 0.2, expectedTotalTimeMs: 1000, expectedAverageTimeMs: 500, expectedNthPercentileTimeMs: 1000, }, { description: 'Extreme Load - 0% Throttled - Burst Single Window', totalRequests: 40000, proportionUniqueIds: 0.5, proportionThrottled: 0, proportionHighCost: 0, proportionJitter: 0.2, expectedTotalTimeMs: 3000, expectedAverageTimeMs: 1500, expectedNthPercentileTimeMs: 2000, }, { description: 'High Load - 50% Throttled - Burst Single Window', totalRequests: 20000, proportionUniqueIds: 0.5, proportionThrottled: 0.5, proportionHighCost: 0, proportionJitter: 0.2, expectedTotalTimeMs: 1000, expectedAverageTimeMs: 500, expectedNthPercentileTimeMs: 1000, }, { description: 'High Load - 50% Throttled - Sustained Single Window', totalRequests: 20000, proportionUniqueIds: 0.5, proportionThrottled: 0.5, proportionHighCost: 0, proportionJitter: 0.8, expectedTotalTimeMs: 1000, expectedAverageTimeMs: 500, expectedNthPercentileTimeMs: 500, }, { description: 'High Load - 50% Throttled & 50% High-Cost - Sustained Multiple Windows', totalRequests: 40000, proportionUniqueIds: 0.5, proportionThrottled: 0.5, proportionHighCost: 0.5, proportionJitter: 2.2, expectedTotalTimeMs: 3000, expectedAverageTimeMs: 30, expectedNthPercentileTimeMs: 100, }, { description: 'Extreme Load - 50% Throttled & 50% High-Cost - Sustained Multiple Windows', totalRequests: 80000, proportionUniqueIds: 0.5, proportionThrottled: 0.5, proportionHighCost: 0.5, proportionJitter: 2.2, expectedTotalTimeMs: 4000, expectedAverageTimeMs: 1000, expectedNthPercentileTimeMs: 1500, }, { description: 'High Load - 50% Throttled & 90% High-Cost - Sustained Multiple Windows', totalRequests: 40000, proportionUniqueIds: 0.5, proportionThrottled: 0.5, proportionHighCost: 0.9, proportionJitter: 2.2, expectedTotalTimeMs: 3000, expectedAverageTimeMs: 50, expectedNthPercentileTimeMs: 200, }, { description: 'High Load - 50% Throttled & 0% Unique - Sustained Multiple Windows', totalRequests: 40000, proportionUniqueIds: 0, proportionThrottled: 0.5, proportionHighCost: 0, proportionJitter: 2.2, expectedTotalTimeMs: 3000, expectedAverageTimeMs: 30, expectedNthPercentileTimeMs: 200, }, { description: 'High Load - 50% Throttled & 100% Unique - Sustained Multiple Windows', totalRequests: 40000, proportionUniqueIds: 1, proportionThrottled: 0.5, proportionHighCost: 0, proportionJitter: 2.2, expectedTotalTimeMs: 3000, expectedAverageTimeMs: 30, expectedNthPercentileTimeMs: 100, }, ]; const mockLowCost = 1; const mockHighCost = 10; const mockWindowDuration = 1; const mockWindowDurationMs = mockWindowDuration * 1000; const mockProportionRefill = 0.5; const testThrottledCountErrorTolerance = 0.2; const testPercentile = 0.95; function printHistogram(results) { // Define the number of bins for the histogram const bins = 10; // Find the maximum duration to scale the histogram const maxDuration = Math.max(...results.map((result) => result.duration)); // Initialize an array for the histogram bins const histogram = Array(bins).fill(0); // Populate the histogram bins results.forEach((result) => { const index = Math.floor((result.duration / maxDuration) * bins); histogram[index < bins ? index : bins - 1] += 1; }); // Find the maximum bin count to scale the histogram height const maxCount = Math.max(...histogram); // Print the histogram console.log(`\t Request Time (ms)`); histogram.forEach((count, i) => { const bar = '*'.repeat((count / maxCount) * 50); // Scale to a max width of 50 "*" console.log(`\t ${(((i + 1) / bins) * maxDuration).toFixed(2).padStart(7)}: ${bar}`); }); } testCases .map( ({ description, totalRequests, proportionUniqueIds, proportionThrottled, proportionHighCost, proportionJitter, expectedAverageTimeMs, expectedNthPercentileTimeMs, expectedTotalTimeMs, }) => { return () => { describe(description, () => { let testContext; let results: Array<{ duration: number; success: boolean }>; let totalTime: number; let averageTime: number; let successCount: number; let throttledCount: number; let variance: number; let stdev: number; let nthPercentile: number; const maxTokens = Math.ceil(totalRequests * (1 - proportionThrottled)); const uniqueIdRequests = Math.max(1, Math.floor(totalRequests * proportionUniqueIds)); const uniqueIds = Array.from({ length: uniqueIdRequests }).map(() => uuid()); const mockRepeatId = uuid(); const maxJitterMs = mockWindowDurationMs * proportionJitter; const refillPerWindow = (maxTokens * mockProportionRefill) / mockWindowDuration; before(async () => { const cacheServiceInitialized = await inMemoryCacheService.useFactory(); testContext = { redis: EvaluateTokenBucketRateLimit.getCacheClient(cacheServiceInitialized), }; const proms = Array.from({ length: totalRequests }).map(async (_val, index) => { const cost = Math.random() < proportionHighCost ? mockHighCost : mockLowCost; /** * Distribute unique ids with request allocation skewed left. * matching an expected distribution of requests per unique API client, where: * - the majority of clients make a small number of requests * - a small number of clients make a large number of requests * * Number of Requests per Unique Id * ID Requests * 1 * * 2 ** * 3 **** * 4 ****** * 5 ********* * 6 ************* * 7 ***************** * 8 *********************** * 9 ******************************** * 10 ******************************************* */ const id = Math.random() < proportionUniqueIds ? uniqueIds[Math.floor((index / totalRequests) * uniqueIds.length)] : mockRepeatId; const jitter = Math.floor(Math.random() * maxJitterMs); await new Promise((resolve) => { setTimeout(resolve, jitter); }); const start = Date.now(); const limit = EvaluateTokenBucketRateLimit.tokenBucketLimiter( refillPerWindow, mockWindowDuration, maxTokens, cost ); const { success } = await limit(testContext, id); const end = Date.now(); const duration = end - start; return { duration, success, }; }); const startAll = Date.now(); results = await Promise.all(proms); const endAll = Date.now(); totalTime = endAll - startAll; averageTime = results.reduce((acc, val) => acc + val.duration, 0) / results.length; variance = results.reduce((acc, val) => acc + (val.duration - averageTime) ** 2, 0) / results.length; stdev = Math.sqrt(variance); nthPercentile = results.sort((a, b) => a.duration - b.duration)[ Math.floor(results.length * testPercentile) ].duration; successCount = results.filter(({ success }) => success).length; throttledCount = totalRequests - successCount; console.log( `\t Params: Total Req: ${totalRequests.toLocaleString()}\tUsers: ${uniqueIdRequests.toLocaleString()}\tThrottled: ${ proportionThrottled * 100 }%\tHigh Cost: ${proportionHighCost * 100}%\tJitter: ${maxJitterMs}ms` ); console.log( `\t Stats: Total Time: ${totalTime.toLocaleString()}ms\tAvg: ${averageTime.toFixed( 1 )}ms\tStdev: ${stdev.toFixed(1)}\tp(${ testPercentile * 100 }): ${nthPercentile}\tThrottled: ${throttledCount.toLocaleString()}` ); printHistogram(results); }); describe('Script Performance', () => { it(`should be able to process ${totalRequests.toLocaleString()} evaluations in less than ${expectedTotalTimeMs}ms`, async () => { expect(totalTime).to.be.lessThan(expectedTotalTimeMs); }); it(`should have average evaluation duration less than ${expectedAverageTimeMs}ms`, async () => { expect(averageTime).to.be.lessThan(expectedAverageTimeMs); }); it(`should have ${ testPercentile * 100 }th percentile evaluation duration less than ${expectedNthPercentileTimeMs}ms`, async () => { expect(nthPercentile).to.be.lessThan(expectedNthPercentileTimeMs); }); }); describe('Script Throttle Evaluation', () => { const proportionRequestsPerWindow = maxJitterMs > mockWindowDurationMs ? mockWindowDurationMs / maxJitterMs : 1; const totalRequestsPerWindow = Math.floor(totalRequests * proportionRequestsPerWindow); const uniqueRequestsPerWindow = Math.floor(totalRequestsPerWindow * (1 - proportionThrottled)); const expectedPerRequestCost = (1 - proportionHighCost) * mockLowCost + proportionHighCost * mockHighCost; const expectedWindowCost = uniqueRequestsPerWindow * expectedPerRequestCost; const firstWindowThrottledRequests = expectedWindowCost > maxTokens ? (expectedWindowCost - maxTokens) / expectedPerRequestCost : 0; const secondWindowMaxTokens = Math.max( maxTokens, maxTokens - firstWindowThrottledRequests + refillPerWindow ); const secondWindowThrottledRequests = expectedWindowCost > secondWindowMaxTokens ? (expectedWindowCost - secondWindowMaxTokens) / expectedPerRequestCost : 0; const expectedThrottledCount = firstWindowThrottledRequests + secondWindowThrottledRequests; const expectedThrottledCountMin = Math.floor( expectedThrottledCount * (1 - testThrottledCountErrorTolerance) ); const expectedThrottledCountMax = Math.floor( expectedThrottledCount * (1 + testThrottledCountErrorTolerance) ); it(`should throttle between ${expectedThrottledCountMin} and ${expectedThrottledCountMax} requests`, async () => { expect(throttledCount).to.be.greaterThanOrEqual(expectedThrottledCountMin); expect(throttledCount).to.be.lessThanOrEqual(expectedThrottledCountMax); }); }); }); }; } ) .forEach((testCase) => { testCase(); }); }); }); }); ================================================ FILE: apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.types.ts ================================================ import { Ratelimit } from '@upstash/ratelimit'; export type UpstashRedisClient = ConstructorParameters[0]['redis']; export type EvaluateTokenBucketRateLimitResponseDto = { /** * Whether the request may pass(true) or exceeded the limit(false) */ success: boolean; /** * Maximum number of requests allowed within a window. */ limit: number; /** * How many requests the client has left within the current window. */ remaining: number; /** * Unix timestamp in milliseconds when the limits are reset. */ reset: number; }; export type RegionLimiter = ReturnType; /** * You have a bucket filled with `{maxTokens}` tokens that refills constantly * at `{refillRate}` per `{interval}`. * Every request will remove `{cost}` token(s) from the bucket and if there is no * token to take, the request is rejected. * * **Pro:** * * - Bursts of requests are smoothed out and you can process them at a constant * rate. * - Allows to set a higher initial burst limit by setting `maxTokens` higher * than `refillRate` */ export type CostLimiter = ( /** * How many tokens are refilled per `interval` * * An interval of `10s` and refillRate of 5 will cause a new token to be added every 2 seconds. */ refillRate: number, /** * The interval in seconds for the `refillRate` */ interval: number, /** * Maximum number of tokens. * A newly created bucket starts with this many tokens. * Useful to allow higher burst limits. */ maxTokens: number, /** * The number of tokens used in the request. */ cost: number ) => RegionLimiter; ================================================ FILE: apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.usecase.ts ================================================ import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { CacheService, InstrumentUsecase, PinoLogger } from '@novu/application-generic'; import { Ratelimit } from '@upstash/ratelimit'; import { EvaluateTokenBucketRateLimitCommand } from './evaluate-token-bucket-rate-limit.command'; import { EvaluateTokenBucketRateLimitResponseDto, RegionLimiter, UpstashRedisClient, } from './evaluate-token-bucket-rate-limit.types'; const LOG_CONTEXT = 'EvaluateTokenBucketRateLimit'; @Injectable() export class EvaluateTokenBucketRateLimit { private ephemeralCache = new Map(); public algorithm = 'token bucket'; constructor( private cacheService: CacheService, private logger: PinoLogger ) { this.logger.setContext(this.constructor.name); } @InstrumentUsecase() async execute(command: EvaluateTokenBucketRateLimitCommand): Promise { if (!this.cacheService.cacheEnabled()) { const message = 'Rate limiting cache service is not available'; this.logger.error(message); throw new ServiceUnavailableException(message); } const cacheClient = EvaluateTokenBucketRateLimit.getCacheClient(this.cacheService); const ratelimit = new Ratelimit({ redis: cacheClient, limiter: EvaluateTokenBucketRateLimit.tokenBucketLimiter( command.refillRate, command.windowDuration, command.maxTokens, command.cost ), prefix: '', // Empty cache key prefix to give us full control over the key format ephemeralCache: this.ephemeralCache, }); try { const { success, limit, remaining, reset } = await ratelimit.limit(command.identifier); return { success, limit, remaining, reset, }; } catch (error) { const apiMessage = 'Failed to evaluate rate limit'; const logMessage = `${apiMessage} for identifier: "${command.identifier}". Error: "${error}"`; this.logger.error(logMessage); throw new ServiceUnavailableException(apiMessage); } } public static getCacheClient(cacheService: CacheService): UpstashRedisClient { // Adapter for the @upstash/redis client -> cache client return { sadd: async (key, ...members) => cacheService.sadd(key, ...members.map((member) => String(member))), eval: async (script, keys, args) => cacheService.eval( script, keys, args.map((arg) => String(arg)) ), }; } /** * Token Bucket algorithm with variable cost. Adapted from @upstash/ratelimit and modified to support variable cost. * Also influenced by Krakend's token bucket implementation to delay refills until bucket is empty. * * @see https://github.com/upstash/ratelimit/blob/3a8cfb00e827188734ac347965cb743a75fcb98a/src/single.ts#L292 * @see https://github.com/krakend/krakend-ratelimit/blob/369f0be9b51a4fb8ab7d43e4833d076b461a4374/rate.go#L85 */ public static tokenBucketLimiter( refillRate: number, interval: number, maxTokens: number, cost: number ): RegionLimiter { const script = /* Lua */ ` local key = KEYS[1] -- current interval identifier including prefixes local maxTokens = tonumber(ARGV[1]) -- maximum number of tokens local interval = tonumber(ARGV[2]) -- size of the window in milliseconds local fillInterval = tonumber(ARGV[3]) -- time between refills in milliseconds local now = tonumber(ARGV[4]) -- current timestamp in milliseconds local cost = tonumber(ARGV[5]) -- cost of request local remaining = 0 -- remaining number of tokens local reset = 0 -- timestamp when next request of {cost} token(s) can be accepted local resetCost = 0 -- multiplier for the next reset time local lastRefill = 0 -- timestamp of last refill local bucket = redis.call("HMGET", key, "lastRefill", "tokens") if bucket[1] == false then -- The bucket does not exist yet, so we create it and add a ttl. lastRefill = now remaining = maxTokens - cost resetCost = (remaining < cost) and (cost - remaining) or cost redis.call("HMSET", key, "lastRefill", lastRefill, "tokens", remaining) redis.call("PEXPIRE", key, interval * 2) else -- The current bucket does exist lastRefill = tonumber(bucket[1]) local tokens = tonumber(bucket[2]) if tokens >= cost then -- Delay refill until bucket is empty remaining = tokens - cost resetCost = (remaining < cost) and (cost - remaining) or cost redis.call("HMSET", key, "tokens", remaining) else local elapsed = now - lastRefill local tokensToAdd = math.floor(elapsed / fillInterval) local newTokens = math.min(maxTokens, tokens + tokensToAdd) remaining = newTokens - cost if remaining >= 0 then -- Update the time of the last refill depending on how many tokens we added lastRefill = lastRefill + tokensToAdd * fillInterval resetCost = (remaining < cost) and (cost - remaining) or cost redis.call("HMSET", key, "lastRefill", lastRefill, "tokens", remaining) redis.call("PEXPIRE", key, interval * 2) else resetCost = cost - tokens end end end reset = lastRefill + resetCost * fillInterval return {remaining, reset} `; const intervalDurationMs = interval * 1e3; const fillInterval = intervalDurationMs / refillRate; return async (ctx, identifier) => { // Cost needs to be included in local cache identifier to ensure lower cost requests are not blocked const localCacheIdentifier = `${identifier}:${cost}`; if (ctx.cache) { const { blocked, reset } = ctx.cache.isBlocked(localCacheIdentifier); if (blocked) { return { success: false, limit: refillRate, remaining: 0, reset, pending: Promise.resolve(), }; } } const now = Date.now(); const [remaining, reset] = (await ctx.redis.eval( script, [identifier], [maxTokens, intervalDurationMs, fillInterval, now, cost] )) as [number, number]; const success = remaining >= 0; const nonNegativeRemaining = Math.max(0, remaining); if (ctx.cache && !success) { ctx.cache.blockUntil(localCacheIdentifier, reset); } return { success, limit: refillRate, remaining: nonNegativeRemaining, reset, pending: Promise.resolve(), }; }; } } ================================================ FILE: apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/index.ts ================================================ export * from './evaluate-token-bucket-rate-limit.command'; export * from './evaluate-token-bucket-rate-limit.types'; export * from './evaluate-token-bucket-rate-limit.usecase'; ================================================ FILE: apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-algorithm-config/get-api-rate-limit-algorithm-config.spec.ts ================================================ import { Test } from '@nestjs/testing'; import { ApiRateLimitAlgorithmEnum, ApiRateLimitAlgorithmEnvVarFormat, DEFAULT_API_RATE_LIMIT_ALGORITHM_CONFIG, } from '@novu/shared'; import { expect } from 'chai'; import { GetApiRateLimitAlgorithmConfig } from './get-api-rate-limit-algorithm-config.usecase'; describe('GetApiRateLimitAlgorithmConfig', () => { let useCase: GetApiRateLimitAlgorithmConfig; beforeEach(async () => { const moduleRef = await Test.createTestingModule({ providers: [GetApiRateLimitAlgorithmConfig], }).compile(); useCase = moduleRef.get(GetApiRateLimitAlgorithmConfig); }); it('should use the default rate limit algorithm config when no environment variables are set', () => { expect(useCase.default).to.deep.equal(DEFAULT_API_RATE_LIMIT_ALGORITHM_CONFIG); }); it('should override default rate limit algorithm config with environment variables', () => { const mockOverrideBurstAllowance = 0.2; const mockApiRateLimitConfigurationKey = ApiRateLimitAlgorithmEnum.BURST_ALLOWANCE; const envVarName: ApiRateLimitAlgorithmEnvVarFormat = `API_RATE_LIMIT_ALGORITHM_${ mockApiRateLimitConfigurationKey.toUpperCase() as Uppercase }`; process.env[envVarName] = `${mockOverrideBurstAllowance}`; // Re-initialize the defaultApiRateLimits after setting the environment variable useCase.loadDefault(); const result = useCase.default; expect(result[mockApiRateLimitConfigurationKey]).to.equal(mockOverrideBurstAllowance); delete process.env[envVarName]; // cleanup }); }); ================================================ FILE: apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-algorithm-config/get-api-rate-limit-algorithm-config.usecase.ts ================================================ import { Injectable } from '@nestjs/common'; import { ApiRateLimitAlgorithmEnum, ApiRateLimitAlgorithmEnvVarFormat, DEFAULT_API_RATE_LIMIT_ALGORITHM_CONFIG, IApiRateLimitAlgorithm, } from '@novu/shared'; @Injectable() export class GetApiRateLimitAlgorithmConfig { public default: IApiRateLimitAlgorithm; constructor() { this.loadDefault(); } public loadDefault(): void { this.default = this.createDefault(); } private createDefault(): IApiRateLimitAlgorithm { const mergedConfig: IApiRateLimitAlgorithm = { ...DEFAULT_API_RATE_LIMIT_ALGORITHM_CONFIG }; // Read process environment only once for performance const processEnv = process.env; Object.values(ApiRateLimitAlgorithmEnum).forEach((algorithmOption) => { const envVarName = this.getEnvVarName(algorithmOption); const envVarValue = processEnv[envVarName]; if (envVarValue) { mergedConfig[algorithmOption] = Number(envVarValue); } }); return mergedConfig; } private getEnvVarName(algorithmOption: ApiRateLimitAlgorithmEnum): ApiRateLimitAlgorithmEnvVarFormat { return `API_RATE_LIMIT_ALGORITHM_${algorithmOption.toUpperCase() as Uppercase}`; } } ================================================ FILE: apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-algorithm-config/index.ts ================================================ export * from './get-api-rate-limit-algorithm-config.usecase'; ================================================ FILE: apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-cost-config/get-api-rate-limit-cost-config.spec.ts ================================================ import { Test } from '@nestjs/testing'; import { ApiRateLimitCostEnum, ApiRateLimitCostEnvVarFormat, DEFAULT_API_RATE_LIMIT_COST_CONFIG } from '@novu/shared'; import { expect } from 'chai'; import { GetApiRateLimitCostConfig } from './get-api-rate-limit-cost-config.usecase'; describe('GetApiRateLimitCostConfig', () => { let useCase: GetApiRateLimitCostConfig; beforeEach(async () => { const moduleRef = await Test.createTestingModule({ providers: [GetApiRateLimitCostConfig], }).compile(); useCase = moduleRef.get(GetApiRateLimitCostConfig); }); it('should use the default rate limit cost configuration when no environment variables are set', () => { expect(useCase.default).to.deep.equal(DEFAULT_API_RATE_LIMIT_COST_CONFIG); }); it('should override default rate limit cost configuration with environment variables', () => { const mockOverrideBulkCost = 15; const mockApiRateLimitConfigurationKey = ApiRateLimitCostEnum.BULK; const envVarName: ApiRateLimitCostEnvVarFormat = `API_RATE_LIMIT_COST_${ mockApiRateLimitConfigurationKey.toUpperCase() as Uppercase }`; process.env[envVarName] = `${mockOverrideBulkCost}`; // Re-initialize the defaultApiRateLimits after setting the environment variable useCase.loadDefault(); const result = useCase.default; expect(result[mockApiRateLimitConfigurationKey]).to.equal(mockOverrideBulkCost); delete process.env[envVarName]; // cleanup }); }); ================================================ FILE: apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-cost-config/get-api-rate-limit-cost-config.usecase.ts ================================================ import { Injectable } from '@nestjs/common'; import { ApiRateLimitCostEnum, ApiRateLimitCostEnvVarFormat, DEFAULT_API_RATE_LIMIT_COST_CONFIG, IApiRateLimitCost, } from '@novu/shared'; @Injectable() export class GetApiRateLimitCostConfig { public default: IApiRateLimitCost; constructor() { this.loadDefault(); } public loadDefault(): void { this.default = this.createDefault(); } private createDefault(): IApiRateLimitCost { const mergedConfig: IApiRateLimitCost = { ...DEFAULT_API_RATE_LIMIT_COST_CONFIG }; // Read process environment only once for performance const processEnv = process.env; Object.values(ApiRateLimitCostEnum).forEach((costOption) => { const envVarName = this.getEnvVarName(costOption); const envVarValue = processEnv[envVarName]; if (envVarValue) { mergedConfig[costOption] = Number(envVarValue); } }); return mergedConfig; } private getEnvVarName(costOption: ApiRateLimitCostEnum): ApiRateLimitCostEnvVarFormat { return `API_RATE_LIMIT_COST_${costOption.toUpperCase() as Uppercase}`; } } ================================================ FILE: apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-cost-config/index.ts ================================================ export * from './get-api-rate-limit-cost-config.usecase'; ================================================ FILE: apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-maximum/get-api-rate-limit-maximum.command.ts ================================================ import { ApiRateLimitCategoryEnum } from '@novu/shared'; import { IsDefined, IsEnum } from 'class-validator'; import { EnvironmentCommand } from '../../../shared/commands/project.command'; export class GetApiRateLimitMaximumCommand extends EnvironmentCommand { @IsDefined() @IsEnum(ApiRateLimitCategoryEnum) apiRateLimitCategory: ApiRateLimitCategoryEnum; } ================================================ FILE: apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-maximum/get-api-rate-limit-maximum.dto.ts ================================================ import { ApiServiceLevelEnum } from '@novu/shared'; export const CUSTOM_API_SERVICE_LEVEL = 'custom'; export type ApiServiceLevel = ApiServiceLevelEnum | typeof CUSTOM_API_SERVICE_LEVEL; // Array type to keep the cached entity as small as possible for more performant caching export type GetApiRateLimitMaximumDto = [apiRateLimitMaximum: number, apiServiceLevel: ApiServiceLevel]; ================================================ FILE: apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-maximum/get-api-rate-limit-maximum.spec.ts ================================================ import { Test } from '@nestjs/testing'; import { CacheService, MockCacheService } from '@novu/application-generic'; import { CommunityOrganizationRepository, EnvironmentRepository } from '@novu/dal'; import { ApiRateLimitCategoryEnum, ApiRateLimitCategoryToFeatureName, ApiServiceLevelEnum, FeatureFlagsKeysEnum, getFeatureForTierAsNumber, } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import sinon from 'sinon'; import { SharedModule } from '../../../shared/shared.module'; import { RateLimitingModule } from '../../rate-limiting.module'; import { CUSTOM_API_SERVICE_LEVEL } from './get-api-rate-limit-maximum.dto'; import { GetApiRateLimitMaximum, GetApiRateLimitMaximumCommand } from './index'; const mockDefaultApiRateLimits = { [ApiServiceLevelEnum.FREE]: { [ApiRateLimitCategoryEnum.GLOBAL]: 60, [ApiRateLimitCategoryEnum.TRIGGER]: 60, [ApiRateLimitCategoryEnum.CONFIGURATION]: 60, }, [ApiServiceLevelEnum.UNLIMITED]: { [ApiRateLimitCategoryEnum.GLOBAL]: 600, [ApiRateLimitCategoryEnum.TRIGGER]: 600, [ApiRateLimitCategoryEnum.CONFIGURATION]: 600, }, }; describe('GetApiRateLimitMaximum', async () => { let useCase: GetApiRateLimitMaximum; let session: UserSession; let organizationRepository: CommunityOrganizationRepository; let environmentRepository: EnvironmentRepository; let findOneEnvironmentStub: sinon.SinonStub; let findOneOrganizationStub: sinon.SinonStub; beforeEach(async () => { const moduleRef = await Test.createTestingModule({ imports: [SharedModule, RateLimitingModule], providers: [], }) .overrideProvider(CacheService) .useValue(MockCacheService.createClient()) .compile(); await moduleRef.init(); // Trigger OnModuleInit session = new UserSession(); await session.initialize(); useCase = moduleRef.get(GetApiRateLimitMaximum); organizationRepository = moduleRef.get(CommunityOrganizationRepository); environmentRepository = moduleRef.get(EnvironmentRepository); findOneEnvironmentStub = sinon.stub(environmentRepository, 'findOne'); findOneOrganizationStub = sinon.stub(organizationRepository, 'findById'); }); afterEach(() => { findOneEnvironmentStub.restore(); findOneOrganizationStub.restore(); }); it('should throw error when environment is not found', async () => { findOneEnvironmentStub.resolves(undefined); try { await useCase.execute( GetApiRateLimitMaximumCommand.create({ organizationId: session.organization._id, environmentId: session.environment._id, apiRateLimitCategory: ApiRateLimitCategoryEnum.GLOBAL, }) ); throw new Error('Should not reach here'); } catch (e) { expect(e.message).to.equal(`Environment id: ${session.environment._id} not found`); } }); describe('Environment DOES have rate limits specified', () => { const mockGlobalLimit = 65; const mockApiRateLimitCategory = ApiRateLimitCategoryEnum.GLOBAL; beforeEach(() => { findOneEnvironmentStub.resolves({ apiRateLimits: { [mockApiRateLimitCategory]: mockGlobalLimit, }, }); }); it('should return api rate limit for the category set on environment', async () => { const [rateLimit] = await useCase.execute( GetApiRateLimitMaximumCommand.create({ organizationId: session.organization._id, environmentId: session.environment._id, apiRateLimitCategory: mockApiRateLimitCategory, }) ); expect(rateLimit).to.equal(mockGlobalLimit); }); it('should return api service level of CUSTOM', async () => { const [, apiServiceLevel] = await useCase.execute( GetApiRateLimitMaximumCommand.create({ organizationId: session.organization._id, environmentId: session.environment._id, apiRateLimitCategory: mockApiRateLimitCategory, }) ); expect(apiServiceLevel).to.equal(CUSTOM_API_SERVICE_LEVEL); }); }); describe('Environment DOES NOT have rate limits specified', () => { const mockApiRateLimitCategory = ApiRateLimitCategoryEnum.GLOBAL; beforeEach(() => { findOneEnvironmentStub.resolves({ apiRateLimits: undefined, }); }); describe('Organization DOES have api service level specified', () => { const mockApiServiceLevel = ApiServiceLevelEnum.FREE; beforeEach(() => { findOneOrganizationStub.resolves({ apiServiceLevel: mockApiServiceLevel, }); }); it('should return default api rate limit for the organizations apiServiceLevel when apiServiceLevel IS set on organization', async () => { const defaultApiRateLimit = getFeatureForTierAsNumber( ApiRateLimitCategoryToFeatureName[mockApiRateLimitCategory], mockApiServiceLevel, false ); const [rateLimit] = await useCase.execute( GetApiRateLimitMaximumCommand.create({ organizationId: session.organization._id, environmentId: session.environment._id, apiRateLimitCategory: mockApiRateLimitCategory, }) ); expect(rateLimit).to.equal(defaultApiRateLimit); }); it('should return the api service level set on organization when apiServiceLevel IS set on organization', async () => { const [, apiServiceLevel] = await useCase.execute( GetApiRateLimitMaximumCommand.create({ organizationId: session.organization._id, environmentId: session.environment._id, apiRateLimitCategory: mockApiRateLimitCategory, }) ); expect(apiServiceLevel).to.equal(mockApiServiceLevel); }); }); describe('Organization DOES NOT have api service level specified', () => { beforeEach(() => { findOneOrganizationStub.resolves({ apiServiceLevel: undefined, }); }); it('should return default api rate limit for the UNLIMITED service level when apiServiceLevel IS NOT set on organization', async () => { const defaultApiRateLimit = getFeatureForTierAsNumber( ApiRateLimitCategoryToFeatureName[mockApiRateLimitCategory], ApiServiceLevelEnum.UNLIMITED, false ); const [rateLimit] = await useCase.execute( GetApiRateLimitMaximumCommand.create({ organizationId: session.organization._id, environmentId: session.environment._id, apiRateLimitCategory: mockApiRateLimitCategory, }) ); expect(rateLimit).to.equal(defaultApiRateLimit); }); it('should return the default api service level of UNLIMITED when apiServiceLevel IS NOT set on organization', async () => { const defaultApiServiceLevel = ApiServiceLevelEnum.UNLIMITED; const [, apiServiceLevel] = await useCase.execute( GetApiRateLimitMaximumCommand.create({ organizationId: session.organization._id, environmentId: session.environment._id, apiRateLimitCategory: mockApiRateLimitCategory, }) ); expect(apiServiceLevel).to.equal(defaultApiServiceLevel); }); }); it('should throw an error when the organization is not found', async () => { findOneOrganizationStub.resolves(undefined); try { await useCase.execute( GetApiRateLimitMaximumCommand.create({ organizationId: session.organization._id, environmentId: session.environment._id, apiRateLimitCategory: mockApiRateLimitCategory, }) ); throw new Error('Should not reach here'); } catch (e) { expect(e.message).to.equal(`Organization id: ${session.organization._id} not found`); } }); }); }); ================================================ FILE: apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-maximum/get-api-rate-limit-maximum.usecase.ts ================================================ import { Injectable, InternalServerErrorException, OnModuleInit } from '@nestjs/common'; import { buildMaximumApiRateLimitKey, CachedResponse, Instrument, InstrumentUsecase, PinoLogger, } from '@novu/application-generic'; import { CommunityOrganizationRepository, EnvironmentRepository } from '@novu/dal'; import { ApiRateLimitCategoryEnum, ApiRateLimitCategoryToFeatureName, ApiRateLimitServiceMaximumEnvVarFormat, ApiServiceLevelEnum, getFeatureForTierAsNumber, IApiRateLimitServiceMaximum, } from '@novu/shared'; import { GetApiRateLimitMaximumCommand } from './get-api-rate-limit-maximum.command'; import { CUSTOM_API_SERVICE_LEVEL, GetApiRateLimitMaximumDto } from './get-api-rate-limit-maximum.dto'; @Injectable() export class GetApiRateLimitMaximum implements OnModuleInit { private apiRateLimitRecord: IApiRateLimitServiceMaximum; constructor( private environmentRepository: EnvironmentRepository, private organizationRepository: CommunityOrganizationRepository, private logger: PinoLogger ) { this.logger.setContext(this.constructor.name); } onModuleInit() { this.apiRateLimitRecord = this.buildApiRateLimitRecord(); } @InstrumentUsecase() async execute(command: GetApiRateLimitMaximumCommand): Promise { return await this.getApiRateLimit({ apiRateLimitCategory: command.apiRateLimitCategory, _environmentId: command.environmentId, _organizationId: command.organizationId, }); } @CachedResponse({ builder: (command: { apiRateLimitCategory: ApiRateLimitCategoryEnum; _environmentId: string }) => buildMaximumApiRateLimitKey({ _environmentId: command._environmentId, apiRateLimitCategory: command.apiRateLimitCategory, }), }) private async getApiRateLimit({ apiRateLimitCategory, _environmentId, _organizationId, }: { apiRateLimitCategory: ApiRateLimitCategoryEnum; _environmentId: string; _organizationId: string; }): Promise { const environment = await this.getEnvironment(_environmentId); if (environment.apiRateLimits) { return [environment.apiRateLimits[apiRateLimitCategory], CUSTOM_API_SERVICE_LEVEL]; } const apiServiceLevel = await this.getOrganizationApiServiceLevel(_organizationId); const apiRateLimitRecord = this.apiRateLimitRecord[apiServiceLevel]; return [apiRateLimitRecord[apiRateLimitCategory], apiServiceLevel]; } private async getOrganizationApiServiceLevel(_organizationId: string): Promise { const organization = await this.organizationRepository.findById(_organizationId, '_id apiServiceLevel'); if (!organization) { const message = `Organization id: ${_organizationId} not found`; this.logger.error(message); throw new InternalServerErrorException(message); } if (organization.apiServiceLevel) { return organization.apiServiceLevel; } return ApiServiceLevelEnum.UNLIMITED; } private async getEnvironment(_environmentId: string) { const environment = await this.environmentRepository.findOne({ _id: _environmentId }, '_id apiRateLimits', { readPreference: 'secondaryPreferred', }); if (!environment) { const message = `Environment id: ${_environmentId} not found`; this.logger.error(message); throw new InternalServerErrorException(message); } return environment; } @Instrument() private buildApiRateLimitRecord(): IApiRateLimitServiceMaximum { // Read process environment only once for performance const processEnv = process.env; return Object.values(ApiServiceLevelEnum).reduce((acc, apiServiceLevel) => { acc[apiServiceLevel] = Object.values(ApiRateLimitCategoryEnum).reduce( (categoryAcc, apiRateLimitCategory) => { const featureName = ApiRateLimitCategoryToFeatureName[apiRateLimitCategory]; const featureForTierAsNumber = getFeatureForTierAsNumber(featureName, apiServiceLevel); const envVarName = this.getEnvVarName(apiServiceLevel, apiRateLimitCategory); const envVarValue = processEnv[envVarName]; categoryAcc[apiRateLimitCategory] = envVarValue ? Number(envVarValue) : featureForTierAsNumber; return categoryAcc; }, {} as Record ); return acc; }, {} as IApiRateLimitServiceMaximum); } private getEnvVarName( apiServiceLevel: ApiServiceLevelEnum, apiRateLimitCategory: ApiRateLimitCategoryEnum ): ApiRateLimitServiceMaximumEnvVarFormat { return `API_RATE_LIMIT_MAXIMUM_${apiServiceLevel.toUpperCase() as Uppercase}_${ apiRateLimitCategory.toUpperCase() as Uppercase }`; } } ================================================ FILE: apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-maximum/index.ts ================================================ export * from './get-api-rate-limit-maximum.command'; export * from './get-api-rate-limit-maximum.usecase'; ================================================ FILE: apps/api/src/app/rate-limiting/usecases/index.ts ================================================ import { EvaluateApiRateLimit } from './evaluate-api-rate-limit'; import { EvaluateTokenBucketRateLimit } from './evaluate-token-bucket-rate-limit'; import { GetApiRateLimitAlgorithmConfig } from './get-api-rate-limit-algorithm-config'; import { GetApiRateLimitCostConfig } from './get-api-rate-limit-cost-config'; import { GetApiRateLimitMaximum } from './get-api-rate-limit-maximum'; export const USE_CASES = [ // GetApiRateLimitMaximum, GetApiRateLimitAlgorithmConfig, GetApiRateLimitCostConfig, EvaluateApiRateLimit, EvaluateTokenBucketRateLimit, ]; ================================================ FILE: apps/api/src/app/shared/commands/authenticated.command.ts ================================================ import { BaseCommand } from '@novu/application-generic'; import { IsNotEmpty } from 'class-validator'; export abstract class AuthenticatedCommand extends BaseCommand { @IsNotEmpty() public readonly userId: string; } ================================================ FILE: apps/api/src/app/shared/commands/organization.command.ts ================================================ import { IsNotEmpty } from 'class-validator'; import { AuthenticatedCommand } from './authenticated.command'; export abstract class OrganizationCommand extends AuthenticatedCommand { @IsNotEmpty() readonly organizationId: string; } ================================================ FILE: apps/api/src/app/shared/commands/project.command.ts ================================================ import { BaseCommand } from '@novu/application-generic'; import { IsArray, IsNotEmpty, IsOptional, IsString } from 'class-validator'; export abstract class EnvironmentCommand extends BaseCommand { @IsNotEmpty() readonly environmentId: string; @IsNotEmpty() readonly organizationId: string; } export abstract class EnvironmentWithUserCommand extends EnvironmentCommand { @IsNotEmpty() readonly userId: string; } export abstract class EnvironmentWithSubscriber extends EnvironmentCommand { @IsNotEmpty() readonly environmentId: string; @IsNotEmpty() readonly organizationId: string; @IsNotEmpty() readonly subscriberId: string; @IsOptional() @IsArray() @IsString({ each: true }) readonly contextKeys?: string[]; } ================================================ FILE: apps/api/src/app/shared/constants.ts ================================================ export const TRANSLATIONS_SERVICE = 'TRANSLATIONS_SERVICE'; export const MANAGE_TRANSLATIONS = 'MANAGE_TRANSLATIONS'; ================================================ FILE: apps/api/src/app/shared/dtos/api-key.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; export class ApiKey { @ApiProperty() key: string; @ApiProperty() _userId: string; } ================================================ FILE: apps/api/src/app/shared/dtos/base-responses.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; export enum DirectionEnum { ASC = 'ASC', DESC = 'DESC', } export class ResponseError { @ApiProperty({ description: 'The error code or identifier.', type: String, }) error: string; @ApiProperty({ description: 'Detailed error message.', type: String, }) message: string; @ApiProperty({ description: 'HTTP status code associated with the error.', type: Number, }) statusCode: number; } export class PaginatedResponse { @ApiProperty({ description: 'Array of data items of type T.', type: 'array', // Use 'array' instead of Array items: { type: 'object' }, // Define the type of items in the array }) data: T[]; @ApiProperty({ description: 'Indicates if there are more items available.', type: Boolean, }) hasMore: boolean; @ApiProperty({ description: 'Total number of items available.', type: Number, }) totalCount: number; @ApiProperty({ description: 'Number of items per page.', type: Number, }) pageSize: number; @ApiProperty({ description: 'Current page number.', type: Number, }) page: number; } export type KeysOfT = keyof T; export class CursorPaginationQueryDto { @ApiProperty({ description: 'Maximum number of items to return.', type: Number, }) limit?: number; @ApiProperty({ description: 'Cursor for pagination, used to fetch the next set of results.', type: String, }) cursor?: string; @ApiProperty({ description: 'Direction for ordering results.', enum: DirectionEnum, }) orderDirection?: DirectionEnum; @ApiProperty({ description: 'Field by which to order the results.', type: String, }) orderBy?: K; } export class LimitOffsetPaginationDto> { @ApiProperty({ description: 'Maximum number of items to return.', type: String, }) limit: string; @ApiProperty({ description: 'Number of items to skip before starting to collect the result set.', type: String, }) offset: string; @ApiProperty({ description: 'Direction for ordering results.', enum: DirectionEnum, }) orderDirection?: DirectionEnum; @ApiProperty({ description: 'Field by which to order the results.', type: String, }) orderBy?: K; } export class PaginationParams { @ApiProperty({ description: 'Current page number.', type: Number, }) page: number; @ApiProperty({ description: 'Number of items per page.', type: Number, }) limit: number; } export class PaginationWithQueryParams extends PaginationParams { @ApiProperty({ description: 'Optional search query string.', type: String, required: false, }) query?: string; } export enum OrderDirectionEnum { ASC = 1, DESC = -1, } export enum OrderByEnum { ASC = 'ASC', DESC = 'DESC', } ================================================ FILE: apps/api/src/app/shared/dtos/base-subscriber-fields.dto.ts ================================================ import { ApiPropertyOptional } from '@nestjs/swagger'; import { SubscriberCustomData } from '@novu/shared'; import { IsEmail, IsLocale, IsObject, IsOptional, IsString, IsTimeZone, ValidateIf } from 'class-validator'; export class BaseSubscriberFieldsDto { @ApiPropertyOptional({ description: 'First name of the subscriber', example: 'John', nullable: true, type: String, }) @IsOptional() @ValidateIf((obj) => obj.firstName !== null) @IsString() firstName?: string | null; @ApiPropertyOptional({ description: 'Last name of the subscriber', example: 'Doe', nullable: true, type: String, }) @IsOptional() @ValidateIf((obj) => obj.lastName !== null) @IsString() lastName?: string | null; @ApiPropertyOptional({ description: 'Email address of the subscriber', example: 'john.doe@example.com', nullable: true, type: String, }) @IsOptional() @ValidateIf((obj) => obj.email !== null) @IsEmail() email?: string | null; @ApiPropertyOptional({ description: 'Phone number of the subscriber', example: '+1234567890', nullable: true, type: String, }) @IsOptional() @ValidateIf((obj) => obj.phone !== null) @IsString() phone?: string | null; @ApiPropertyOptional({ description: 'Avatar URL or identifier', example: 'https://example.com/avatar.jpg', nullable: true, type: String, }) @IsOptional() @ValidateIf((obj) => obj.avatar !== null) @IsString() avatar?: string | null; @ApiPropertyOptional({ description: 'Locale of the subscriber', example: 'en-US', nullable: true, type: String, }) @IsOptional() @ValidateIf((obj) => obj.locale !== null) @IsLocale() locale?: string | null; @ApiPropertyOptional({ description: 'Timezone of the subscriber', example: 'America/New_York', nullable: true, type: String, }) @IsOptional() @ValidateIf((obj) => obj.timezone !== null) @IsTimeZone() timezone?: string | null; @ApiPropertyOptional({ type: Object, description: 'Additional custom data associated with the subscriber', nullable: true, additionalProperties: true, }) @IsOptional() @ValidateIf((obj) => obj.data !== null) @IsObject() data?: SubscriberCustomData | null; } ================================================ FILE: apps/api/src/app/shared/dtos/channel-preference.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { ChannelTypeEnum } from '@novu/shared'; import { IsBoolean, IsDefined, IsEnum } from 'class-validator'; export class ChannelPreference { @ApiProperty({ enum: [...Object.values(ChannelTypeEnum)], enumName: 'ChannelTypeEnum', description: 'The type of channel that is enabled or not', }) @IsDefined() @IsEnum(ChannelTypeEnum) type: ChannelTypeEnum; @ApiProperty({ type: Boolean, description: 'If channel is enabled or not', }) @IsBoolean() @IsDefined() enabled: boolean; } ================================================ FILE: apps/api/src/app/shared/dtos/cursor-paginated-response.ts ================================================ import { mixin } from '@nestjs/common'; import { ApiProperty, ApiPropertyOptions } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { ValidateNested } from 'class-validator'; type Constructor = new (...args: any[]) => T; export function withCursorPagination(Base: TBase, options?: ApiPropertyOptions | undefined) { class ResponseDTO { @ApiProperty({ isArray: true, type: Base, ...options, }) @Type(() => Base) @ValidateNested({ each: true }) data!: Array>; @ApiProperty({ description: 'The cursor for the next page of results, or null if there are no more pages.', type: String, nullable: true, }) next: string | null; @ApiProperty({ description: 'The cursor for the previous page of results, or null if this is the first page.', type: String, nullable: true, }) previous: string | null; @ApiProperty({ description: 'The total count of items (up to 50,000)', type: Number, }) totalCount: number; @ApiProperty({ description: 'Whether there are more than 50,000 results available', type: Boolean, }) totalCountCapped: boolean; } return mixin(ResponseDTO); // This is important otherwise you will get always the same instance } ================================================ FILE: apps/api/src/app/shared/dtos/cursor-pagination-request.ts ================================================ import { ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsInt, IsMongoId, IsOptional, Max, Min } from 'class-validator'; import type { Constructor, CursorPaginationParams } from '../types'; export function CursorPaginationRequestDto(defaultLimit = 10, maxLimit = 100): Constructor { class CursorPaginationRequest { @ApiPropertyOptional({ type: Number, required: false, default: defaultLimit, maximum: maxLimit, example: 10, }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(maxLimit) limit = defaultLimit; @ApiPropertyOptional() @IsOptional() @IsMongoId({ message: 'The after cursor must be a valid MongoDB ObjectId', }) after?: string; @ApiPropertyOptional({ type: Number, example: 0, }) @IsOptional() @Type(() => Number) @IsInt() @Min(0) offset = 0; } return CursorPaginationRequest; } ================================================ FILE: apps/api/src/app/shared/dtos/data-wrapper-dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; export class DataWrapperDto { @ApiProperty() data: T; } export class DataBooleanDto { @ApiProperty() data: boolean; } export class DataNumberDto { @ApiProperty() data: number; } ================================================ FILE: apps/api/src/app/shared/dtos/limit-offset-pagination.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsEnum, IsInt, IsNumber, IsOptional, IsString, Min } from 'class-validator'; // Enum for sorting direction export enum DirectionEnum { ASC = 'ASC', DESC = 'DESC', } export function LimitOffsetPaginationQueryDto( BaseClass: new (...args: any[]) => T, allowedFields: K[] ): new () => { limit?: number; offset?: number; orderDirection?: DirectionEnum; orderBy?: K; } { class PaginationDto { @ApiProperty({ description: 'Number of items to return per page', type: 'number', required: false, example: 10, }) @Transform(({ value }) => { // Convert to number, handle different input types const parsed = Number(value); return !Number.isNaN(parsed) ? parsed : undefined; }) @IsNumber() @IsInt() @Min(1) // Optional: ensure minimum limit @IsOptional() limit?: number; @ApiProperty({ description: 'Number of items to skip before starting to return results', type: 'number', required: false, example: 0, }) @Transform(({ value }) => { // Convert to number, handle different input types const parsed = Number(value); return !Number.isNaN(parsed) ? parsed : undefined; }) @IsInt() @IsNumber() @Min(0) // Ensure non-negative offset @IsOptional() offset?: number; @ApiPropertyOptional({ description: 'Direction of sorting', enum: DirectionEnum, enumName: 'DirectionEnum', required: false, }) @IsOptional() @IsEnum(DirectionEnum) orderDirection?: DirectionEnum; @ApiPropertyOptional({ description: 'Field to sort the results by', enum: allowedFields, enumName: `${BaseClass.name}SortField`, type: 'string', required: false, }) @IsOptional() @IsString() @IsEnum(Object.fromEntries(allowedFields.map((field) => [field, field]))) orderBy?: K; } return PaginationDto; } ================================================ FILE: apps/api/src/app/shared/dtos/message-template.ts ================================================ import { ActorTypeEnum, IActor, IEmailBlock, IMessageCTA, ITemplateVariable, MessageTemplateContentType, StepTypeEnum, } from '@novu/shared'; import { IsDefined, IsEnum, IsOptional, IsString, ValidateNested } from 'class-validator'; export class MessageTemplate { @IsOptional() @IsEnum(StepTypeEnum) type: StepTypeEnum; @IsOptional() variables?: ITemplateVariable[]; @IsDefined() content: string | IEmailBlock[]; @IsOptional() contentType?: MessageTemplateContentType; @IsOptional() @ValidateNested() cta?: IMessageCTA; @IsOptional() @IsString() feedId?: string; @IsOptional() layoutId?: string | null; @IsOptional() @IsString() name?: string; @IsOptional() @IsString() subject?: string; @IsOptional() @IsString() title?: string; @IsOptional() @IsString() preheader?: string; @IsOptional() @IsString() senderName?: string; @IsOptional() actor?: IActor; @IsOptional() _creatorId?: string; } ================================================ FILE: apps/api/src/app/shared/dtos/message.template.dto.ts ================================================ import { ActorTypeEnum, IEmailBlock, IMessageCTADto, ITemplateVariable, MessageTemplateContentType, StepTypeEnum, } from '@novu/shared'; export class MessageTemplateDto { type: StepTypeEnum; content: string | IEmailBlock[]; contentType?: MessageTemplateContentType; cta?: IMessageCTADto; actor?: { type: ActorTypeEnum; data: string | null; }; variables?: ITemplateVariable[]; _feedId?: string; _layoutId?: string | null; name?: string; subject?: string; title?: string; preheader?: string; senderName?: string; _creatorId?: string; } ================================================ FILE: apps/api/src/app/shared/dtos/notification-step-dto.ts ================================================ import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger'; import { StepFilterDto } from '@novu/application-generic'; import { DaysEnum, DelayTypeEnum, DigestTypeEnum, DigestUnitEnum, IDelayRegularMetadata, IDelayScheduledMetadata, IDigestBaseMetadata, IDigestRegularMetadata, IDigestTimedMetadata, ITimedConfig, IWorkflowStepMetadata, MonthlyTypeEnum, OrdinalEnum, OrdinalValueEnum, StepVariantDto, } from '@novu/shared'; import { Type } from 'class-transformer'; import { IsBoolean, IsString, ValidateNested } from 'class-validator'; import { MessageTemplate } from './message-template'; class TimedConfig implements ITimedConfig { @ApiPropertyOptional() atTime?: string; @ApiPropertyOptional({ enum: [...Object.values(DaysEnum)], isArray: true }) weekDays?: DaysEnum[]; @ApiPropertyOptional() monthDays?: number[]; @ApiPropertyOptional({ enum: [...Object.values(OrdinalEnum)] }) ordinal?: OrdinalEnum; @ApiPropertyOptional({ enum: [...Object.values(OrdinalValueEnum)] }) ordinalValue?: OrdinalValueEnum; @ApiPropertyOptional({ enum: [...Object.values(MonthlyTypeEnum)] }) monthlyType?: MonthlyTypeEnum; } class AmountAndUnit { @ApiPropertyOptional() amount: number; @ApiPropertyOptional({ enum: [...Object.values(DigestUnitEnum)], }) unit: DigestUnitEnum; } class DigestBaseMetadata extends AmountAndUnit implements IDigestBaseMetadata { @ApiPropertyOptional() digestKey?: string; } class DigestRegularMetadata extends DigestBaseMetadata implements IDigestRegularMetadata { @ApiProperty({ enum: [DigestTypeEnum.REGULAR, DigestTypeEnum.BACKOFF] }) type: DigestTypeEnum.REGULAR | DigestTypeEnum.BACKOFF; @ApiPropertyOptional() backoff?: boolean; @ApiPropertyOptional() backoffAmount?: number; @ApiPropertyOptional({ enum: [...Object.values(DigestUnitEnum)], }) backoffUnit?: DigestUnitEnum; @ApiPropertyOptional() updateMode?: boolean; } class DigestTimedMetadata extends DigestBaseMetadata implements IDigestTimedMetadata { @ApiProperty({ enum: [DigestTypeEnum.TIMED], }) type: DigestTypeEnum.TIMED; @ApiPropertyOptional() @ValidateNested() timed?: TimedConfig; } class DelayRegularMetadata extends AmountAndUnit implements IDelayRegularMetadata { @ApiProperty({ enum: [DelayTypeEnum.REGULAR], }) type: DelayTypeEnum.REGULAR; } class DelayScheduledMetadata implements IDelayScheduledMetadata { @ApiProperty({ enum: [DelayTypeEnum.SCHEDULED], }) type: DelayTypeEnum.SCHEDULED; @ApiProperty() delayPath: string; } // Define the ReplyCallback type with OpenAPI annotations export class ReplyCallback { @ApiPropertyOptional({ description: 'Indicates whether the reply callback is active.', type: Boolean, }) @IsBoolean() active: boolean; @ApiPropertyOptional({ description: 'The URL to which replies should be sent.', type: String, }) @IsString() url: string; } @ApiExtraModels(DigestRegularMetadata, DigestTimedMetadata, DelayRegularMetadata, DelayScheduledMetadata) export class NotificationStepData implements StepVariantDto { @ApiPropertyOptional({ description: 'Unique identifier for the notification step.', type: String, }) _id?: string; @ApiPropertyOptional({ description: 'Universally unique identifier for the notification step.', type: String, }) uuid?: string; @ApiPropertyOptional({ description: 'Name of the notification step.', type: String, }) name?: string; @ApiPropertyOptional({ description: 'ID of the template associated with this notification step.', type: String, }) _templateId?: string; @ApiPropertyOptional({ description: 'Indicates whether the notification step is active.', type: Boolean, }) @IsBoolean() active?: boolean; @ApiPropertyOptional({ description: 'Determines if the process should stop on failure.', type: Boolean, }) shouldStopOnFail?: boolean; @ApiPropertyOptional({ description: 'Message template used in this notification step.', type: () => MessageTemplate, // Assuming MessageTemplate is a class }) @ValidateNested() template?: MessageTemplate; @ApiPropertyOptional({ description: 'Filters applied to this notification step.', type: [StepFilterDto], }) @ValidateNested({ each: true }) filters?: StepFilterDto[]; @ApiPropertyOptional({ description: 'ID of the parent notification step, if applicable.', type: String, }) _parentId?: string | null; @ApiPropertyOptional({ description: 'Metadata associated with the workflow step. Can vary based on the type of step.', oneOf: [ { $ref: getSchemaPath(DigestRegularMetadata) }, { $ref: getSchemaPath(DigestTimedMetadata) }, { $ref: getSchemaPath(DelayRegularMetadata) }, { $ref: getSchemaPath(DelayScheduledMetadata) }, ], }) metadata?: IWorkflowStepMetadata; @ApiPropertyOptional({ description: 'Callback information for replies, including whether it is active and the callback URL.', type: () => ReplyCallback, }) replyCallback?: ReplyCallback; } export class NotificationStepDto extends NotificationStepData { @ApiPropertyOptional({ type: () => [NotificationStepData], // Specify that this is an array of NotificationStepData }) @ValidateNested({ each: true }) // Validate each nested variant @Type(() => NotificationStepData) // Transform to NotificationStepData instances variants?: NotificationStepData[]; } ================================================ FILE: apps/api/src/app/shared/dtos/pagination-request.ts ================================================ import { ApiPropertyOptional } from '@nestjs/swagger'; import { IPaginationParams } from '@novu/shared'; import { Type } from 'class-transformer'; import { IsInt, Max, Min } from 'class-validator'; import { Constructor } from '../types'; export function PaginationRequestDto(defaultLimit = 10, maxLimit = 100): Constructor { class PaginationRequest { @ApiPropertyOptional({ type: Number, required: false, example: 0, }) @Type(() => Number) @IsInt() page = 0; @ApiPropertyOptional({ type: Number, required: false, default: defaultLimit, maximum: maxLimit, example: 10, }) @Type(() => Number) @IsInt() @Min(1) @Max(maxLimit) limit = defaultLimit; } return PaginationRequest; } ================================================ FILE: apps/api/src/app/shared/dtos/pagination-response.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { IPaginatedResponseDto } from '@novu/shared'; export class PaginatedResponseDto implements IPaginatedResponseDto { @ApiProperty({ description: 'The current page of the paginated response', }) page: number; @ApiProperty({ description: 'Does the list have more items to fetch', }) hasMore: boolean; @ApiProperty({ description: 'Number of items on each page', }) pageSize: number; @ApiProperty({ description: 'The list of items matching the query', isArray: true, type: Object, }) data: T[]; } ================================================ FILE: apps/api/src/app/shared/dtos/pagination-with-filters-request.ts ================================================ import { ApiPropertyOptional } from '@nestjs/swagger'; import { IPaginationWithQueryParams } from '@novu/shared'; import { IsOptional, IsString } from 'class-validator'; import { Constructor } from '../types'; import { PaginationRequestDto } from './pagination-request'; export function PaginationWithFiltersRequestDto({ defaultLimit = 10, maxLimit = 100, queryDescription, }: { defaultLimit: number; maxLimit: number; queryDescription: string; }): Constructor { class PaginationWithFiltersRequest extends PaginationRequestDto(defaultLimit, maxLimit) { @ApiPropertyOptional({ type: String, required: false, description: `A query string to filter the results. ${queryDescription}`, }) @IsOptional() @IsString() query?: string; } return PaginationWithFiltersRequest; } ================================================ FILE: apps/api/src/app/shared/dtos/preference-channels.ts ================================================ import { ApiPropertyOptional } from '@nestjs/swagger'; import { IsBoolean, IsOptional } from 'class-validator'; export class SubscriberPreferenceChannels { @ApiPropertyOptional({ type: Boolean, description: 'Email channel preference', example: true, }) @IsBoolean() @IsOptional() email?: boolean; @ApiPropertyOptional({ type: Boolean, description: 'SMS channel preference', example: false, }) @IsBoolean() @IsOptional() sms?: boolean; @ApiPropertyOptional({ type: Boolean, description: 'In-app channel preference', example: true, }) @IsBoolean() @IsOptional() in_app?: boolean; @ApiPropertyOptional({ type: Boolean, description: 'Chat channel preference', example: false, }) @IsBoolean() @IsOptional() chat?: boolean; @ApiPropertyOptional({ type: Boolean, description: 'Push notification channel preference', example: true, }) @IsBoolean() @IsOptional() push?: boolean; } ================================================ FILE: apps/api/src/app/shared/dtos/schedule.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsBoolean, IsOptional, IsString, ValidateNested } from 'class-validator'; import { IsTime12HourFormat } from '../validators/is-time-12-hour-format.validator'; import { WeeklyScheduleValidation } from '../validators/weekly-schedule-disabled.validator'; export class TimeRangeDto { @ApiProperty({ type: String, description: 'Start time', example: '09:00 AM', }) @IsString() @IsTime12HourFormat() readonly start: string; @ApiProperty({ type: String, description: 'End time', example: '05:00 PM', }) @IsString() @IsTime12HourFormat() readonly end: string; } export class DayScheduleDto { @ApiProperty({ type: Boolean, description: 'Day schedule enabled', example: true, }) @IsBoolean() readonly isEnabled: boolean; @ApiPropertyOptional({ type: [TimeRangeDto], description: 'Hours', example: [{ start: '09:00 AM', end: '05:00 PM' }], }) @IsOptional() @ValidateNested() @Type(() => TimeRangeDto) readonly hours?: TimeRangeDto[]; } export class WeeklyScheduleDto { @ApiPropertyOptional({ type: DayScheduleDto, description: 'Monday schedule', example: { isEnabled: true, hours: [{ start: '09:00 AM', end: '05:00 PM' }], }, }) @IsOptional() @ValidateNested() @Type(() => DayScheduleDto) readonly monday?: DayScheduleDto; @ApiPropertyOptional({ type: DayScheduleDto, description: 'Tuesday schedule', example: { isEnabled: true, hours: [{ start: '09:00 AM', end: '05:00 PM' }], }, }) @IsOptional() @ValidateNested() @Type(() => DayScheduleDto) readonly tuesday?: DayScheduleDto; @ApiPropertyOptional({ type: DayScheduleDto, description: 'Wednesday schedule', example: { isEnabled: true, hours: [{ start: '09:00 AM', end: '05:00 PM' }], }, }) @IsOptional() @ValidateNested() @Type(() => DayScheduleDto) readonly wednesday?: DayScheduleDto; @ApiPropertyOptional({ type: DayScheduleDto, description: 'Thursday schedule', example: { isEnabled: true, hours: [{ start: '09:00 AM', end: '05:00 PM' }], }, }) @IsOptional() @ValidateNested() @Type(() => DayScheduleDto) readonly thursday?: DayScheduleDto; @ApiPropertyOptional({ type: DayScheduleDto, description: 'Friday schedule', example: { isEnabled: true, hours: [{ start: '09:00 AM', end: '05:00 PM' }], }, }) @IsOptional() @ValidateNested() @Type(() => DayScheduleDto) readonly friday?: DayScheduleDto; @ApiPropertyOptional({ type: DayScheduleDto, description: 'Saturday schedule', example: { isEnabled: true, hours: [{ start: '09:00 AM', end: '05:00 PM' }], }, }) @IsOptional() @ValidateNested() @Type(() => DayScheduleDto) readonly saturday?: DayScheduleDto; @ApiPropertyOptional({ type: DayScheduleDto, description: 'Sunday schedule', example: { isEnabled: true, hours: [{ start: '09:00 AM', end: '05:00 PM' }], }, }) @IsOptional() @ValidateNested() @Type(() => DayScheduleDto) readonly sunday?: DayScheduleDto; } export class ScheduleDto { @ApiProperty({ type: Boolean, description: 'Schedule enabled', example: true, }) @IsBoolean() readonly isEnabled: boolean; @ApiPropertyOptional({ type: WeeklyScheduleDto, description: 'Weekly schedule', example: { monday: { isEnabled: true, hours: [{ start: '09:00 AM', end: '05:00 PM' }], }, tuesday: { isEnabled: true, hours: [{ start: '09:00 AM', end: '05:00 PM' }], }, wednesday: { isEnabled: true, hours: [{ start: '09:00 AM', end: '05:00 PM' }], }, thursday: { isEnabled: true, hours: [{ start: '09:00 AM', end: '05:00 PM' }], }, friday: { isEnabled: true, hours: [{ start: '09:00 AM', end: '05:00 PM' }], }, saturday: { isEnabled: true, hours: [{ start: '09:00 AM', end: '05:00 PM' }], }, sunday: { isEnabled: true, hours: [{ start: '09:00 AM', end: '05:00 PM' }], }, }, }) @IsOptional() @ValidateNested() @Type(() => WeeklyScheduleDto) @WeeklyScheduleValidation() readonly weeklySchedule?: WeeklyScheduleDto; } ================================================ FILE: apps/api/src/app/shared/dtos/subscription-details-response.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsArray, IsOptional, IsString, ValidateNested } from 'class-validator'; import { SubscriptionPreferenceDto } from './subscriptions/create-subscriptions-response.dto'; export class SubscriptionDetailsResponseDto { @ApiProperty({ description: 'The unique identifier of the subscription', example: '64f5e95d3d7946d80d0cb679', }) @IsString() id: string; @ApiProperty({ description: 'The identifier of the subscription', example: 'subscription-identifier', }) @IsString() identifier?: string; @ApiPropertyOptional({ description: 'The name of the subscription', example: 'My Subscription', }) @IsString() @IsOptional() name?: string; @ApiPropertyOptional({ description: 'The preferences/rules for the subscription', type: [SubscriptionPreferenceDto], }) @IsArray() @ValidateNested({ each: true }) @Type(() => SubscriptionPreferenceDto) @IsOptional() preferences?: SubscriptionPreferenceDto[]; @ApiPropertyOptional({ description: 'Context keys that scope this subscription (e.g., tenant:org-a, project:proj-123)', example: ['tenant:org-a', 'project:proj-123'], type: [String], }) contextKeys?: string[]; } ================================================ FILE: apps/api/src/app/shared/dtos/subscriptions/create-subscriptions-response.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsArray, IsDefined, IsOptional, IsString, ValidateIf, ValidateNested } from 'class-validator'; import { RulesLogic } from 'json-logic-js'; import { WorkflowDto } from '../../../inbox/dtos/workflow.dto'; export class TopicDto { @ApiProperty({ description: 'The internal unique identifier of the topic', example: '64f5e95d3d7946d80d0cb677', }) @IsString() _id: string; @ApiProperty({ description: 'The key identifier of the topic used in your application. Should be unique on the environment level.', example: 'product-updates', }) @IsString() key: string; @ApiPropertyOptional({ description: 'The name of the topic', example: 'Product Updates', }) @IsString() @IsOptional() name?: string; } export class SubscriberDto { @ApiProperty({ description: 'The unique identifier of the subscriber', example: '64f5e95d3d7946d80d0cb678', }) @IsString() _id: string; @ApiProperty({ description: 'The external identifier of the subscriber', example: 'external-subscriber-id', }) @IsString() subscriberId: string; @ApiPropertyOptional({ description: 'The avatar URL of the subscriber', example: 'https://example.com/avatar.png', }) @IsString() @IsOptional() avatar?: string; @ApiPropertyOptional({ description: 'The first name of the subscriber', example: 'John', }) @IsString() @IsOptional() firstName?: string; @ApiPropertyOptional({ description: 'The last name of the subscriber', example: 'Doe', }) @IsString() @IsOptional() lastName?: string; @ApiPropertyOptional({ description: 'The email of the subscriber', example: 'john.doe@example.com', }) @IsString() @IsOptional() email?: string; @ApiPropertyOptional({ description: 'The creation date of the subscriber', example: '2025-04-24T05:40:21Z', }) @IsString() @IsOptional() createdAt?: string; @ApiPropertyOptional({ description: 'The last update date of the subscriber', example: '2025-04-24T05:40:21Z', }) @IsString() @IsOptional() updatedAt?: string; } export class SubscriptionPreferenceDto { @ApiProperty({ description: 'The unique identifier of the subscription', example: '64f5e95d3d7946d80d0cb679', }) @IsString() subscriptionId: string; @ApiPropertyOptional({ type: () => WorkflowDto, description: 'Workflow information if this is a template-level preference', nullable: true, }) @IsOptional() @ValidateNested() @Type(() => WorkflowDto) workflow?: WorkflowDto; @ApiProperty({ type: Boolean, description: 'Whether the preference is enabled', example: true, }) @IsDefined() enabled: boolean; @ApiPropertyOptional({ description: 'Optional condition using JSON Logic rules', required: false, type: 'object', additionalProperties: true, example: { and: [{ '===': [{ var: 'tier' }, 'premium'] }] }, }) @ValidateIf((o) => o.condition !== undefined) @IsOptional() condition?: RulesLogic; } export class SubscriptionResponseDto { @ApiProperty({ description: 'The unique identifier of the subscription', example: '64f5e95d3d7946d80d0cb679', }) @IsString() _id: string; @ApiProperty({ description: 'The identifier of the subscription', example: 'tk=product-updates:si=subscriber-123', }) @IsString() @IsOptional() identifier?: string; @ApiPropertyOptional({ description: 'The name of the subscription', example: 'My Subscription', }) @IsString() @IsOptional() name?: string; @ApiProperty({ description: 'The topic information', type: () => TopicDto, }) topic: TopicDto; @ApiProperty({ description: 'The subscriber information', type: () => SubscriberDto, nullable: true, }) subscriber: SubscriberDto | null; @ApiPropertyOptional({ description: 'The preferences for workflows in this subscription', type: () => [SubscriptionPreferenceDto], }) @IsArray() @IsOptional() preferences?: SubscriptionPreferenceDto[]; @ApiPropertyOptional({ description: 'Context keys that scope this subscription (e.g., tenant:org-a, project:proj-123)', example: ['tenant:org-a', 'project:proj-123'], type: [String], }) contextKeys?: string[]; @ApiProperty({ description: 'The creation date of the subscription', example: '2025-04-24T05:40:21Z', }) createdAt: string; @ApiProperty({ description: 'The last update date of the subscription', example: '2025-04-24T05:40:21Z', }) updatedAt: string; } export class SubscriptionErrorDto { @ApiProperty({ description: 'The subscriber ID that failed', example: 'invalid-subscriber-id', }) subscriberId: string; @ApiProperty({ description: 'The error code', example: 'SUBSCRIBER_NOT_FOUND', }) code: string; @ApiProperty({ description: 'The error message', example: 'Subscriber with ID invalid-subscriber-id could not be found', }) message: string; } export class MetaDto { @ApiProperty({ description: 'The total count of subscriber IDs provided', example: 3, }) totalCount: number; @ApiProperty({ description: 'The count of successfully created subscriptions', example: 2, }) successful: number; @ApiProperty({ description: 'The count of failed subscription attempts', example: 1, }) failed: number; } export class CreateSubscriptionsResponseDto { @ApiProperty({ description: 'The list of successfully created subscriptions', type: () => [SubscriptionResponseDto], }) data: SubscriptionResponseDto[]; @ApiProperty({ description: 'Metadata about the operation', type: MetaDto, }) meta: MetaDto; @ApiPropertyOptional({ description: 'The list of errors for failed subscription attempts', type: [SubscriptionErrorDto], }) errors?: SubscriptionErrorDto[]; } ================================================ FILE: apps/api/src/app/shared/dtos/subscriptions/create-subscriptions.dto.ts ================================================ import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { ArrayMaxSize, ArrayMinSize, IsArray, IsDefined, IsOptional, IsString, ValidateIf, ValidateNested, } from 'class-validator'; import { RulesLogic } from 'json-logic-js'; export class TopicSubscriberIdentifierDto { @ApiProperty({ description: 'Unique identifier for this subscription', example: 'subscriber-123-subscription-a', }) @IsString() @IsDefined() identifier: string; @ApiProperty({ description: 'The subscriber ID', example: 'subscriber-123', }) @IsString() @IsDefined() subscriberId: string; @ApiPropertyOptional({ description: 'The name of the subscription', example: 'My Subscription', }) @IsString() @IsOptional() name?: string; } export class BasePreferenceDto { @ApiProperty({ description: 'Whether the preference is enabled. Used when condition is not provided.', required: false, type: Boolean, example: true, }) @IsOptional() enabled?: boolean; @ApiProperty({ description: 'Optional condition using JSON Logic rules', required: false, type: 'object', additionalProperties: true, example: { and: [{ '===': [{ var: 'tier' }, 'premium'] }] }, }) @ValidateIf((o) => o.condition !== undefined) @IsOptional() condition?: RulesLogic; } export class WorkflowPreferenceRequestDto extends BasePreferenceDto { @ApiProperty({ description: 'The workflow identifier', example: 'workflow-123', }) @IsString() @IsDefined() workflowId: string; } export class GroupPreferenceFilterDetailsDto { @ApiProperty({ description: 'List of workflow identifiers', type: [String], example: ['workflow-1', 'workflow-2'], }) @IsArray() @IsString({ each: true }) @IsOptional() workflowIds?: string[]; @ApiProperty({ description: 'List of tags', type: [String], example: ['tag1', 'tag2'], }) @IsArray() @IsString({ each: true }) @IsOptional() tags?: string[]; } export class GroupPreferenceFilterDto extends BasePreferenceDto { @ApiProperty({ description: 'Filter criteria for workflow IDs and tags', type: GroupPreferenceFilterDetailsDto, }) @ValidateNested() @Type(() => GroupPreferenceFilterDetailsDto) @IsDefined() filter: GroupPreferenceFilterDetailsDto; } @ApiExtraModels(WorkflowPreferenceRequestDto, GroupPreferenceFilterDto, TopicSubscriberIdentifierDto) export class CreateSubscriptionsRequestDto { @ApiProperty({ description: 'List of subscriber IDs to subscribe to the topic (max: 100). @deprecated Use the "subscriptions" property instead.', type: [String], example: ['subscriberId1', 'subscriberId2'], deprecated: true, }) @IsArray() @IsString({ each: true }) @IsOptional() @ArrayMaxSize(100, { message: 'Cannot subscribe more than 100 subscribers at once' }) @ArrayMinSize(1, { message: 'At least one subscriber identifier is required' }) subscriberIds?: string[]; @ApiProperty({ description: 'List of subscriptions to subscribe to the topic (max: 100). Can be either a string array of subscriber IDs or an array of objects with identifier and subscriberId', type: 'array', items: { oneOf: [{ type: 'string' }, { $ref: getSchemaPath(TopicSubscriberIdentifierDto) }], }, example: [ { identifier: 'subscriber-123-subscription-a', subscriberId: 'subscriber-123' }, { identifier: 'subscriber-456-subscription-b', subscriberId: 'subscriber-456' }, ], }) @IsArray() @IsOptional() @ArrayMaxSize(100, { message: 'Cannot subscribe more than 100 subscriptions at once' }) @ArrayMinSize(1, { message: 'At least one subscription is required' }) subscriptions?: Array; @ApiProperty({ description: 'The name of the topic', example: 'My Topic', }) @IsString() @IsOptional() name?: string; @ApiProperty({ description: 'The preferences of the topic. Can be a simple workflow ID string, workflow preference object, or group filter object', type: 'array', items: { oneOf: [ { type: 'string' }, { $ref: getSchemaPath(WorkflowPreferenceRequestDto) }, { $ref: getSchemaPath(GroupPreferenceFilterDto) }, ], }, example: [{ workflowId: 'workflow-123', condition: { '===': [{ var: 'tier' }, 'premium'] } }], }) @IsArray() @IsOptional() preferences?: Array; } ================================================ FILE: apps/api/src/app/shared/dtos/subscriptions/update-subscription.dto.ts ================================================ import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger'; import { IsArray, IsOptional, IsString } from 'class-validator'; import { GroupPreferenceFilterDto, WorkflowPreferenceRequestDto } from './create-subscriptions.dto'; @ApiExtraModels(WorkflowPreferenceRequestDto, GroupPreferenceFilterDto) export class UpdateSubscriptionRequestDto { @ApiProperty({ description: 'The name of the subscription', example: 'My Subscription', }) @IsString() @IsOptional() name?: string; @ApiProperty({ description: 'The preferences of the subscription. Can be a simple workflow ID string, workflow preference object, or group filter object', type: 'array', items: { oneOf: [ { type: 'string' }, { $ref: getSchemaPath(WorkflowPreferenceRequestDto) }, { $ref: getSchemaPath(GroupPreferenceFilterDto) }, ], }, example: [{ workflowId: 'workflow-123', condition: { '===': [{ var: 'tier' }, 'premium'] } }], }) @IsArray() @IsOptional() preferences?: Array; } ================================================ FILE: apps/api/src/app/shared/framework/analytics-logs.guard.ts ================================================ import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; const LOG_ANALYTICS_KEY = 'logAnalytics'; /** * Analytics Logs Guard * * This guard sets the `_shouldLogAnalytics` flag on incoming requests early in the NestJS lifecycle. * It runs BEFORE interceptors, ensuring the flag is available even if interceptors throw exceptions. * * Why use a Guard instead of an Interceptor? * - Guards execute before interceptors in the NestJS request lifecycle * - If any interceptor throws an exception, subsequent interceptors never run, so the flag * cannot be reliably set by an interceptor that might not execute * - By setting the flag in a guard, AllExceptionsFilter can always check for analytics logging * regardless of which interceptor threw the exception * - AllExceptionsFilter cannot access decorator metadata directly since it operates outside * the normal request lifecycle and doesn't have access to the original ExecutionContext * * Example execution order: * 1. Guard runs → sets _shouldLogAnalytics = true * 2. QuotaThrottlerInterceptor runs → throws exception * 3. AllExceptionsFilter runs → finds _shouldLogAnalytics = true → logs analytics */ @Injectable() export class AnalyticsLogsGuard implements CanActivate { constructor(private reflector: Reflector) {} canActivate(context: ExecutionContext): boolean { const shouldLogAnalytics = this.shouldLogAnalytics(context); if (shouldLogAnalytics) { const request = context.switchToHttp().getRequest(); request._shouldLogAnalytics = true; } // Always return true - this guard never blocks requests, it only sets metadata return true; } private shouldLogAnalytics(context: ExecutionContext): boolean { // Check if @LogAnalytics() decorator is present on the handler or controller const handlerMetadata = this.reflector.get(LOG_ANALYTICS_KEY, context.getHandler()); const classMetadata = this.reflector.get(LOG_ANALYTICS_KEY, context.getClass()); return handlerMetadata !== undefined || classMetadata !== undefined; } } ================================================ FILE: apps/api/src/app/shared/framework/analytics-logs.interceptor.ts ================================================ import { applyDecorators, CallHandler, ExecutionContext, Injectable, NestInterceptor, SetMetadata, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { PinoLogger, RequestLog, RequestLogRepository } from '@novu/application-generic'; import { UserSessionData } from '@novu/shared'; import { Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; import { TriggerEventResponseDto } from '../../events/dtos/trigger-event-response.dto'; import { buildLog } from '../utils/mappers'; const LOG_ANALYTICS_KEY = 'logAnalytics'; export enum AnalyticsStrategyEnum { BASIC = 'basic', EVENTS = 'events', EVENTS_BULK = 'events_bulk', } export function LogAnalytics(strategy: AnalyticsStrategyEnum = AnalyticsStrategyEnum.BASIC): MethodDecorator { return applyDecorators(SetMetadata(LOG_ANALYTICS_KEY, strategy)); } @Injectable() export class AnalyticsLogsInterceptor implements NestInterceptor { constructor( private readonly requestLogRepository: RequestLogRepository, private readonly logger: PinoLogger, private readonly reflector: Reflector ) { this.logger.setContext(this.constructor.name); } private shouldLogAnalytics(context: ExecutionContext): boolean { const strategy = this.getAnalyticsStrategy(context); this.logger.debug(`Analytics logs should log strategy: ${strategy}`); return strategy !== undefined; } private getAnalyticsStrategy(context: ExecutionContext): AnalyticsStrategyEnum { const globalHandler = context.getHandler && Reflect.getMetadata(LOG_ANALYTICS_KEY, context.getHandler()); const handlerMetadata = this.reflector.get(LOG_ANALYTICS_KEY, context.getHandler()); const handler = context.getHandler(); const customDecorator = handler && (handler as any)._analyticsStrategy; this.logger.debug(`Analytics logs globalHandler strategy: ${globalHandler}`); this.logger.debug(`Analytics logs handlerMetadata strategy: ${handlerMetadata}`); this.logger.debug(`Analytics logs customDecorator strategy: ${customDecorator}`); return globalHandler || handlerMetadata || customDecorator; } async intercept(context: ExecutionContext, next: CallHandler): Promise> { const shouldRun = await this.shouldRun(context); this.logger.debug(`Analytics logs should run LOG_ANALYTICS_KEY: ${shouldRun}`); if (!shouldRun) { return next.handle(); } const req = context.switchToHttp().getRequest(); const user = req.user as UserSessionData; const start = Date.now(); const res = context.switchToHttp().getResponse(); this.logger.debug('Analytics logs interceptor started'); return next.handle().pipe( tap(async (data) => { const duration = Date.now() - start; const basicLog = buildLog(req, res.statusCode, data, user, duration); if (!basicLog) { this.logger.warn('Analytics log construction failed - unable to track request metrics'); return; } const analyticsLog = this.buildLogByStrategy(context, basicLog, data); try { this.logger.debug({ analyticsLog }, 'Analytics log Inserting'); await this.requestLogRepository.create(analyticsLog, { organizationId: user?.organizationId, environmentId: user?.environmentId, userId: user?._id, }); this.logger.debug('Analytics log Inserted'); } catch (err) { this.logger.error({ err }, 'Failed to log analytics to ClickHouse after retries'); } }) ); } private async shouldRun(context: ExecutionContext): Promise { const shouldLog = this.shouldLogAnalytics(context); if (!shouldLog) return false; const isEnabled = process.env.IS_ANALYTICS_LOGS_ENABLED === 'true'; this.logger.debug( `Analytics logs should run IS_ANALYTICS_LOGS_ENABLED: ${process.env.IS_ANALYTICS_LOGS_ENABLED}, isEnabled: ${isEnabled}` ); if (!isEnabled) return false; return true; } private buildLogByStrategy( context: ExecutionContext, analyticsLog: Omit, res: unknown ): Omit { const strategy = this.getAnalyticsStrategy(context); if (strategy === AnalyticsStrategyEnum.EVENTS) { const eventResponse = (res as any).data as TriggerEventResponseDto; if (eventResponse.transactionId) { return { ...analyticsLog, transaction_id: eventResponse.transactionId, }; } } if (strategy === AnalyticsStrategyEnum.EVENTS_BULK) { const bulkEventResponse = (res as any).data as TriggerEventResponseDto[]; if (Array.isArray(bulkEventResponse)) { const transactionIds = bulkEventResponse .map((response) => response.transactionId) .filter(Boolean) .join(','); if (transactionIds) { return { ...analyticsLog, transaction_id: transactionIds, }; } } } return analyticsLog; } } ================================================ FILE: apps/api/src/app/shared/framework/constants/headers.schema.ts ================================================ import { HeaderObject, HttpResponseHeaderKeysEnum } from '@novu/application-generic'; export const COMMON_RESPONSE_HEADERS: Array = [ HttpResponseHeaderKeysEnum.CONTENT_TYPE, HttpResponseHeaderKeysEnum.RATELIMIT_LIMIT, HttpResponseHeaderKeysEnum.RATELIMIT_REMAINING, HttpResponseHeaderKeysEnum.RATELIMIT_RESET, HttpResponseHeaderKeysEnum.RATELIMIT_POLICY, HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY, HttpResponseHeaderKeysEnum.IDEMPOTENCY_REPLAY, ]; export const RESPONSE_HEADER_CONFIG: Record = { [HttpResponseHeaderKeysEnum.CONTENT_TYPE]: { required: true, description: 'The MIME type of the response body.', schema: { type: 'string' }, example: 'application/json', }, [HttpResponseHeaderKeysEnum.RATELIMIT_LIMIT]: { required: false, description: 'The number of requests that the client is permitted to make per second. The actual maximum may differ when burst is enabled.', schema: { type: 'string' }, example: '100', }, [HttpResponseHeaderKeysEnum.RATELIMIT_REMAINING]: { required: false, description: 'The number of requests remaining until the next window.', schema: { type: 'string' }, example: '93', }, [HttpResponseHeaderKeysEnum.RATELIMIT_RESET]: { required: false, description: 'The remaining seconds until a request of the same cost will be refreshed.', schema: { type: 'string' }, example: '8', }, [HttpResponseHeaderKeysEnum.RATELIMIT_POLICY]: { required: false, description: 'The rate limit policy that was used to evaluate the request.', schema: { type: 'string' }, example: '100;w=1;burst=110;comment="token bucket";category="trigger";cost="single"', }, [HttpResponseHeaderKeysEnum.RETRY_AFTER]: { required: false, description: 'The number of seconds after which the client may retry the request that was previously rejected.', schema: { type: 'string' }, example: '8', }, [HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY]: { required: false, description: 'The idempotency key used to evaluate the request.', schema: { type: 'string' }, example: '8', }, [HttpResponseHeaderKeysEnum.IDEMPOTENCY_REPLAY]: { required: false, description: 'Whether the request was a replay of a previous request.', schema: { type: 'string' }, example: 'true', }, [HttpResponseHeaderKeysEnum.LINK]: { required: false, description: 'A link to the documentation.', schema: { type: 'string' }, example: 'https://docs.novu.co/', }, }; ================================================ FILE: apps/api/src/app/shared/framework/constants/index.ts ================================================ export * from './headers.schema'; export * from './responses.schema'; ================================================ FILE: apps/api/src/app/shared/framework/constants/responses.schema.ts ================================================ import { ApiResponseOptions } from '@nestjs/swagger'; import { ApiResponseDecoratorName, HttpResponseHeaderKeysEnum } from '@novu/application-generic'; import { THROTTLED_EXCEPTION_MESSAGE } from '../../../rate-limiting/guards'; import { createReusableHeaders } from '../swagger'; export const COMMON_RESPONSES: Partial> = { ApiConflictResponse: { description: 'The request could not be completed due to a conflict with the current state of the target resource.', schema: { type: 'string', example: 'Request with key 3909d656-d4fe-4e80-ba86-90d3861afcd7 is currently being processed. Please retry after 1 second', }, headers: createReusableHeaders([HttpResponseHeaderKeysEnum.RETRY_AFTER, HttpResponseHeaderKeysEnum.LINK]), }, ApiTooManyRequestsResponse: { description: 'The client has sent too many requests in a given amount of time. ', schema: { type: 'string', example: THROTTLED_EXCEPTION_MESSAGE }, headers: createReusableHeaders([HttpResponseHeaderKeysEnum.RETRY_AFTER]), }, ApiServiceUnavailableResponse: { description: 'The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.', schema: { type: 'string', example: 'Please wait some time, then try again.' }, headers: createReusableHeaders([HttpResponseHeaderKeysEnum.RETRY_AFTER]), }, }; ================================================ FILE: apps/api/src/app/shared/framework/exclude-from-idempotency.ts ================================================ import { applyDecorators, SetMetadata } from '@nestjs/common'; export const EXCLUDE_FROM_IDEMPOTENCY = 'exclude_from_idempotency'; export function ExcludeFromIdempotency() { return applyDecorators(SetMetadata(EXCLUDE_FROM_IDEMPOTENCY, true)); } ================================================ FILE: apps/api/src/app/shared/framework/idempotency.e2e.ts ================================================ import { CacheService, HttpResponseHeaderKeysEnum } from '@novu/application-generic'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { IdempotenceTestingResponse, IdempotencyBehaviorEnum, IdempotencyTestingDto, } from '../../testing/dtos/idempotency.dto'; import { expectSdkExceptionGeneric } from '../helpers/e2e/sdk/e2e-sdk.helper'; const DOCS_LINK = 'https://docs.novu.co/additional-resources/idempotency'; // @ts-ignore process.env.LAUNCH_DARKLY_SDK_KEY = ''; // disable Launch Darkly to allow test to define FF state const idempotancyKey = HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY.toLowerCase(); const retryAfterHeaderKey = HttpResponseHeaderKeysEnum.RETRY_AFTER.toLowerCase(); const IDEMPOTENCE_IMMEDIATE_EXCEPTION = { expectedBehavior: IdempotencyBehaviorEnum.IMMEDIATE_EXCEPTION, }; const IDEMPOTENCE_IMMEDIATE_RESPONSE = { expectedBehavior: IdempotencyBehaviorEnum.IMMEDIATE_RESPONSE, }; const IDEMPOTENCE_DELAYED_RESPONSE = { expectedBehavior: IdempotencyBehaviorEnum.DELAYED_RESPONSE, }; const idempotancyReplayKey = HttpResponseHeaderKeysEnum.IDEMPOTENCY_REPLAY.toLowerCase(); describe('Idempotency Test', async () => { let session: UserSession; const path = '/v1/health-check/test-idempotency'; let cacheService: CacheService | null = null; async function testIdempotencyPost( idempotencyTestingDto: IdempotencyTestingDto, key: string ): Promise<{ body: IdempotenceTestingResponse; headers: Record }> { const { body, headers } = await session.testAgent .post(path) .set(HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY, key) .set('authorization', `ApiKey ${session.apiKey}`) .send(idempotencyTestingDto); return { body: body.data, headers }; } async function testIdempotencyGet( key: string ): Promise<{ body: IdempotenceTestingResponse; headers: Record }> { const { body, headers } = await session.testAgent .get(path) .set(HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY, key) .set('authorization', `ApiKey ${session.apiKey}`) .send(); return { body: body.data, headers }; } before(async () => { session = new UserSession(); await session.initialize(); cacheService = session.testServer?.getService(CacheService); process.env.IS_API_IDEMPOTENCY_ENABLED = 'true'; }); it('should return cached same response for duplicate requests', async () => { const key = `IdempotencyKey1`; const res1 = await testIdempotencyPost(IDEMPOTENCE_IMMEDIATE_RESPONSE, key); const res2 = await testIdempotencyPost(IDEMPOTENCE_IMMEDIATE_RESPONSE, key); expect(res1.body.number).to.equal(res2.body.number); expect(res1.headers[idempotancyKey]).to.eq(key); expect(res2.headers[idempotancyKey]).to.eq(key); expect(res2.headers[idempotancyReplayKey]).to.eq('true'); }); it('should return cached and use correct cache key when apiKey is used', async () => { const key = `IdempotencyKey2`; const res1 = await testIdempotencyPost(IDEMPOTENCE_IMMEDIATE_RESPONSE, key); const cacheKey = `test-${session.organization._id}-${key}`; session.testServer?.getHttpServer(); const cacheVal = JSON.stringify(JSON.parse(await cacheService?.get(cacheKey)!).data); expect(res1.body.number, cacheVal).to.eq(JSON.parse(cacheVal).data.number); const res2 = await testIdempotencyPost(IDEMPOTENCE_IMMEDIATE_RESPONSE, key); expect(res1.body.number).to.equal(res2.body.number); expect(res1.headers[idempotancyKey]).to.eq(key); expect(res2.headers[idempotancyKey]).to.eq(key); expect(res2.headers[idempotancyReplayKey]).to.eq('true'); }); it('should return cached and use correct cache key when authToken and apiKey combination is used', async () => { const key = `3`; const res1 = await testIdempotencyPost(IDEMPOTENCE_IMMEDIATE_RESPONSE, key); const cacheKey = `test-${session.organization._id}-${key}`; session.testServer?.getHttpServer(); const cacheVal = JSON.stringify(JSON.parse(await cacheService?.get(cacheKey)!).data); expect(res1.body.number).to.eq(JSON.parse(cacheVal).data.number); const res2 = await testIdempotencyPost(IDEMPOTENCE_IMMEDIATE_RESPONSE, key); expect(res1.body.number).to.equal(res2.body.number); expect(res1.headers[idempotancyKey]).to.eq(key); expect(res2.headers[idempotancyKey]).to.eq(key); expect(res2.headers[idempotancyReplayKey]).to.eq('true'); }); it('should return conflict when concurrent requests are made', async () => { const key = `4`; const [{ headers, body, status }, { headers: headerDupe, body: bodyDupe, status: statusDupe }] = await Promise.all([ session.testAgent .post(path) .set(HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY, key) .send(IDEMPOTENCE_DELAYED_RESPONSE), session.testAgent .post(path) .set(HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY, key) .send(IDEMPOTENCE_DELAYED_RESPONSE), ]); const oneSuccess = status === 201 || statusDupe === 201; const oneConflict = status === 409 || statusDupe === 409; const conflictBody = status === 201 ? bodyDupe : body; const retryHeader = headers[retryAfterHeaderKey] || headerDupe[retryAfterHeaderKey]; expect(oneSuccess).to.be.true; expect(oneConflict).to.be.true; expect(headers[idempotancyKey]).to.eq(key); expect(headerDupe[idempotancyKey], JSON.stringify(headerDupe)).to.eq(key); expect(headerDupe[HttpResponseHeaderKeysEnum.LINK.toLowerCase()], JSON.stringify(headerDupe)).to.eq(DOCS_LINK); expect(retryHeader).to.eq(`1`); expect(conflictBody.message).to.eq( `Request with key "${key}" is currently being processed. Please retry after 1 second` ); expect(conflictBody.error).to.eq('Conflict'); expect(conflictBody.statusCode).to.eq(409); }); it('should return UnprocessableEntity when different body is sent for same key', async () => { const key = '5'; await testIdempotencyPost(IDEMPOTENCE_IMMEDIATE_RESPONSE, key); const { error } = await expectSdkExceptionGeneric(() => testIdempotencyPost(IDEMPOTENCE_IMMEDIATE_EXCEPTION, key)); expect(error?.statusCode).to.eq(422); }); it('should return non cached response for unique requests', async () => { const key = '6'; const key1 = '7'; const response = await testIdempotencyPost(IDEMPOTENCE_IMMEDIATE_RESPONSE, key); const response2 = await testIdempotencyPost(IDEMPOTENCE_IMMEDIATE_RESPONSE, key1); expect(response.body.number).to.not.eq(response2.body.number); expect(response.headers[idempotancyKey]).to.eq(key); expect(response2.headers[idempotancyKey]).to.eq(key1); }); it('should return non cached response for GET requests', async () => { const key = '8'; const response = await testIdempotencyGet(key); const response2 = await testIdempotencyGet(key); expect(response.body.number).to.not.eq(response2.body.number); }); it('should return cached error response for duplicate requests', async () => { const key = '9'; const { error } = await expectSdkExceptionGeneric(() => testIdempotencyPost(IDEMPOTENCE_IMMEDIATE_EXCEPTION, key)); const { error: error2 } = await expectSdkExceptionGeneric(() => testIdempotencyPost(IDEMPOTENCE_IMMEDIATE_EXCEPTION, key) ); expect(error?.message).to.eq(error2?.message); }); it('should return 400 when key bigger than allowed limit', async () => { const key = Array.from({ length: 256 }) .fill(0) .map((i) => i) .join(''); const { error } = await expectSdkExceptionGeneric(() => testIdempotencyPost(IDEMPOTENCE_IMMEDIATE_EXCEPTION, key)); expect(error?.statusCode).to.eq(400); expect(error?.message).to.include(`has exceeded`); }); describe('Allowed Authentication Security Schemes', () => { it('should set Idempotency-Key header when ApiKey security scheme is used to authenticate', async () => { const key = '10'; const { headers } = await testIdempotencyPost(IDEMPOTENCE_IMMEDIATE_RESPONSE, key); expect(headers[idempotancyKey]).to.exist; }); it('should set rate limit headers when a Bearer security scheme is used to authenticate', async () => { const key = '10'; const { headers } = await testIdempotencyPost(IDEMPOTENCE_IMMEDIATE_RESPONSE, key); expect(headers[idempotancyKey]).to.exist; }); it('should NOT set rate limit headers when NO authorization header is present', async () => { const key = '10'; const { headers } = await testIdempotencyPost(IDEMPOTENCE_IMMEDIATE_RESPONSE, key); expect(headers[idempotancyKey]).not.to.exist; }); }); }); ================================================ FILE: apps/api/src/app/shared/framework/idempotency.interceptor.ts ================================================ import { BadRequestException, CallHandler, ConflictException, ExecutionContext, HttpException, Injectable, InternalServerErrorException, NestInterceptor, ServiceUnavailableException, UnprocessableEntityException, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { CacheService, FeatureFlagsService, HttpResponseHeaderKeysEnum, Instrument, PinoLogger, } from '@novu/application-generic'; import { ApiAuthSchemeEnum, FeatureFlagsKeysEnum, UserSessionData } from '@novu/shared'; import { createHash } from 'crypto'; import { Observable, of, throwError } from 'rxjs'; import { catchError, map } from 'rxjs/operators'; import { EXCLUDE_FROM_IDEMPOTENCY } from './exclude-from-idempotency'; const IDEMPOTENCY_CACHE_TTL = 60 * 60 * 24; // 24h const IDEMPOTENCY_PROGRESS_TTL = 60 * 5; // 5min enum ReqStatusEnum { PROGRESS = 'in-progress', SUCCESS = 'success', ERROR = 'error', } export const DOCS_LINK = 'https://docs.novu.co/additional-resources/idempotency'; export const ALLOWED_AUTH_SCHEMES = [ApiAuthSchemeEnum.API_KEY]; const ALLOWED_METHODS = ['post', 'patch']; @Injectable() export class IdempotencyInterceptor implements NestInterceptor { constructor( private readonly reflector: Reflector, private readonly cacheService: CacheService, private featureFlagService: FeatureFlagsService, private logger: PinoLogger ) { this.logger.setContext(this.constructor.name); } protected async isEnabled(context: ExecutionContext): Promise { const isExcluded = this.reflector.getAllAndOverride(EXCLUDE_FROM_IDEMPOTENCY, [ context.getHandler(), context.getClass(), ]); if (isExcluded) { return false; } const isAllowedAuthScheme = this.isAllowedAuthScheme(context); if (!isAllowedAuthScheme) { return true; } const user = this.getReqUser(context); const { organizationId, environmentId, _id } = user; return await this.featureFlagService.getFlag({ key: FeatureFlagsKeysEnum.IS_API_IDEMPOTENCY_ENABLED, defaultValue: false, environment: { _id: environmentId }, organization: { _id: organizationId }, user: { _id }, }); } @Instrument() async intercept(context: ExecutionContext, next: CallHandler): Promise> { const request = context.switchToHttp().getRequest(); const isAllowedMethod = ALLOWED_METHODS.includes(request.method.toLowerCase()); const idempotencyKey = this.getIdempotencyKey(context); const isEnabled = await this.isEnabled(context); if (!idempotencyKey || !isAllowedMethod || !isEnabled) { return next.handle(); } if (idempotencyKey?.length > 255) { return throwError( () => new BadRequestException( `idempotencyKey "${idempotencyKey}" has exceeded the maximum allowed length of 255 characters` ) ); } const cacheKey = this.getCacheKey(context); try { const bodyHash = this.hashRequestBody(request.body); // if 1st time we are seeing the request, marks the request as in-progress if not, does nothing const isNewReq = await this.setCache( cacheKey, { status: ReqStatusEnum.PROGRESS, bodyHash }, IDEMPOTENCY_PROGRESS_TTL, true ); // Check if the idempotency key is in the cache if (isNewReq) { return await this.handleNewRequest(context, next, bodyHash); } else { return await this.handlerDuplicateRequest(context, bodyHash); } } catch (err) { this.logger.warn( `An error occurred while making idempotency check, key:${idempotencyKey}. error: ${err.message}` ); if (err instanceof HttpException) { return throwError(() => err); } } // something unexpected happened, both cached response and handler did not execute as expected return throwError(() => new ServiceUnavailableException()); } private getIdempotencyKey(context: ExecutionContext): string | undefined { const request = context.switchToHttp().getRequest(); return request.headers[HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY.toLocaleLowerCase()]; } private getReqUser(context: ExecutionContext): UserSessionData { const req = context.switchToHttp().getRequest(); return req.user; } private isAllowedAuthScheme(context: ExecutionContext): boolean { const req = context.switchToHttp().getRequest(); const { authScheme } = req; return ALLOWED_AUTH_SCHEMES.some((scheme) => authScheme === scheme); } private getCacheKey(context: ExecutionContext): string { const user = this.getReqUser(context); if (user === undefined) { const message = 'Cannot build idempotency cache key without user'; this.logger.error(message); throw new InternalServerErrorException(message); } const env = process.env.NODE_ENV; return `${env}-${user.organizationId}-${this.getIdempotencyKey(context)}`; } async setCache( key: string, val: { status: ReqStatusEnum; bodyHash: string; data?: any; statusCode?: number }, ttl: number, ifNotExists?: boolean ): Promise { try { if (ifNotExists) { return await this.cacheService.setIfNotExist(key, JSON.stringify(val), { ttl }); } await this.cacheService.set(key, JSON.stringify(val), { ttl }); } catch (err) { this.logger.warn(`An error occurred while setting idempotency cache, key:${key} error: ${err.message}`); } return null; } private setHeaders(response: any, headers: Record) { Object.keys(headers).forEach((key) => { if (headers[key]) { response.set(key, headers[key]); } }); } private hashRequestBody(body: object): string { const hash = createHash('blake2s256'); try { hash.update(Buffer.from(JSON.stringify(body))); } catch (error) { // For multipart/form-data or other non-serializable bodies, // create a hash from the object's string representation hash.update(Buffer.from(String(body))); } return hash.digest('hex'); } private async handlerDuplicateRequest(context: ExecutionContext, bodyHash: string): Promise> { const cacheKey = this.getCacheKey(context); const idempotencyKey = this.getIdempotencyKey(context)!; const data = await this.cacheService.get(cacheKey); this.setHeaders(context.switchToHttp().getResponse(), { [HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY]: idempotencyKey, }); const parsed = JSON.parse(data); if (parsed.status === ReqStatusEnum.PROGRESS) { // api call is in progress, so client need to handle this case this.logger.trace(`previous api call in progress rejecting the request. key: "${idempotencyKey}"`); this.setHeaders(context.switchToHttp().getResponse(), { [HttpResponseHeaderKeysEnum.RETRY_AFTER]: `1`, [HttpResponseHeaderKeysEnum.LINK]: DOCS_LINK, }); throw new ConflictException( `Request with key "${idempotencyKey}" is currently being processed. Please retry after 1 second` ); } if (bodyHash !== parsed.bodyHash) { // different body sent than before this.logger.trace(`idempotency key is being reused for different bodies. key: "${idempotencyKey}"`); this.setHeaders(context.switchToHttp().getResponse(), { [HttpResponseHeaderKeysEnum.LINK]: DOCS_LINK, }); throw new UnprocessableEntityException( `Request with key "${idempotencyKey}" is being reused for a different body` ); } this.setHeaders(context.switchToHttp().getResponse(), { [HttpResponseHeaderKeysEnum.IDEMPOTENCY_REPLAY]: 'true' }); // already seen the request return cached response if (parsed.status === ReqStatusEnum.ERROR) { this.logger.trace(`returning cached error response. key: "${idempotencyKey}"`); throw parsed.data; } return of(parsed.data); } private async handleNewRequest( context: ExecutionContext, next: CallHandler, bodyHash: string ): Promise> { const cacheKey = this.getCacheKey(context); const idempotencyKey = this.getIdempotencyKey(context)!; return next.handle().pipe( map(async (response) => { const httpResponse = context.switchToHttp().getResponse(); const { statusCode } = httpResponse; // Cache the success response and return it await this.setCache( cacheKey, { status: ReqStatusEnum.SUCCESS, bodyHash, statusCode, data: response }, IDEMPOTENCY_CACHE_TTL ); this.logger.trace(`cached the success response for idempotency key: "${idempotencyKey}"`); this.setHeaders(httpResponse, { [HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY]: idempotencyKey }); return response; }), catchError((err) => { this.setCache( cacheKey, { status: ReqStatusEnum.ERROR, bodyHash, data: err, }, IDEMPOTENCY_CACHE_TTL ).catch(() => {}); this.logger.trace(`cached the error response for idempotency key: "${idempotencyKey}"`); this.setHeaders(context.switchToHttp().getResponse(), { [HttpResponseHeaderKeysEnum.IDEMPOTENCY_KEY]: idempotencyKey, }); throw err; }) ); } } ================================================ FILE: apps/api/src/app/shared/framework/paginated-ok-response.decorator.ts ================================================ import { applyDecorators, Type } from '@nestjs/common'; import { ApiExtraModels, getSchemaPath } from '@nestjs/swagger'; import { PaginatedResponseDto } from '../dtos/pagination-response'; import { ApiOkResponse } from './response.decorator'; export const ApiOkPaginatedResponse = >(dataDto: DataDto) => applyDecorators( ApiExtraModels(PaginatedResponseDto, dataDto), ApiOkResponse({ schema: { allOf: [ { $ref: getSchemaPath(PaginatedResponseDto) }, { properties: { data: { type: 'array', items: { $ref: getSchemaPath(dataDto) }, }, }, }, ], }, }) ); ================================================ FILE: apps/api/src/app/shared/framework/response.decorator.ts ================================================ import { applyDecorators, Type } from '@nestjs/common'; import { ApiExpectationFailedResponse, ApiExtraModels, ApiHttpVersionNotSupportedResponse, ApiLengthRequiredResponse, ApiNonAuthoritativeInformationResponse, ApiNotModifiedResponse, ApiPartialContentResponse, ApiPaymentRequiredResponse, ApiPermanentRedirectResponse, ApiProxyAuthenticationRequiredResponse, ApiRequestedRangeNotSatisfiableResponse, ApiResetContentResponse, ApiResponseOptions, ApiSeeOtherResponse, ApiUriTooLongResponse, getSchemaPath, } from '@nestjs/swagger'; import { ErrorDto, ValidationErrorDto } from '../../../error-dto'; import { DataWrapperDto } from '../dtos/data-wrapper-dto'; import { COMMON_RESPONSES } from './constants/responses.schema'; import { customResponseDecorators } from './swagger/responses.decorator'; export const { ApiOkResponse }: { ApiOkResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators; export const { ApiCreatedResponse }: { ApiCreatedResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators; export const { ApiAcceptedResponse }: { ApiAcceptedResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators; export const { ApiNoContentResponse }: { ApiNoContentResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators; export const { ApiMovedPermanentlyResponse, }: { ApiMovedPermanentlyResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators; export const { ApiTemporaryRedirectResponse, }: { ApiTemporaryRedirectResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators; export const { ApiFoundResponse }: { ApiFoundResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators; export const { ApiBadRequestResponse }: { ApiBadRequestResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators; export const { ApiUnauthorizedResponse, }: { ApiUnauthorizedResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators; export const { ApiTooManyRequestsResponse, }: { ApiTooManyRequestsResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators; export const { ApiNotFoundResponse }: { ApiNotFoundResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators; export const { ApiInternalServerErrorResponse, }: { ApiInternalServerErrorResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators; export const { ApiBadGatewayResponse }: { ApiBadGatewayResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators; export const { ApiConflictResponse }: { ApiConflictResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators; export const { ApiForbiddenResponse }: { ApiForbiddenResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators; export const { ApiGatewayTimeoutResponse, }: { ApiGatewayTimeoutResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators; export const { ApiGoneResponse }: { ApiGoneResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators; export const { ApiMethodNotAllowedResponse, }: { ApiMethodNotAllowedResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators; export const { ApiNotAcceptableResponse, }: { ApiNotAcceptableResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators; export const { ApiNotImplementedResponse, }: { ApiNotImplementedResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators; export const { ApiPreconditionFailedResponse, }: { ApiPreconditionFailedResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators; export const { ApiPayloadTooLargeResponse, }: { ApiPayloadTooLargeResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators; export const { ApiRequestTimeoutResponse, }: { ApiRequestTimeoutResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators; export const { ApiServiceUnavailableResponse, }: { ApiServiceUnavailableResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators; export const { ApiUnprocessableEntityResponse, }: { ApiUnprocessableEntityResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators; export const { ApiUnsupportedMediaTypeResponse, }: { ApiUnsupportedMediaTypeResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators; export const { ApiDefaultResponse }: { ApiDefaultResponse: (options?: ApiResponseOptions) => MethodDecorator } = customResponseDecorators; function buildEnvelopeProperties>(isResponseArray: boolean, dataDto: DataDto) { if (isResponseArray) { return { data: { type: 'array', items: { $ref: getSchemaPath(dataDto) }, }, }; } else { return { data: { $ref: getSchemaPath(dataDto) } }; } } function buildSchema>( shouldEnvelope: boolean, isResponseArray: boolean, dataDto: DataDto ) { if (shouldEnvelope) { return { properties: buildEnvelopeProperties(isResponseArray, dataDto), }; } return { $ref: getSchemaPath(dataDto) }; } export const ApiResponse = >( dataDto: DataDto, statusCode: number = 200, isResponseArray = false, shouldEnvelope = true, options?: ApiResponseOptions ) => { let responseDecoratorFunction; let description = 'Ok'; // Default description switch (statusCode) { // 2XX Success case 200: responseDecoratorFunction = ApiOkResponse; description = 'OK'; break; case 201: responseDecoratorFunction = ApiCreatedResponse; description = 'Created'; break; case 202: responseDecoratorFunction = ApiAcceptedResponse; description = 'Accepted'; break; case 203: responseDecoratorFunction = ApiNonAuthoritativeInformationResponse; description = 'Non-Authoritative Information'; break; case 204: responseDecoratorFunction = ApiNoContentResponse; description = 'No Content'; break; case 205: responseDecoratorFunction = ApiResetContentResponse; description = 'Reset Content'; break; case 206: responseDecoratorFunction = ApiPartialContentResponse; description = 'Partial Content'; break; // 3XX Redirection case 301: responseDecoratorFunction = ApiMovedPermanentlyResponse; description = 'Moved Permanently'; break; case 302: responseDecoratorFunction = ApiFoundResponse; description = 'Found'; break; case 303: responseDecoratorFunction = ApiSeeOtherResponse; description = 'See Other'; break; case 304: responseDecoratorFunction = ApiNotModifiedResponse; description = 'Not Modified'; break; case 305: responseDecoratorFunction = ApiProxyAuthenticationRequiredResponse; description = 'Use Proxy'; break; case 307: responseDecoratorFunction = ApiTemporaryRedirectResponse; description = 'Temporary Redirect'; break; case 308: responseDecoratorFunction = ApiPermanentRedirectResponse; description = 'Permanent Redirect'; break; // 4XX Client Errors case 400: responseDecoratorFunction = ApiBadRequestResponse; description = 'Bad Request'; break; case 401: responseDecoratorFunction = ApiUnauthorizedResponse; description = 'Unauthorized'; break; case 402: responseDecoratorFunction = ApiPaymentRequiredResponse; description = 'Payment Required'; break; case 403: responseDecoratorFunction = ApiForbiddenResponse; description = 'Forbidden'; break; case 404: responseDecoratorFunction = ApiNotFoundResponse; description = 'Not Found'; break; case 405: responseDecoratorFunction = ApiMethodNotAllowedResponse; description = 'Method Not Allowed'; break; case 406: responseDecoratorFunction = ApiNotAcceptableResponse; description = 'Not Acceptable'; break; case 407: responseDecoratorFunction = ApiProxyAuthenticationRequiredResponse; description = 'Proxy Authentication Required'; break; case 408: responseDecoratorFunction = ApiRequestTimeoutResponse; description = 'Request Timeout'; break; case 409: responseDecoratorFunction = ApiConflictResponse; description = 'Conflict'; break; case 410: responseDecoratorFunction = ApiGoneResponse; description = 'Gone'; break; case 411: responseDecoratorFunction = ApiLengthRequiredResponse; description = 'Length Required'; break; case 412: responseDecoratorFunction = ApiPreconditionFailedResponse; description = 'Precondition Failed'; break; case 413: responseDecoratorFunction = ApiPayloadTooLargeResponse; description = 'Payload Too Large'; break; case 414: responseDecoratorFunction = ApiUriTooLongResponse; description = 'URI Too Long'; break; case 415: responseDecoratorFunction = ApiUnsupportedMediaTypeResponse; description = 'Unsupported Media Type'; break; case 416: responseDecoratorFunction = ApiRequestedRangeNotSatisfiableResponse; description = 'Range Not Satisfiable'; break; case 417: responseDecoratorFunction = ApiExpectationFailedResponse; description = 'Expectation Failed'; break; case 422: responseDecoratorFunction = ApiUnprocessableEntityResponse; description = 'Unprocessable Entity'; break; // 5XX Server Errors case 500: responseDecoratorFunction = ApiInternalServerErrorResponse; description = 'Internal Server Error'; break; case 501: responseDecoratorFunction = ApiNotImplementedResponse; description = 'Not Implemented'; break; case 502: responseDecoratorFunction = ApiBadGatewayResponse; description = 'Bad Gateway'; break; case 503: responseDecoratorFunction = ApiServiceUnavailableResponse; description = 'Service Unavailable'; break; case 504: responseDecoratorFunction = ApiGatewayTimeoutResponse; description = 'Gateway Timeout'; break; case 505: responseDecoratorFunction = ApiHttpVersionNotSupportedResponse; description = 'HTTP Version Not Supported'; break; // Default case default: responseDecoratorFunction = ApiOkResponse; // Fallback to a default response description = 'OK'; // Default description break; } return applyDecorators( ApiExtraModels(DataWrapperDto, dataDto), responseDecoratorFunction({ description, schema: buildSchema(shouldEnvelope, isResponseArray, dataDto), ...options, }) ); }; export const ApiCommonResponses = () => { const decorators: any = []; for (const [decoratorName, responseOptions] of Object.entries(COMMON_RESPONSES)) { const decorator = customResponseDecorators[decoratorName](responseOptions); decorators.push(decorator); } return applyDecorators( ...decorators, ApiResponse(ErrorDto, 400, false, false), ApiResponse(ErrorDto, 401, false, false), ApiResponse(ErrorDto, 403, false, false), ApiResponse(ErrorDto, 404, false, false), ApiResponse(ErrorDto, 405, false, false), ApiResponse(ErrorDto, 409, false, false), ApiResponse(ErrorDto, 413, false, false), ApiResponse(ErrorDto, 414, false, false), ApiResponse(ErrorDto, 415, false, false), ApiResponse(ErrorDto, 500, false, false), ApiResponse(ValidationErrorDto, 422, false, false) ); }; ================================================ FILE: apps/api/src/app/shared/framework/response.interceptor.ts ================================================ import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; import { instanceToPlain } from 'class-transformer'; import { isArray, isObject } from 'lodash'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; export interface Response { data: T; } @Injectable() export class ResponseInterceptor implements NestInterceptor> { intercept(context, next: CallHandler): Observable> { if (context.getType() === 'graphql') return next.handle(); return next.handle().pipe( map((data) => { if (this.returnWholeObject(data)) { return { ...data, data: isObject(data.data) ? this.transformResponse(data.data) : data.data, }; } return { data: isObject(data) ? this.transformResponse(data) : data, }; }) ); } /** * This method is used to determine if the entire object should be returned or just the data property * for paginated results that already contain the data wrapper, true. * for single entity result that *could* contain data object, false. * @param data * @private */ private returnWholeObject(data) { const isPaginatedResult = data?.data; const isEntityObject = data?._id || data?.id; return isPaginatedResult && !isEntityObject; } private transformResponse(response) { if (isArray(response)) { return response.map((item) => this.transformToPlain(item)); } return this.transformToPlain(response); } private transformToPlain(plainOrClass) { return plainOrClass && plainOrClass.constructor !== Object ? instanceToPlain(plainOrClass) : plainOrClass; } } ================================================ FILE: apps/api/src/app/shared/framework/swagger/headers.decorator.ts ================================================ import { OpenAPIObject } from '@nestjs/swagger'; import { HeaderObjects, HttpResponseHeaderKeysEnum } from '@novu/application-generic'; import { RESPONSE_HEADER_CONFIG } from '../constants/headers.schema'; export const injectReusableHeaders = (document: OpenAPIObject): OpenAPIObject => { const newDocument = { ...document }; newDocument.components = { ...document.components, headers: Object.entries(RESPONSE_HEADER_CONFIG).reduce((acc, [name, header]) => { return { ...acc, [name]: header, }; }, {} as HeaderObjects), }; return newDocument; }; export const createReusableHeaders = (headers: Array) => { return headers.reduce((acc, header) => { return { ...acc, [header]: { $ref: `#/components/headers/${header}`, }, }; }, {} as HeaderObjects); }; ================================================ FILE: apps/api/src/app/shared/framework/swagger/index.ts ================================================ export * from './headers.decorator'; export * from './injection'; export * from './responses.decorator'; ================================================ FILE: apps/api/src/app/shared/framework/swagger/injection.ts ================================================ import { OpenAPIObject } from '@nestjs/swagger'; import { injectReusableHeaders } from './headers.decorator'; export const injectDocumentComponents = (document: OpenAPIObject): OpenAPIObject => { const injectedResponseHeadersDocument = injectReusableHeaders(document); return injectedResponseHeadersDocument; }; ================================================ FILE: apps/api/src/app/shared/framework/swagger/keyless.security.ts ================================================ import { applyDecorators, SetMetadata } from '@nestjs/common'; export const KEYLESS_ACCESSIBLE = 'keyless_accessible'; export function KeylessAccessible() { return applyDecorators(SetMetadata(KEYLESS_ACCESSIBLE, true)); } ================================================ FILE: apps/api/src/app/shared/framework/swagger/open.api.manipulation.component.ts ================================================ import { OpenAPIObject } from '@nestjs/swagger'; import { OperationObject, PathItemObject, PathsObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.interface'; import { API_KEY_SWAGGER_SECURITY_NAME } from '@novu/application-generic'; import Nimma from 'nimma'; const jpath = '$.paths..responses["200","201"].content["application/json"]'; /** * @param {import("nimma").EmittedScope} scope */ function liftDataProperty(scope) { if ( typeof scope.value !== 'object' || !scope.value || !('schema' in scope.value) || typeof scope.value.schema !== 'object' || !scope.value.schema ) { return; } const { schema } = scope.value; const data = 'properties' in schema && typeof schema.properties === 'object' && schema.properties && 'data' in schema.properties && typeof schema.properties.data === 'object' ? schema.properties.data : null; if (!data) { return; } scope.value.schema = data; } export function removeEndpointsWithoutApiKey(openApiDocument: T): T { const parsedDocument = JSON.parse(JSON.stringify(openApiDocument)); if (!parsedDocument.paths) { throw new Error('Invalid OpenAPI document'); } for (const path in parsedDocument.paths) { const operations = parsedDocument.paths[path]; for (const method in operations) { const operation = operations[method]; if (operation.security) { const hasApiKey = operation.security.some((sec: { [key: string]: string[] }) => Object.keys(sec).includes(API_KEY_SWAGGER_SECURITY_NAME) ); operation.security = operation.security.filter((sec: { [key: string]: string[] }) => Object.keys(sec).includes(API_KEY_SWAGGER_SECURITY_NAME) ); if (!hasApiKey) { delete operations[method]; } } } if (Object.keys(operations).length === 0) { delete parsedDocument.paths[path]; } } return parsedDocument; } function unwrapDataAttribute(inputDocument: OpenAPIObject) { Nimma.query(inputDocument, { [jpath]: liftDataProperty, }); } function filterBearerOnlyIfExternal(isForInternalSdk: boolean, inputDocument: OpenAPIObject) { let openAPIObject: OpenAPIObject; if (isForInternalSdk) { return inputDocument; } else { return removeEndpointsWithoutApiKey(inputDocument) as OpenAPIObject; } } export function overloadDocumentForSdkGeneration(inputDocument: OpenAPIObject, isForInternalSdk: boolean = false) { unwrapDataAttribute(inputDocument); const openAPIObject = filterBearerOnlyIfExternal(isForInternalSdk, inputDocument); return addIdempotencyKeyHeader(openAPIObject) as OpenAPIObject; } export function addIdempotencyKeyHeader(openApiDocument: T): T { const parsedDocument = JSON.parse(JSON.stringify(openApiDocument)); if (!parsedDocument.paths) { throw new Error('Invalid OpenAPI document'); } const idempotencyKeyHeader = { name: 'idempotency-key', in: 'header', description: 'A header for idempotency purposes', required: false, schema: { type: 'string', }, }; const paths = Object.keys(parsedDocument.paths); for (const path of paths) { const operations = parsedDocument.paths[path]; const methods = Object.keys(operations); for (const method of methods) { const operation = operations[method]; if (!operation.parameters) { operation.parameters = []; } const hasIdempotencyKey = operation.parameters.some( (param) => param.name === 'Idempotency-Key' && param.in === 'header' ); if (!hasIdempotencyKey) { operation.parameters.push(idempotencyKeyHeader); } } } return parsedDocument; } export function sortOpenAPIDocument(openApiDoc: OpenAPIObject): OpenAPIObject { // Create a deep copy of the original document const sortedDoc: OpenAPIObject = JSON.parse(JSON.stringify(openApiDoc)); // Remove empty tag references if (sortedDoc.tags) { sortedDoc.tags = sortedDoc.tags.filter((tag) => tag.name && tag.name.trim() !== ''); } // Sort paths if (sortedDoc.paths) { const sortedPaths: PathsObject = {}; // Sort path keys based on version (v2 before v1) and then alphabetically const sortedPathKeys = Object.keys(sortedDoc.paths).sort((a, b) => { // Extract version from path const getVersion = (path: string) => { const versionMatch = path.match(/\/v(\d+)/); return versionMatch ? parseInt(versionMatch[1], 10) : 0; }; const versionA = getVersion(a); const versionB = getVersion(b); // Sort by version (newer first) if (versionA !== versionB) { return versionB - versionA; } // If versions are the same, sort alphabetically return a.localeCompare(b); }); // Reconstruct paths with sorted keys and sorted methods within each path sortedPathKeys.forEach((pathKey) => { const pathItem = sortedDoc.paths[pathKey]; // Define method order priority const methodPriority = ['post', 'put', 'patch', 'get', 'delete', 'options', 'head', 'trace']; // Sort methods within the path item sortedPaths[pathKey] = { ...pathItem, ...Object.fromEntries( methodPriority .map((method) => { const operation = pathItem[method as keyof PathItemObject]; return operation ? [method, operation] : null; }) .filter((entry): entry is [string, OperationObject] => entry !== null) .sort((a, b) => { const opIdA = a[1].operationId || ''; const opIdB = b[1].operationId || ''; return opIdA.localeCompare(opIdB); }) ), }; }); sortedDoc.paths = sortedPaths; } return sortedDoc; } ================================================ FILE: apps/api/src/app/shared/framework/swagger/responses.decorator.ts ================================================ import { applyDecorators } from '@nestjs/common'; import * as nestSwagger from '@nestjs/swagger'; import { ApiResponseOptions } from '@nestjs/swagger'; import type { ApiResponseDecoratorName } from '@novu/application-generic'; import { COMMON_RESPONSE_HEADERS, COMMON_RESPONSES } from '../constants'; import { createReusableHeaders } from './headers.decorator'; const createCustomResponseDecorator = (decoratorName: ApiResponseDecoratorName) => { return (options?: ApiResponseOptions) => { return applyDecorators( nestSwagger[decoratorName]({ ...COMMON_RESPONSES[decoratorName], ...options, headers: { ...createReusableHeaders(COMMON_RESPONSE_HEADERS), ...options?.headers, }, }) ); }; }; const nestSwaggerResponseExports = Object.keys(nestSwagger).filter( (key) => key.match(/^Api([a-zA-Z]+)Response$/) !== null ) as Array; export const customResponseDecorators = nestSwaggerResponseExports.reduce( (acc, decoratorName) => { return { ...acc, [decoratorName]: createCustomResponseDecorator(decoratorName), }; }, {} as Record ReturnType> ); ================================================ FILE: apps/api/src/app/shared/framework/swagger/sdk.decorators.ts ================================================ import { applyDecorators } from '@nestjs/common'; import { ApiExtension, ApiParam, ApiProperty } from '@nestjs/swagger'; import { ApiParamOptions } from '@nestjs/swagger/dist/decorators/api-param.decorator'; import { ApiPropertyOptions } from '@nestjs/swagger/dist/decorators/api-property.decorator'; /** * Sets the method name for the SDK. * @param {string} methodName - The name of the method. * @returns {Decorator} The decorator to be used on the method. */ export function SdkMethodName(methodName: string) { return applyDecorators(ApiExtension('x-speakeasy-name-override', methodName)); } /** * Sets the group name for the SDK. * @param {string} methodName - The name of the group. * @returns {Decorator} The decorator to be used on the method. */ export function SdkGroupName(methodName: string) { return applyDecorators(ApiExtension('x-speakeasy-group', methodName)); } /** * A decorator function that marks a path or operation to be ignored in OpenAPI documentation. * * This function applies the `x-ignore` extension to the OpenAPI specification, * indicating that the decorated path or operation should not be included in the generated documentation. * * @returns {Function} A decorator function that applies the `x-ignore` extension. */ export function DocumentationIgnore() { return applyDecorators(ApiExtension('x-ignore', true)); } /** * Ignores the path for the SDK. * @param {string} methodName - The name of the method. * @returns {Decorator} The decorator to be used on the method. */ export function SdkIgnorePath(methodName: string) { return applyDecorators(ApiExtension('x-speakeasy-ignore', 'true')); } /** * Sets the usage example for the SDK. * @param {string} title - The title of the example. * @param {string} description - The description of the example. * @param {number} position - The position of the example. * @returns {Decorator} The decorator to be used on the method. */ export function SdkUsageExample(title?: string, description?: string, position?: number) { return applyDecorators(ApiExtension('x-speakeasy-usage-example', { title, description, position })); } /** * Sets the maximum number of parameters for the SDK method. * @param {number} maxParamsBeforeCollapseToObject - The maximum number of parameters before they are collapsed into an object. * @returns {Decorator} The decorator to be used on the method. */ export function SdkMethodMaxParamsOverride(maxParamsBeforeCollapseToObject?: number) { return applyDecorators(ApiExtension('x-speakeasy-max-method-params', maxParamsBeforeCollapseToObject)); } class SDKOverrideOptions { nameOverride?: string; } export function SdkApiParam(options: ApiParamOptions, sdkOverrideOptions?: SDKOverrideOptions) { let finalOptions: ApiParamOptions; if (sdkOverrideOptions) { finalOptions = sdkOverrideOptions.nameOverride ? ({ ...options, 'x-speakeasy-name-override': sdkOverrideOptions.nameOverride } as unknown as ApiParamOptions) : options; } else { finalOptions = options; } return applyDecorators(ApiParam(finalOptions)); } export function SdkApiProperty(options: ApiPropertyOptions, sdkOverrideOptions?: SDKOverrideOptions) { let finalOptions: ApiPropertyOptions; if (sdkOverrideOptions) { finalOptions = sdkOverrideOptions.nameOverride ? ({ ...options, 'x-speakeasy-name-override': sdkOverrideOptions.nameOverride } as unknown as ApiPropertyOptions) : options; } else { finalOptions = options; } return applyDecorators(ApiProperty(finalOptions)); } /** * Sets the pagination for the SDK. * @param {string} override - The override for the limit parameter. * @returns {Decorator} The decorator to be used on the method. */ export function SdkUsePagination(override?: string) { return applyDecorators( ApiExtension('x-speakeasy-pagination', { type: 'offsetLimit', inputs: [ { name: 'page', in: 'parameters', type: 'page', }, { name: override || 'limit', in: 'parameters', type: 'limit', }, ], outputs: { results: '$.data.resultArray', }, }) ); } ================================================ FILE: apps/api/src/app/shared/framework/swagger/swagger.controller.ts ================================================ import { INestApplication } from '@nestjs/common'; import { DocumentBuilder, OpenAPIObject, SwaggerModule } from '@nestjs/swagger'; import { SecuritySchemeObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.interface'; import { API_KEY_SWAGGER_SECURITY_NAME, BEARER_SWAGGER_SECURITY_NAME } from '@novu/application-generic'; import packageJson from '../../../../../package.json'; import metadata from '../../../../metadata'; import { webhookEvents } from '../../../outbound-webhooks/webhooks.const'; import { injectDocumentComponents } from './injection'; import { overloadDocumentForSdkGeneration, removeEndpointsWithoutApiKey, sortOpenAPIDocument, } from './open.api.manipulation.component'; export const API_KEY_SECURITY_DEFINITIONS: SecuritySchemeObject = { type: 'apiKey', name: 'Authorization', in: 'header', description: 'API key authentication. Allowed headers-- "Authorization: ApiKey ".', 'x-speakeasy-example': 'YOUR_SECRET_KEY_HERE', } as unknown as SecuritySchemeObject; export const BEARER_SECURITY_DEFINITIONS: SecuritySchemeObject = { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', }; function buildBaseOptions() { const options = new DocumentBuilder() .setTitle('Novu API') .setDescription('Novu REST API. Please see https://docs.novu.co/api-reference for more details.') .setVersion(packageJson.version) .setContact('Novu Support', 'https://discord.gg/novu', 'support@novu.co') .setExternalDoc('Novu Documentation', 'https://docs.novu.co') .setTermsOfService('https://novu.co/terms') .setLicense('MIT', 'https://opensource.org/license/mit') .addServer('https://api.novu.co') .addServer('https://eu.api.novu.co') .addSecurity(API_KEY_SWAGGER_SECURITY_NAME, API_KEY_SECURITY_DEFINITIONS) .addSecurityRequirements(API_KEY_SWAGGER_SECURITY_NAME) .addTag( 'Events', `Events represent a change in state of a subscriber. They are used to trigger workflows, and enable you to send notifications to subscribers based on their actions.`, { url: 'https://docs.novu.co/workflows' } ) .addTag( 'Subscribers', `A subscriber in Novu represents someone who should receive a message. A subscriber's profile information contains important attributes about the subscriber that will be used in messages (name, email). The subscriber object can contain other key-value pairs that can be used to further personalize your messages.`, { url: 'https://docs.novu.co/subscribers/subscribers' } ) .addTag( 'Topics', `Topics are a way to group subscribers together so that they can be notified of events at once. A topic is identified by a custom key. This can be helpful for things like sending out marketing emails or notifying users of new features. Topics can also be used to send notifications to the subscribers who have been grouped together based on their interests, location, activities and much more.`, { url: 'https://docs.novu.co/subscribers/topics' } ) .addTag( 'Integrations', `With the help of the Integration Store, you can easily integrate your favorite delivery provider. During the runtime of the API, the Integrations Store is responsible for storing the configurations of all the providers.`, { url: 'https://docs.novu.co/platform/integrations/overview' } ) .addTag( 'Workflows', `All notifications are sent via a workflow. Each workflow acts as a container for the logic and blueprint that are associated with a type of notification in your system.`, { url: 'https://docs.novu.co/workflows' } ) .addTag( 'Messages', `A message in Novu represents a notification delivered to a recipient on a particular channel. Messages contain information about the request that triggered its delivery, a view of the data sent to the recipient, and a timeline of its lifecycle events. Learn more about messages.`, { url: 'https://docs.novu.co/workflows/messages' } ) .addTag( 'Environments', `Environments allow you to manage different stages of your application development lifecycle. Each environment has its own set of API keys and configurations, enabling you to separate development, staging, and production workflows.`, { url: 'https://docs.novu.co/platform/environments' } ) .addTag('Layouts', `Layouts are reusable wrappers for your email notifications.`, { url: 'https://docs.novu.co/platform/workflow/layouts', }) .addTag('Translations', `Used to localize your notifications to different languages.`, { url: 'https://docs.novu.co/platform/workflow/advanced-features/translations', }); return options; } function buildOpenApiBaseDocument(internalSdkGeneration: boolean | undefined) { const options = buildBaseOptions(); if (internalSdkGeneration) { options.addSecurity(BEARER_SWAGGER_SECURITY_NAME, BEARER_SECURITY_DEFINITIONS); options.addSecurityRequirements(BEARER_SWAGGER_SECURITY_NAME); } return options.build(); } function buildFullDocumentWithPath(app: INestApplication, baseDocument: Omit) { // Define extraModels to ensure webhook payload DTOs are included in the schema definitions // Add other relevant payload DTOs here if more webhooks are defined const allWebhookPayloadDtos = [...new Set(webhookEvents.map((event) => event.payloadDto))]; const document = injectDocumentComponents( SwaggerModule.createDocument(app, baseDocument, { operationIdFactory: (controllerKey: string, methodKey: string) => `${controllerKey}_${methodKey}`, deepScanRoutes: true, ignoreGlobalPrefix: false, include: [], extraModels: [...allWebhookPayloadDtos], // Make sure payload DTOs are processed }) ); return document; } function publishDeprecatedDocument(app: INestApplication, document: OpenAPIObject) { SwaggerModule.setup('api', app, { ...document, info: { ...document.info, title: `DEPRECATED: ${document.info.title}. Use /openapi.{json,yaml} instead.`, }, }); } function publishLegacyOpenApiDoc(app: INestApplication, document: OpenAPIObject) { SwaggerModule.setup('openapi', app, removeEndpointsWithoutApiKey(document), { jsonDocumentUrl: 'openapi.json', yamlDocumentUrl: 'openapi.yaml', explorer: process.env.NODE_ENV !== 'production', }); } /** * Generates the `x-webhooks` section for the OpenAPI document based on defined events and DTOs. * Follows the OpenAPI specification for webhooks: https://spec.openapis.org/oas/v3.1.0#fixed-fields-1:~:text=Webhooks%20Object */ function generateWebhookDefinitions(document: OpenAPIObject) { const webhooksDefinition: Record = {}; // Structure matches Path Item Object webhookEvents.forEach((webhook) => { // Assume the schema name matches the DTO class name (generated by Swagger) const payloadSchemaRef = `#/components/schemas/${(webhook.payloadDto as Function).name}`; const wrapperSchemaName = `${(webhook.payloadDto as Function).name}WebhookPayloadWrapper`; // Unique name for the wrapper schema // Define the wrapper schema in components/schemas if it doesn't exist if (document.components && !document.components.schemas?.[wrapperSchemaName]) { if (!document.components.schemas) { document.components.schemas = {}; } document.components.schemas[wrapperSchemaName] = { type: 'object', properties: { id: { type: 'string', description: 'Unique identifier of the webhook event (evt_✱).', }, type: { type: 'string', enum: [webhook.event], description: 'The type of the webhook event.' }, data: { description: 'The actual event data payload.', allOf: [{ $ref: payloadSchemaRef }], // Use allOf to correctly reference the payload schema }, timestamp: { type: 'string', format: 'date-time', description: 'ISO timestamp of when the event occurred.' }, environmentId: { type: 'string', description: 'The ID of the environment associated with the event.' }, object: { type: 'string', enum: [webhook.objectType], description: 'The type of object the event relates to.', }, }, required: ['type', 'data', 'timestamp', 'environmentId', 'object'], }; } webhooksDefinition[webhook.event] = { // This structure represents a Path Item Object, describing the webhook POST request. post: { summary: `Event: ${webhook.event}`, description: `This webhook is triggered when a \`${webhook.objectType}\` event (\`${ webhook.event }\`) occurs. The payload contains the details of the event. Configure your webhook endpoint URL in the Novu dashboard.`, requestBody: { description: `Webhook payload for the \`${webhook.event}\` event.`, required: true, content: { 'application/json': { schema: { $ref: `#/components/schemas/${wrapperSchemaName}` }, // Reference the wrapper schema }, }, }, responses: { '200': { description: 'Acknowledges successful receipt of the webhook. No response body is expected.', }, // Consider adding other responses (e.g., 4xx for signature validation failure, 5xx for processing errors) }, tags: ['Webhooks'], // Assign to a 'Webhooks' tag }, }; }); document['x-webhooks'] = webhooksDefinition; } export const setupSwagger = async (app: INestApplication, internalSdkGeneration?: boolean) => { await SwaggerModule.loadPluginMetadata(metadata); const baseDocument = buildOpenApiBaseDocument(internalSdkGeneration); const document = buildFullDocumentWithPath(app, baseDocument); // Generate and add x-webhooks section FIRST generateWebhookDefinitions(document); publishDeprecatedDocument(app, document); publishLegacyOpenApiDoc(app, document); return publishSdkSpecificDocumentAndReturnDocument(app, document, internalSdkGeneration); }; function overloadNamingGuidelines(document: OpenAPIObject) { document['x-speakeasy-name-override'] = [ { operationId: '^.*get.*', methodNameOverride: 'retrieve' }, { operationId: '^.*retrieve.*', methodNameOverride: 'retrieve' }, { operationId: '^.*create.*', methodNameOverride: 'create' }, { operationId: '^.*update.*', methodNameOverride: 'update' }, { operationId: '^.*list.*', methodNameOverride: 'list' }, { operationId: '^.*delete.*', methodNameOverride: 'delete' }, { operationId: '^.*remove.*', methodNameOverride: 'delete' }, ]; } function overloadGlobalSdkRetrySettings(document: OpenAPIObject) { document['x-speakeasy-retries'] = { strategy: 'backoff', backoff: { initialInterval: 1000, maxInterval: 30000, maxElapsedTime: 3600000, exponent: 1.5, }, statusCodes: [408, 409, 429, '5XX'], retryConnectionErrors: true, }; } function patchOpenEnumSchemas(document: OpenAPIObject) { const openEnumSchemas = ['UiComponentEnum']; for (const schemaName of openEnumSchemas) { const schema = document.components?.schemas?.[schemaName]; if (schema) { (schema as Record)['x-speakeasy-unknown-values'] = 'allow'; } } } function publishSdkSpecificDocumentAndReturnDocument( app: INestApplication, document: OpenAPIObject, internalSdkGeneration?: boolean ) { overloadNamingGuidelines(document); overloadGlobalSdkRetrySettings(document); patchOpenEnumSchemas(document); let sdkDocument: OpenAPIObject = overloadDocumentForSdkGeneration(document, internalSdkGeneration); sdkDocument = sortOpenAPIDocument(sdkDocument); SwaggerModule.setup('openapi.sdk', app, sdkDocument, { jsonDocumentUrl: 'openapi.sdk.json', yamlDocumentUrl: 'openapi.sdk.yaml', explorer: process.env.NODE_ENV !== 'production', }); return sdkDocument; } ================================================ FILE: apps/api/src/app/shared/framework/user.decorator.ts ================================================ import { createParamDecorator, UnauthorizedException } from '@nestjs/common'; import { UserSession } from '@novu/application-generic'; import { SubscriberEntity } from '@novu/dal'; import { ApiAuthSchemeEnum } from '@novu/shared'; import jwt from 'jsonwebtoken'; export { UserSession }; export interface SubscriberSession extends SubscriberEntity { organizationId: string; environmentId: string; contextKeys: string[]; scheme: ApiAuthSchemeEnum; } export const SubscriberSession = createParamDecorator((data, ctx) => { const req = ctx.getType() === 'graphql' ? ctx.getArgs()[2].req : ctx.switchToHttp().getRequest(); if (req.user) { return req.user; } const authorization = req.headers?.authorization; if (!authorization) { return null; } const tokenParts = authorization.split(' '); if (tokenParts[0] !== 'Bearer' || !tokenParts[1]) { throw new UnauthorizedException('bad_token'); } return jwt.decode(tokenParts[1]); }); ================================================ FILE: apps/api/src/app/shared/helpers/content.service.spec.ts ================================================ import { ContentService } from '@novu/application-generic'; import { DelayTypeEnum, DigestTypeEnum, DigestUnitEnum, FieldLogicalOperatorEnum, FieldOperatorEnum, FilterPartTypeEnum, INotificationTemplateStep, StepTypeEnum, TriggerContextTypeEnum, } from '@novu/shared'; import { expect } from 'chai'; describe('ContentService', () => { describe('replaceVariables', () => { it('should replace duplicates entries', () => { const variables = { firstName: 'Name', lastName: 'Last Name', }; const contentService = new ContentService(); const modified = contentService.replaceVariables( '{{firstName}} is the first {{firstName}} of {{firstName}}', variables ); expect(modified).to.equal('Name is the first Name of Name'); }); it('should replace multiple variables', () => { const variables = { firstName: 'Name', $last_name: 'Last Name', }; const contentService = new ContentService(); const modified = contentService.replaceVariables( '{{firstName}} is the first {{$last_name}} of {{firstName}}', variables ); expect(modified).to.equal('Name is the first Last Name of Name'); }); it('should not manipulate variables for text without them', () => { const variables = { firstName: 'Name', lastName: 'Last Name', }; const contentService = new ContentService(); const modified = contentService.replaceVariables('This is a text without variables', variables); expect(modified).to.equal('This is a text without variables'); }); }); describe('extractVariables', () => { it('should not find any variables', () => { const contentService = new ContentService(); try { contentService.extractVariables('This is a text without variables {{ invalid }} {{ not valid{ {var}}'); expect(true).to.equal(false); } catch (e) { expect(e.response.message).to.equal('Failed to extract variables'); } }); it('should extract all valid variables', () => { const contentService = new ContentService(); const extractVariables = contentService.extractVariables( ' {{name}} d {{lastName}} dd {{_validName}} {{not valid}} aa {{0notValid}}tr {{organization_name}}' ); const variablesNames = extractVariables.map((variable) => variable.name); expect(extractVariables.length).to.equal(4); expect(variablesNames).to.include('_validName'); expect(variablesNames).to.include('lastName'); expect(variablesNames).to.include('name'); expect(variablesNames).to.include('organization_name'); }); it('should correctly extract variables related to registered handlebar helpers', () => { const contentService = new ContentService(); const extractVariables = contentService.extractVariables(' {{titlecase word}}'); expect(extractVariables.length).to.equal(1); expect(extractVariables[0].name).to.include('word'); }); it('should not show @data variables ', () => { const contentService = new ContentService(); const extractVariables = contentService.extractVariables( ' {{#each array}} {{@index}} {{#if @first}} First {{/if}} {{name}} {{/each}}' ); expect(extractVariables.length).to.equal(2); expect(extractVariables[0].name).to.include('array'); expect(extractVariables[0].type).to.eq('Array'); expect(extractVariables[1].name).to.include('name'); }); }); describe('extractMessageVariables', () => { it('should not extract variables', () => { const contentService = new ContentService(); const { variables } = contentService.extractMessageVariables([ { template: { type: StepTypeEnum.IN_APP, subject: 'Test', content: 'Text', }, }, ]); expect(variables.length).to.equal(0); }); it('should extract subject variables', () => { const contentService = new ContentService(); const { variables } = contentService.extractMessageVariables([ { template: { type: StepTypeEnum.EMAIL, subject: 'Test {{firstName}}', content: [], }, }, ]); expect(variables.length).to.equal(1); expect(variables[0].name).to.include('firstName'); }); it('should extract reserved variables', () => { const contentService = new ContentService(); const { variables, reservedVariables } = contentService.extractMessageVariables([ { template: { type: StepTypeEnum.EMAIL, subject: 'Test {{firstName}} {{tenant.name}}', content: [], }, }, ]); expect(variables.length).to.equal(1); expect(variables[0].name).to.include('firstName'); expect(reservedVariables.length).to.equal(1); expect(reservedVariables[0].type).to.eq(TriggerContextTypeEnum.TENANT); expect(reservedVariables[0].variables[0].name).to.include('identifier'); }); it('should add phone when SMS channel Exists', () => { const contentService = new ContentService(); const variables = contentService.extractSubscriberMessageVariables([ { template: { type: StepTypeEnum.IN_APP, subject: 'Test', content: 'Text', }, }, { template: { type: StepTypeEnum.SMS, content: 'Text', }, }, ]); expect(variables.length).to.equal(1); expect(variables[0]).to.equal('phone'); }); it('should add email when EMAIL channel Exists', () => { const contentService = new ContentService(); const variables = contentService.extractSubscriberMessageVariables([ { template: { type: StepTypeEnum.EMAIL, subject: 'Test', content: 'Text', }, }, { template: { type: StepTypeEnum.IN_APP, content: 'Text', }, }, ]); expect(variables.length).to.equal(1); expect(variables[0]).to.equal('email'); }); it('should extract email content variables', () => { const contentService = new ContentService(); const messages = [ { template: { type: StepTypeEnum.EMAIL, subject: 'Test {{firstName}}', content: [ { content: 'Test of {{lastName}}', type: 'text', }, { content: 'Test of {{lastName}}', type: 'text', url: 'Test of {{url}}', }, ], }, }, { template: { type: StepTypeEnum.EMAIL, subject: 'Test {{email}}', content: [ { content: 'Test of {{lastName}}', type: 'text', }, { content: 'Test of {{lastName}}', type: 'text', url: 'Test of {{url}}', }, ], }, }, ] as INotificationTemplateStep[]; const { variables } = contentService.extractMessageVariables(messages); const subscriberVariables = contentService.extractSubscriberMessageVariables(messages); const variablesNames = variables.map((variable) => variable.name); expect(variables.length).to.equal(4); expect(subscriberVariables.length).to.equal(1); expect(variablesNames).to.include('lastName'); expect(variablesNames).to.include('url'); expect(variablesNames).to.include('firstName'); expect(subscriberVariables).to.include('email'); }); it('should extract in-app content variables', () => { const contentService = new ContentService(); const { variables } = contentService.extractMessageVariables([ { template: { type: StepTypeEnum.IN_APP, content: '{{customVariables}}', }, }, ]); expect(variables.length).to.equal(1); expect(variables[0].name).to.include('customVariables'); }); it('should extract i18n content variables', () => { const contentService = new ContentService(); const { variables } = contentService.extractMessageVariables([ { template: { type: StepTypeEnum.IN_APP, content: '{{i18n "group.key" var=customVar.subVar var2=secVar}}', }, }, ]); expect(variables.length).to.equal(2); const variablesNames = variables.map((variable) => variable.name); expect(variablesNames).to.include('customVar.subVar'); expect(variablesNames).to.include('secVar'); }); it('should extract action steps variables', () => { const contentService = new ContentService(); const { variables } = contentService.extractMessageVariables([ { template: { type: StepTypeEnum.DELAY, content: '', }, metadata: { type: DelayTypeEnum.SCHEDULED, delayPath: 'sendAt' }, }, { template: { type: StepTypeEnum.DIGEST, content: '', }, metadata: { type: DigestTypeEnum.REGULAR, digestKey: 'path', unit: DigestUnitEnum.SECONDS, amount: 1 }, }, ]); const variablesNames = variables.map((variable) => variable.name); expect(variables.length).to.equal(2); expect(variablesNames).to.include('sendAt'); expect(variablesNames).to.include('path'); }); it('should extract filter variables on payload', () => { const contentService = new ContentService(); const { variables } = contentService.extractMessageVariables([ { template: { type: StepTypeEnum.EMAIL, content: '{{name}}', }, filters: [ { isNegated: false, type: 'GROUP', value: FieldLogicalOperatorEnum.AND, children: [ { on: FilterPartTypeEnum.PAYLOAD, field: 'counter', value: 'test value', operator: FieldOperatorEnum.EQUAL, }, ], }, ], }, ]); const variablesNames = variables.map((variable) => variable.name); expect(variables.length).to.equal(2); expect(variablesNames).to.include('name'); expect(variablesNames).to.include('counter'); }); it('should not extract variables reserved for the system', () => { const contentService = new ContentService(); const messages = [ { template: { type: StepTypeEnum.EMAIL, subject: 'Test {{subscriber.firstName}}', content: [ { content: 'Test of {{subscriber.firstName}} {{lastName}}', type: 'text', }, ], }, }, ] as INotificationTemplateStep[]; const { variables: extractVariables } = contentService.extractMessageVariables(messages); expect(extractVariables.length).to.equal(1); expect(extractVariables[0].name).to.include('lastName'); }); }); describe('extractStepVariables', () => { it('should not fail if no filters available', () => { const contentService = new ContentService(); const messages = [ { template: { type: StepTypeEnum.EMAIL, subject: 'Test {{subscriber.firstName}}', content: [ { content: 'Test of {{subscriber.firstName}} {{lastName}}', type: 'text', }, ], }, }, ] as INotificationTemplateStep[]; const variables = contentService.extractStepVariables(messages); expect(variables.length).to.equal(0); }); it('should not fail if filters are set as non array', () => { const contentService = new ContentService(); const messages = [ { template: { type: StepTypeEnum.EMAIL, subject: 'Test {{subscriber.firstName}}', content: [ { content: 'Test of {{subscriber.firstName}} {{lastName}}', type: 'text', }, ], }, filters: {}, }, ] as INotificationTemplateStep[]; const variables = contentService.extractStepVariables(messages); expect(variables.length).to.equal(0); }); it('should not fail if filters are an empty array', () => { const contentService = new ContentService(); const messages = [ { template: { type: StepTypeEnum.EMAIL, subject: 'Test {{subscriber.firstName}}', content: [ { content: 'Test of {{subscriber.firstName}} {{lastName}}', type: 'text', }, ], }, filters: [], }, ] as INotificationTemplateStep[]; const variables = contentService.extractStepVariables(messages); expect(variables.length).to.equal(0); }); it('should not fail if filters have some wrong settings like missing children in filters', () => { const contentService = new ContentService(); const messages = [ { template: { type: StepTypeEnum.EMAIL, subject: 'Test {{subscriber.firstName}}', content: [ { content: 'Test of {{subscriber.firstName}} {{lastName}}', type: 'text', }, ], }, filters: [ { isNegated: false, type: 'GROUP', value: FieldLogicalOperatorEnum.AND, }, ], }, ] as INotificationTemplateStep[]; const variables = contentService.extractStepVariables(messages); expect(variables.length).to.equal(0); }); }); }); ================================================ FILE: apps/api/src/app/shared/helpers/e2e/sdk/e2e-sdk.helper.ts ================================================ import { Novu } from '@novu/api'; import { NovuCore } from '@novu/api/core'; import { SDKOptions } from '@novu/api/lib/config'; import { HTTPClient, HTTPClientOptions } from '@novu/api/lib/http'; import { ErrorDto, SDKValidationError, ValidationErrorDto } from '@novu/api/models/errors'; import { HttpRequestHeaderKeysEnum } from '@novu/application-generic'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; export function initNovuClassSdk(session: UserSession, shouldRetry: boolean = false): Novu { const options: SDKOptions = { security: { secretKey: session.apiKey }, serverURL: session.serverUrl, debugLogger: process.env.LOG_LEVEL === 'debug' ? console : undefined, }; if (!shouldRetry) { options.retryConfig = { strategy: 'none' }; } return new Novu(options); } export function initNovuClassSdkInternalAuth(session: UserSession, shouldRetry: boolean = false): Novu { const options: SDKOptions = { security: { bearerAuth: session.token }, serverURL: session.serverUrl, httpClient: new CustomHeaderHTTPClient({ [HttpRequestHeaderKeysEnum.NOVU_ENVIRONMENT_ID]: session.environment._id, }), // debugLogger: console, }; if (!shouldRetry) { options.retryConfig = { strategy: 'none' }; } return new Novu(options); } export function initNovuFunctionSdk(session: UserSession): NovuCore { return new NovuCore({ security: { secretKey: session.apiKey }, serverURL: session.serverUrl }); } function isErrorDto(error: unknown): error is ErrorDto { return typeof error === 'object' && error !== null && 'name' in error && error.name === 'ErrorDto'; } function isValidationErrorDto(error: unknown): error is ValidationErrorDto { return typeof error === 'object' && error !== null && 'name' in error && error.name === 'ValidationErrorDto'; } function isSDKValidationError(error: unknown): error is SDKValidationError { return ( error instanceof SDKValidationError && error.name === 'SDKValidationError' && 'rawValue' in error && 'rawMessage' in error && 'cause' in error ); } export function handleSdkError(error: unknown): ErrorDto { if (!isErrorDto(error)) { throw new Error(`Provided error is not an ErrorDto error found:\n ${JSON.stringify(error, null, 2)}`); } expect(error.name).to.equal('ErrorDto'); return error; } export function handleSdkZodFailure(error: unknown): SDKValidationError { if (!isSDKValidationError(error)) { throw new Error(`Provided error is not an ErrorDto error found:\n ${JSON.stringify(error, null, 2)}`); } expect(error.name).to.equal('SDKValidationError'); return error; } export function handleValidationErrorDto(error: unknown): ValidationErrorDto { if (!isValidationErrorDto(error)) { throw new Error(`Provided error is not an ValidationErrorDto error found:\n ${JSON.stringify(error, null, 2)}`); } expect(error.name).to.equal('ValidationErrorDto'); expect(error.ctx).to.be.ok; return error; } type AsyncAction = () => Promise; export async function expectSdkExceptionGeneric( action: AsyncAction ): Promise<{ error?: ErrorDto; successfulBody?: U }> { try { const response = await action(); return { successfulBody: response }; } catch (e) { return { error: handleSdkError(e) }; } } export async function expectSdkZodError( action: AsyncAction ): Promise<{ error?: SDKValidationError; successfulBody?: U }> { try { const response = await action(); return { successfulBody: response }; } catch (e) { return { error: handleSdkZodFailure(e) }; } } export async function expectSdkValidationExceptionGeneric( action: AsyncAction ): Promise<{ error?: ValidationErrorDto; successfulBody?: U }> { try { const response = await action(); return { successfulBody: response }; } catch (e) { return { error: handleValidationErrorDto(e) }; } } export class CustomHeaderHTTPClient extends HTTPClient { private defaultHeaders: HeadersInit; constructor(defaultHeaders: HeadersInit = {}, options: HTTPClientOptions = {}) { super(options); this.defaultHeaders = defaultHeaders; } async request(request: Request): Promise { // Create a new request with merged headers const mergedHeaders = new Headers(this.defaultHeaders); /* * Merge existing request headers with default headers * Existing request headers take precedence */ request.headers.forEach((value, key) => { mergedHeaders.set(key, value); }); // Create a new request with merged headers const modifiedRequest = new Request(request, { headers: mergedHeaders, }); // Call the parent class's request method with the modified request return super.request(modifiedRequest); } } ================================================ FILE: apps/api/src/app/shared/helpers/generate-transaction-id.ts ================================================ import { generateObjectId } from '@novu/application-generic'; export function generateTransactionId() { return `txn_${generateObjectId()}`; } ================================================ FILE: apps/api/src/app/shared/helpers/index.ts ================================================ export * from './generate-transaction-id'; export * from './utils'; ================================================ FILE: apps/api/src/app/shared/helpers/is-valid-hmac.ts ================================================ import { createContextHash, createHash, decryptApiKey } from '@novu/application-generic'; import { ContextPayload } from '@novu/shared'; export function isHmacValid(secretKey: string, subscriberId: string, hmacHash: string | undefined) { if (!hmacHash) { return false; } const key = decryptApiKey(secretKey); const computedHmacHash = createHash(key, subscriberId); return computedHmacHash === hmacHash; } export function isContextHmacValid( secretKey: string, context: ContextPayload, contextHash: string | undefined ): boolean { if (!contextHash) { return false; } const key = decryptApiKey(secretKey); const computedContextHash = createContextHash(key, context); return computedContextHash === contextHash; } ================================================ FILE: apps/api/src/app/shared/helpers/utils/index.ts ================================================ export * from './mapMarkMessageToWebSocketEvent'; ================================================ FILE: apps/api/src/app/shared/helpers/utils/mapMarkMessageToWebSocketEvent.ts ================================================ import { MessagesStatusEnum, WebSocketEventEnum } from '@novu/shared'; export function mapMarkMessageToWebSocketEvent(markAs: MessagesStatusEnum): WebSocketEventEnum | undefined { if (markAs === MessagesStatusEnum.READ || markAs === MessagesStatusEnum.UNREAD) { return WebSocketEventEnum.UNREAD; } if (markAs === MessagesStatusEnum.SEEN || markAs === MessagesStatusEnum.UNSEEN) { return WebSocketEventEnum.UNSEEN; } return undefined; } ================================================ FILE: apps/api/src/app/shared/interceptors/product-feature.interceptor.ts ================================================ import { CallHandler, ExecutionContext, HttpException, Injectable, NestInterceptor, UnauthorizedException, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { ProductFeature } from '@novu/application-generic'; import { CommunityOrganizationRepository } from '@novu/dal'; import { ApiServiceLevelEnum, ProductFeatureKeyEnum, productFeatureEnabledForServiceLevel, UserSessionData, } from '@novu/shared'; import { Observable } from 'rxjs'; @Injectable() export class ProductFeatureInterceptor implements NestInterceptor { constructor( private reflector: Reflector, private organizationRepository: CommunityOrganizationRepository ) {} async intercept(context: ExecutionContext, next: CallHandler): Promise> { const handler = context.getHandler(); const classRef = context.getClass(); const requestedFeature: ProductFeatureKeyEnum | undefined = this.reflector.getAllAndOverride(ProductFeature, [ handler, classRef, ]); if (requestedFeature === undefined) { return next.handle(); } const user = this.getReqUser(context); if (!user) { throw new UnauthorizedException(); } const { organizationId } = user; const organization = await this.organizationRepository.findById(organizationId); const enabled = productFeatureEnabledForServiceLevel[requestedFeature].includes( organization?.apiServiceLevel || ApiServiceLevelEnum.FREE ); if (!enabled) { // TODO: Reuse PaymentRequiredException from EE billing module. throw new HttpException('Payment Required', 402); } return next.handle(); } private getReqUser(context: ExecutionContext): UserSessionData { const req = context.switchToHttp().getRequest(); return req.user; } } ================================================ FILE: apps/api/src/app/shared/middleware/request-id.middleware.ts ================================================ import { Injectable, NestMiddleware } from '@nestjs/common'; import { generateObjectId } from '@novu/application-generic'; import { NextFunction, Request, Response } from 'express'; export interface RequestWithReqId extends Request { _nvRequestId: string; } @Injectable() export class RequestIdMiddleware implements NestMiddleware { use(req: RequestWithReqId, _res: Response, next: NextFunction) { req._nvRequestId = `req_${generateObjectId()}`; next(); } } ================================================ FILE: apps/api/src/app/shared/services/encryption/index.ts ================================================ export { decryptCredentials, encryptCredentials } from '@novu/application-generic'; ================================================ FILE: apps/api/src/app/shared/shared.module.ts ================================================ import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import { analyticsService, CacheServiceHealthIndicator, CloudflareSchedulerService, ComputeJobWaitDurationService, CreateExecutionDetails, cacheService, clickHouseService, createNestLoggingModuleOptions, DalServiceHealthIndicator, DeliveryTrendCountsRepository, ExecuteBridgeRequest, ExecuteFrameworkRequest, ExecuteStepResolverRequest, featureFlagsService, GetDecryptedSecretKey, HttpClientService, InMemoryLRUCacheService, InvalidateCacheService, LoggerModule, QueuesModule, RequestLogRepository, StepRunRepository, storageService, TraceLogRepository, TraceRollupRepository, WorkflowRunCountRepository, WorkflowRunRepository, } from '@novu/application-generic'; import { ChangeRepository, CommunityMemberRepository, CommunityOrganizationRepository, CommunityUserRepository, ControlValuesRepository, DalService, EnvironmentRepository, EnvironmentVariableRepository, ExecutionDetailsRepository, FeedRepository, IntegrationRepository, JobRepository, LayoutRepository, MemberRepository, MessageRepository, MessageTemplateRepository, NotificationGroupRepository, NotificationRepository, NotificationTemplateRepository, OrganizationRepository, PreferencesRepository, SubscriberRepository, TenantRepository, TopicRepository, TopicSubscribersRepository, UserRepository, WorkflowOverrideRepository, } from '@novu/dal'; import { isClerkEnabled, JobTopicNameEnum } from '@novu/shared'; import packageJson from '../../../package.json'; function getDynamicAuthProviders() { if (isClerkEnabled()) { const eeAuthPackage = require('@novu/ee-auth'); return eeAuthPackage.injectEEAuthProviders(); } else { const userRepositoryProvider = { provide: 'USER_REPOSITORY', useClass: CommunityUserRepository, }; const memberRepositoryProvider = { provide: 'MEMBER_REPOSITORY', useClass: CommunityMemberRepository, }; const organizationRepositoryProvider = { provide: 'ORGANIZATION_REPOSITORY', useClass: CommunityOrganizationRepository, }; return [userRepositoryProvider, memberRepositoryProvider, organizationRepositoryProvider]; } } const DAL_MODELS = [ UserRepository, OrganizationRepository, CommunityOrganizationRepository, EnvironmentRepository, ExecutionDetailsRepository, NotificationTemplateRepository, SubscriberRepository, NotificationRepository, MessageRepository, MessageTemplateRepository, NotificationGroupRepository, MemberRepository, LayoutRepository, IntegrationRepository, ChangeRepository, JobRepository, FeedRepository, TopicRepository, TopicSubscribersRepository, TenantRepository, WorkflowOverrideRepository, ControlValuesRepository, PreferencesRepository, EnvironmentVariableRepository, ]; const dalService = { provide: DalService, useFactory: async () => { const service = new DalService(); await service.connect(process.env.MONGO_URL || '.'); return service; }, }; const ANALYTICS_PROVIDERS = [ // Repositories RequestLogRepository, TraceLogRepository, StepRunRepository, WorkflowRunRepository, WorkflowRunCountRepository, TraceRollupRepository, DeliveryTrendCountsRepository, // Services clickHouseService, ]; const PROVIDERS = [ analyticsService, cacheService, CacheServiceHealthIndicator, CloudflareSchedulerService, ComputeJobWaitDurationService, dalService, DalServiceHealthIndicator, featureFlagsService, InMemoryLRUCacheService, InvalidateCacheService, storageService, ...DAL_MODELS, CreateExecutionDetails, ExecuteBridgeRequest, ExecuteFrameworkRequest, ExecuteStepResolverRequest, GetDecryptedSecretKey, HttpClientService, ...ANALYTICS_PROVIDERS, ]; const IMPORTS = [ QueuesModule.forRoot([ JobTopicNameEnum.WEB_SOCKETS, JobTopicNameEnum.WORKFLOW, JobTopicNameEnum.INBOUND_PARSE_MAIL, JobTopicNameEnum.STANDARD, ]), LoggerModule.forRoot( createNestLoggingModuleOptions({ serviceName: packageJson.name, version: packageJson.version, silent: !!process.env.CI, }) ), ]; if (process.env.NODE_ENV === 'test') { /** * This is here only because of the tests. These providers are available at AppModule level, * but since in tests we are often importing just the SharedModule and not the entire AppModule * we need to make sure these providers are available. * * TODO: modify tests to either import all services they need explicitly, or remove repositories from SharedModule, * and then import SharedModule + repositories explicitly. */ PROVIDERS.push(...getDynamicAuthProviders()); IMPORTS.push( JwtModule.register({ secret: `${process.env.JWT_SECRET}`, signOptions: { expiresIn: 360000, }, }) ); } @Module({ imports: [...IMPORTS], providers: [...PROVIDERS], exports: [...PROVIDERS, LoggerModule, QueuesModule], }) export class SharedModule {} ================================================ FILE: apps/api/src/app/shared/types.ts ================================================ export type Constructor = new (...args: any[]) => I; export type CursorPaginationParams = { limit: number; after?: string; offset: number; }; ================================================ FILE: apps/api/src/app/shared/utils/auth.utils.ts ================================================ /** * Checks if the authorization header contains a keyless token * @param authorizationHeader - The authorization header value * @returns boolean indicating if the header contains a keyless token */ export function checkIsKeylessHeader(authorizationHeader: string | undefined): boolean { if (!authorizationHeader) { return false; } /* * 'authorization' header 'Keyless pk_keyless_' * 'novu-application-identifier' header 'pk_keyless_' */ return authorizationHeader.includes('pk_keyless_'); } ================================================ FILE: apps/api/src/app/shared/utils/mappers.ts ================================================ import { LogRepository, RequestLog } from '@novu/application-generic'; import { UserSessionData } from '@novu/shared'; import { getClientIp } from 'request-ip'; import { sanitizePayload } from '../../../utils/payload-sanitizer'; import { generateTransactionId } from '../helpers/generate-transaction-id'; import { RequestWithReqId } from '../middleware/request-id.middleware'; import { getRequestId } from './request-transaction.util'; function extractTransactionIdFromBody(body: unknown): string | undefined { if (!body || typeof body !== 'object') return undefined; const singleBody = body as { transactionId?: string }; if (singleBody.transactionId) return singleBody.transactionId; const bulkBody = body as { events?: Array<{ transactionId?: string }> }; if (Array.isArray(bulkBody.events)) { const ids = bulkBody.events.map((e) => e.transactionId).filter(Boolean); if (ids.length > 0) return ids.join(','); } return undefined; } export function buildLog( req: RequestWithReqId, statusCode: number, data: any, user: UserSessionData | null, duration: number = 0 ): Omit | null { // Skip logging when user data is incomplete to prevent orphaned log entries if (!user?._id || !user?.organizationId || !user?.environmentId || !user?.scheme) return null; const requestId = getRequestId(req); if (!requestId) { return null; } return { id: requestId, created_at: LogRepository.formatDateTime64(new Date()), path: req.path, url: req.originalUrl, url_pattern: req.route.path, hostname: req.hostname, status_code: statusCode, method: req.method, transaction_id: extractTransactionIdFromBody(req.body) || generateTransactionId(), ip: getClientIp(req) || '', user_agent: req.headers['user-agent'] || '', request_body: sanitizePayload(req.body), response_body: sanitizePayload(data), user_id: user._id, organization_id: user.organizationId, environment_id: user.environmentId, auth_type: user.scheme, duration_ms: duration, }; } ================================================ FILE: apps/api/src/app/shared/utils/request-transaction.util.ts ================================================ import { RequestWithReqId } from '../middleware/request-id.middleware'; /** * Extracts the request ID from the request object without fallback. * Returns undefined if no request ID is attached to the request. */ export function getRequestId(req: RequestWithReqId): string | undefined { return req._nvRequestId; } ================================================ FILE: apps/api/src/app/shared/validators/image.validator.ts ================================================ import { registerDecorator, ValidationArguments, ValidationOptions } from 'class-validator'; export function IsImageUrl(validationOptions?: ValidationOptions) { return (object: object, propertyName: string) => { registerDecorator({ name: 'isImageUrl', target: object.constructor, propertyName, options: validationOptions, validator: { validate(value: any, args: ValidationArguments) { if (!value || typeof value !== 'string') return false; const validExtensions = ['jpg', 'jpeg', 'png', 'gif', 'svg']; const extension = value.split('.').pop(); if (!extension) return false; return validExtensions.includes(extension); }, }, }); }; } ================================================ FILE: apps/api/src/app/shared/validators/is-enum-or-array.ts ================================================ import { registerDecorator, ValidationArguments, ValidationOptions } from 'class-validator'; export function IsEnumOrArray(enumObj: object, options?: ValidationOptions) { return (object: unknown, propertyName: string) => { registerDecorator({ name: 'isEnumOrArray', target: (object as any).constructor, propertyName, constraints: [enumObj], options, validator: { validate(value: any, args: ValidationArguments) { const allowed = Object.values(args.constraints[0] as object); if (value === undefined || value === null) return true; return Array.isArray(value) ? value.every((v) => allowed.includes(v)) : allowed.includes(value); }, }, }); }; } ================================================ FILE: apps/api/src/app/shared/validators/is-mongo-id-or-array-of-ids.validator.ts ================================================ import { isMongoId, ValidateBy, ValidationOptions } from 'class-validator'; export function IsMongoIdOrArrayOfMongoIds(validationOptions: ValidationOptions & { fieldName?: string }) { return ValidateBy( { name: 'isMongoIdOrArrayOfMongoIds', validator: { validate: (value: unknown): boolean => { if (typeof value === 'string') { return isMongoId(value); } if (Array.isArray(value)) { return value.length > 0 && value.every((id) => typeof id === 'string' && isMongoId(id)); } return false; }, defaultMessage: (): string => { return `${validationOptions.fieldName} must be a valid MongoDB ObjectId or an array of valid MongoDB ObjectIds`; }, }, }, validationOptions ); } ================================================ FILE: apps/api/src/app/shared/validators/is-time-12-hour-format.validator.ts ================================================ import { registerDecorator, ValidationArguments, ValidationOptions } from 'class-validator'; export function IsTime12HourFormat(validationOptions?: ValidationOptions) { return (object: any, propertyName: string) => { registerDecorator({ name: 'isTime12HourFormat', target: object.constructor, propertyName, options: validationOptions, validator: { validate(value: unknown) { if (typeof value !== 'string') { return false; } // Regex pattern for 12-hour format: HH:MM AM/PM // Accepts: 01:00 AM through 12:59 PM // With optional leading zero: 1:00 AM or 01:00 AM const time12HourRegex = /^(0?[1-9]|1[0-2]):[0-5][0-9]\s?(AM|PM)$/i; return time12HourRegex.test(value); }, defaultMessage(args: ValidationArguments) { return `${args.property} must be in 12-hour format (e.g., 09:00 AM or 9:00 AM)`; }, }, }); }; } ================================================ FILE: apps/api/src/app/shared/validators/json-schema.validator.ts ================================================ import Ajv from 'ajv'; import addFormats from 'ajv-formats'; import { registerDecorator, ValidationArguments, ValidationOptions } from 'class-validator'; export function IsValidJsonSchema(validationOptions?: ValidationOptions & { nullable?: boolean }) { return (object: object, propertyName: string) => { registerDecorator({ name: 'isValidJsonSchema', target: object.constructor, propertyName, options: validationOptions, validator: { validate(value: any, args: ValidationArguments) { if (!value || typeof value !== 'object') { if (validationOptions?.nullable && !value) { return true; } return false; } try { const ajv = new Ajv({ strict: false }); addFormats(ajv); ajv.compile(value); return true; } catch (error) { return false; } }, defaultMessage(args: ValidationArguments) { return `${args.property} must be a valid JSON schema`; }, }, }); }; } ================================================ FILE: apps/api/src/app/shared/validators/weekly-schedule-disabled.validator.ts ================================================ import { registerDecorator, ValidationArguments, ValidationOptions } from 'class-validator'; const weekdays = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']; export function WeeklyScheduleValidation(validationOptions?: ValidationOptions) { return (object: object, propertyName: string) => { registerDecorator({ name: 'weeklyScheduleDisabled', target: object.constructor, propertyName, options: validationOptions, validator: { validate(value: unknown, args: ValidationArguments) { const obj = args.object as { isEnabled?: boolean }; if (obj.isEnabled === true && value && Object.keys(value).length === 0) { return false; } if (obj.isEnabled === true && value && Object.keys(value).some((key) => !weekdays.includes(key))) { return false; } return true; }, defaultMessage(args: ValidationArguments) { const obj = args.object as { isEnabled?: boolean }; const value = args.value; if (obj.isEnabled === true && value && Object.keys(value).length === 0) { return 'weeklySchedule must contain at least one day configuration when isEnabled is true'; } if (obj.isEnabled === true && value && Object.keys(value).some((key) => !weekdays.includes(key))) { const invalidKeys = Object.keys(value).filter((key) => !weekdays.includes(key)); return `weeklySchedule contains invalid day names: ${invalidKeys.join(', ')}. Valid days are: ${weekdays.join(', ')}`; } return 'weeklySchedule validation failed'; }, }, }); }; } ================================================ FILE: apps/api/src/app/step-resolvers/dtos/deploy-step-resolver-request.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { parseSlugId } from '@novu/application-generic'; import { StepTypeEnum } from '@novu/shared'; import { Transform, Type } from 'class-transformer'; import { ArrayMinSize, IsArray, IsEnum, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested, } from 'class-validator'; export class DeployStepResolverManifestStepDto { @ApiProperty({ description: 'Workflow identifier (trigger identifier or internal workflow id)', example: 'welcome-email', }) @Transform(({ value }) => parseSlugId(value)) @IsString() @IsNotEmpty() workflowId: string; @ApiProperty({ description: 'Step identifier from workflow definition', example: 'welcome-email-step', }) @IsString() @IsNotEmpty() stepId: string; @ApiProperty({ description: 'Channel step type', enum: StepTypeEnum, example: StepTypeEnum.EMAIL, }) @IsEnum(StepTypeEnum) @IsNotEmpty() stepType: StepTypeEnum; @ApiPropertyOptional({ description: 'JSON Schema describing the control inputs for this step', type: 'object', additionalProperties: true, }) @IsOptional() @IsObject() controlSchema?: Record; } export class DeployStepResolverManifestDto { @ApiProperty({ description: 'Selected steps included in this publish', type: [DeployStepResolverManifestStepDto], }) @IsArray() @ArrayMinSize(1) @ValidateNested({ each: true }) @Type(() => DeployStepResolverManifestStepDto) steps: DeployStepResolverManifestStepDto[]; } export class DeployStepResolverRequestDto { @ApiProperty({ description: 'JSON-serialized step resolver manifest', example: '{"steps":[{"workflowId":"welcome-email","stepId":"welcome","stepType":"email"}]}', }) @IsString() @IsNotEmpty() manifest: string; } ================================================ FILE: apps/api/src/app/step-resolvers/dtos/deploy-step-resolver-response.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; export class SkippedStepDto { @ApiProperty({ description: 'Workflow identifier', example: 'onboarding', }) workflowId: string; @ApiProperty({ description: 'Step identifier', example: 'welcome-email', }) stepId: string; @ApiProperty({ description: 'Reason the step was skipped', example: 'Code steps limit reached (1/1 used on Free plan)', }) reason: string; } export class DeployStepResolverResponseDto { @ApiProperty({ description: 'Readable deterministic release hash', example: '7gk2m-9q4vx', }) stepResolverHash: string; @ApiProperty({ description: 'Cloudflare script identifier for this release (sr- prefix)', example: 'sr-696a21b632ef1f83460d584d-7gk2m-9q4vx', }) workerId: string; @ApiProperty({ description: 'Number of steps successfully deployed in this release', example: 1, }) deployedStepsCount: number; @ApiProperty({ description: 'Steps that were skipped due to plan limits', type: [SkippedStepDto], }) skippedSteps: SkippedStepDto[]; @ApiProperty({ description: 'Deployment timestamp in ISO format', example: '2026-02-11T12:34:56.789Z', }) deployedAt: string; } ================================================ FILE: apps/api/src/app/step-resolvers/dtos/disconnect-step-resolver-request.dto.ts ================================================ import { StepTypeEnum } from '@novu/shared'; import { IsEnum, IsNotEmpty } from 'class-validator'; export class DisconnectStepResolverRequestDto { @IsEnum(StepTypeEnum) @IsNotEmpty() stepType: StepTypeEnum; } ================================================ FILE: apps/api/src/app/step-resolvers/dtos/index.ts ================================================ export * from './deploy-step-resolver-request.dto'; export * from './deploy-step-resolver-response.dto'; export * from './disconnect-step-resolver-request.dto'; export * from './step-resolvers-count-response.dto'; ================================================ FILE: apps/api/src/app/step-resolvers/dtos/step-resolvers-count-response.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; export class StepResolversCountResponseDto { @ApiProperty({ description: 'Number of steps in this environment that use custom code (step resolver)', example: 3, }) count: number; } ================================================ FILE: apps/api/src/app/step-resolvers/e2e/step-resolvers.e2e.ts ================================================ import { Novu } from '@novu/api'; import { WorkflowCreationSourceEnum } from '@novu/api/models/components'; import { FeatureFlagsService, ResourceValidatorService } from '@novu/application-generic'; import { ControlValuesRepository, EnvironmentRepository, MessageTemplateRepository, NotificationTemplateRepository, } from '@novu/dal'; import { ControlValuesLevelEnum, StepTypeEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import sinon from 'sinon'; import { initNovuClassSdkInternalAuth } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; import { CloudflareStepResolverDeployService } from '../services/cloudflare-step-resolver-deploy.service'; describe('Step Resolvers #novu-v2', () => { let session: UserSession; let novuClient: Novu; let sandbox: sinon.SinonSandbox; let workflowId: string; let stepId: string; let stepInternalId: string; let workflowInternalId: string; const messageTemplateRepository = new MessageTemplateRepository(); const controlValuesRepository = new ControlValuesRepository(); const environmentRepository = new EnvironmentRepository(); const workflowRepository = new NotificationTemplateRepository(); beforeEach(async () => { sandbox = sinon.createSandbox(); sandbox.stub(CloudflareStepResolverDeployService.prototype, 'deploy').resolves(); sandbox.stub(FeatureFlagsService.prototype, 'getFlag').resolves(true); sandbox.stub(ResourceValidatorService.prototype, 'getStepResolversAvailableSlots').resolves(9999); sandbox.stub(ResourceValidatorService.prototype, 'validateStepResolversLimit').resolves(); session = new UserSession(); await session.initialize(); novuClient = initNovuClassSdkInternalAuth(session); const uid = Date.now(); const { result } = await novuClient.workflows.create({ name: `Test Workflow ${uid}`, workflowId: `test-workflow-${uid}`, steps: [{ name: 'Email Step', type: 'email' as const, controlValues: { subject: 'Test Subject' } }], source: WorkflowCreationSourceEnum.Editor, }); workflowId = result.workflowId; const firstStep = result.steps[0]; if (firstStep.type === 'UNKNOWN') throw new Error('Unexpected unknown step type'); stepId = firstStep.stepId; const workflow = await workflowRepository.findOne({ _environmentId: session.environment._id, _organizationId: session.organization._id, triggers: { $elemMatch: { identifier: workflowId } }, }); if (!workflow) throw new Error(`Workflow not found: ${workflowId}`); stepInternalId = String(workflow.steps[0]._templateId); workflowInternalId = String(workflow._id); }); afterEach(() => { sandbox.restore(); }); async function deployStep( options: { workflowId: string; stepId: string; stepType?: StepTypeEnum; controlSchema?: Record; }, agent = session.testAgent ) { const bundle = Buffer.from('export default { fetch: () => new Response("ok") }'); const stepType = options.stepType ?? StepTypeEnum.EMAIL; const manifest = JSON.stringify({ steps: [ { workflowId: options.workflowId, stepId: options.stepId, stepType, ...(options.controlSchema ? { controlSchema: options.controlSchema } : {}), }, ], }); return agent .post('/v2/step-resolvers/deploy') .attach('bundle', bundle, { filename: 'worker.mjs', contentType: 'application/javascript+module' }) .field('manifest', manifest); } async function createActionWorkflow(actionStepType: StepTypeEnum.DELAY | StepTypeEnum.DIGEST | StepTypeEnum.THROTTLE) { const uid = Date.now(); const { result } = await novuClient.workflows.create({ name: `${actionStepType} Workflow ${uid}`, workflowId: `${actionStepType}-workflow-${uid}`, steps: [{ name: `${actionStepType} Step`, type: actionStepType as unknown as 'digest' }], source: WorkflowCreationSourceEnum.Editor, }); const actionWorkflow = await workflowRepository.findOne({ _environmentId: session.environment._id, _organizationId: session.organization._id, triggers: { $elemMatch: { identifier: result.workflowId } }, }); if (!actionWorkflow) throw new Error(`Action workflow not found: ${result.workflowId}`); const actionStepInternalId = String(actionWorkflow.steps[0]._templateId); const rawStep = result.steps[0] as unknown as { stepId?: string; type?: string; raw?: { stepId?: string } }; const actionStepId = rawStep?.raw?.stepId ?? rawStep?.stepId; if (!actionStepId) throw new Error(`Could not resolve stepId for ${actionStepType} step`); return { workflowId: result.workflowId, stepId: actionStepId, stepInternalId: actionStepInternalId, }; } async function seedControlValues(controls: Record) { await controlValuesRepository.deleteMany({ _organizationId: session.organization._id, _environmentId: session.environment._id, _stepId: stepInternalId, level: ControlValuesLevelEnum.STEP_CONTROLS, }); await controlValuesRepository.create({ _organizationId: session.organization._id, _environmentId: session.environment._id, _workflowId: workflowInternalId, _stepId: stepInternalId, level: ControlValuesLevelEnum.STEP_CONTROLS, priority: 0, controls, }); } describe('POST /v2/step-resolvers/deploy', () => { it('should write stepResolverHash to MessageTemplate and create ControlValues', async () => { const { body, status } = await deployStep({ workflowId, stepId }); expect(status).to.equal(201); expect(body.data.stepResolverHash).to.match(/^[a-z0-9]{5}-[a-z0-9]{5}$/); expect(body.data.workerId).to.match(/^sr-/); expect(body.data.deployedStepsCount).to.equal(1); expect(body.data.deployedAt).to.be.a('string'); const template = await messageTemplateRepository.findOne({ _id: stepInternalId, _environmentId: session.environment._id, }); expect(template?.stepResolverHash).to.equal(body.data.stepResolverHash); const controlValues = await controlValuesRepository.findOne({ _environmentId: session.environment._id, _organizationId: session.organization._id, _stepId: stepInternalId, level: ControlValuesLevelEnum.STEP_CONTROLS, }); expect(controlValues).to.exist; }); it('should write controlSchema to MessageTemplate.controls.schema when provided', async () => { const controlSchema = { type: 'object', properties: { headline: { type: 'string' } }, additionalProperties: false, required: [], }; const { status } = await deployStep({ workflowId, stepId, controlSchema }); expect(status).to.equal(201); const template = await messageTemplateRepository.findOne({ _id: stepInternalId, _environmentId: session.environment._id, }); expect(template?.controls?.schema).to.deep.equal(controlSchema); }); it('should preserve existing control values that match the redeployed schema', async () => { const controlSchema = { type: 'object', properties: { headline: { type: 'string' } }, additionalProperties: false, required: [], }; await seedControlValues({ headline: 'Hello' }); await deployStep({ workflowId, stepId, controlSchema }); const allControlValues = await controlValuesRepository.find({ _environmentId: session.environment._id, _organizationId: session.organization._id, _stepId: stepInternalId, level: ControlValuesLevelEnum.STEP_CONTROLS, }); expect(allControlValues).to.have.lengthOf(1); expect((allControlValues[0].controls as Record).headline).to.equal('Hello'); }); it('should prune control values for fields removed from the schema on redeploy', async () => { const controlSchema = { type: 'object', properties: { headline: { type: 'string' } }, additionalProperties: false, required: [], }; await seedControlValues({ headline: 'Hello', oldField: 'gone' }); await deployStep({ workflowId, stepId, controlSchema }); const allControlValues = await controlValuesRepository.find({ _environmentId: session.environment._id, _organizationId: session.organization._id, _stepId: stepInternalId, level: ControlValuesLevelEnum.STEP_CONTROLS, }); expect(allControlValues).to.have.lengthOf(1); expect(allControlValues[0].controls).to.deep.equal({ headline: 'Hello' }); }); it('should wipe all existing control values when redeploying without a controlSchema', async () => { await seedControlValues({ headline: 'Hello' }); await deployStep({ workflowId, stepId }); const allControlValues = await controlValuesRepository.find({ _environmentId: session.environment._id, _organizationId: session.organization._id, _stepId: stepInternalId, level: ControlValuesLevelEnum.STEP_CONTROLS, }); expect(allControlValues).to.have.lengthOf(1); expect(allControlValues[0].controls).to.deep.equal({}); }); it('should return 400 when manifest stepType does not match the actual step type', async () => { const { body, status } = await deployStep({ workflowId, stepId, stepType: StepTypeEnum.SMS }); expect(status).to.equal(400); expect(JSON.stringify(body)).to.include('does not match'); const template = await messageTemplateRepository.findOne({ _id: stepInternalId, _environmentId: session.environment._id, }); expect(template?.stepResolverHash).to.not.exist; }); it('should return 400 when no bundle file is provided', async () => { const manifest = JSON.stringify({ steps: [{ workflowId, stepId, stepType: StepTypeEnum.EMAIL }], }); const { body, status } = await session.testAgent.post('/v2/step-resolvers/deploy').field('manifest', manifest); expect(status).to.equal(400); expect(JSON.stringify(body)).to.include('Bundle file is required'); }); describe('Action step types (delay, digest, throttle)', () => { for (const actionStepType of [StepTypeEnum.DELAY, StepTypeEnum.DIGEST, StepTypeEnum.THROTTLE] as const) { it(`should deploy step resolver for a ${actionStepType} step`, async () => { const { workflowId: actionWorkflowId, stepId: actionStepId, stepInternalId: actionStepInternalId } = await createActionWorkflow(actionStepType); const { body, status } = await deployStep({ workflowId: actionWorkflowId, stepId: actionStepId, stepType: actionStepType, }); expect(status).to.equal(201); expect(body.data.stepResolverHash).to.match(/^[a-z0-9]{5}-[a-z0-9]{5}$/); expect(body.data.deployedStepsCount).to.equal(1); const template = await messageTemplateRepository.findOne({ _id: actionStepInternalId, _environmentId: session.environment._id, }); expect(template?.stepResolverHash).to.equal(body.data.stepResolverHash); }); } }); }); describe('DELETE /v2/step-resolvers/:stepInternalId/disconnect', () => { it('should clear stepResolverHash, delete ControlValues, and reset controls.schema', async () => { await deployStep({ workflowId, stepId }); const { status } = await session.testAgent .delete(`/v2/step-resolvers/${stepInternalId}/disconnect`) .send({ stepType: StepTypeEnum.EMAIL }); expect(status).to.equal(200); const template = await messageTemplateRepository.findOne({ _id: stepInternalId, _environmentId: session.environment._id, }); expect(template?.stepResolverHash).to.not.exist; const controlValues = await controlValuesRepository.findOne({ _environmentId: session.environment._id, _organizationId: session.organization._id, _stepId: stepInternalId, level: ControlValuesLevelEnum.STEP_CONTROLS, }); expect(controlValues).to.not.exist; expect(template?.controls?.schema).to.have.property('type', 'object'); expect(template?.controls?.schema).to.have.property('additionalProperties', false); }); it('should disconnect step resolver from a delay step and reset schema to default', async () => { const { workflowId: delayWorkflowId, stepId: delayStepId, stepInternalId: delayStepInternalId } = await createActionWorkflow(StepTypeEnum.DELAY); await deployStep({ workflowId: delayWorkflowId, stepId: delayStepId, stepType: StepTypeEnum.DELAY }); const { status } = await session.testAgent .delete(`/v2/step-resolvers/${delayStepInternalId}/disconnect`) .send({ stepType: StepTypeEnum.DELAY }); expect(status).to.equal(200); const template = await messageTemplateRepository.findOne({ _id: delayStepInternalId, _environmentId: session.environment._id, }); expect(template?.stepResolverHash).to.not.exist; }); it('should return 400 when the provided stepType does not support step resolvers', async () => { const { body, status } = await session.testAgent .delete(`/v2/step-resolvers/${stepInternalId}/disconnect`) .send({ stepType: StepTypeEnum.TRIGGER }); expect(status).to.equal(400); expect(JSON.stringify(body)).to.include('does not support step resolvers'); }); }); describe('GET /v2/step-resolvers/count', () => { it('should return the correct count across the deploy + disconnect lifecycle', async () => { const isolatedSession = new UserSession(); await isolatedSession.initialize(); const isolatedClient = initNovuClassSdkInternalAuth(isolatedSession); async function isolatedCount(): Promise { const { body } = await isolatedSession.testAgent.get('/v2/step-resolvers/count').expect(200); return body.data.count; } let counter = 0; async function createWorkflowInIsolatedSession() { const uid = `${Date.now()}-${++counter}`; const { result } = await isolatedClient.workflows.create({ name: `Count Test Workflow ${uid}`, workflowId: `count-test-${uid}`, steps: [{ name: 'Email Step', type: 'email' as const, controlValues: { subject: 'Test' } }], source: WorkflowCreationSourceEnum.Editor, }); const wf = await workflowRepository.findOne({ _environmentId: isolatedSession.environment._id, _organizationId: isolatedSession.organization._id, triggers: { $elemMatch: { identifier: result.workflowId } }, }); const firstStep = result.steps[0]; if (firstStep.type === 'UNKNOWN') throw new Error('Unexpected unknown step type'); return { workflowId: result.workflowId, stepId: firstStep.stepId, stepInternalId: String(wf!.steps[0]._templateId), }; } expect(await isolatedCount()).to.equal(0); const wfA = await createWorkflowInIsolatedSession(); await deployStep({ workflowId: wfA.workflowId, stepId: wfA.stepId }, isolatedSession.testAgent); expect(await isolatedCount()).to.equal(1); const wfB = await createWorkflowInIsolatedSession(); await deployStep({ workflowId: wfB.workflowId, stepId: wfB.stepId }, isolatedSession.testAgent); expect(await isolatedCount()).to.equal(2); await isolatedSession.testAgent .delete(`/v2/step-resolvers/${wfA.stepInternalId}/disconnect`) .send({ stepType: StepTypeEnum.EMAIL }) .expect(200); expect(await isolatedCount()).to.equal(1); }); }); describe('POST /v2/environments/:id/publish (step resolver sync)', () => { async function getProdEnv() { const prodEnv = await environmentRepository.findOne({ _parentId: session.environment._id, _organizationId: session.organization._id, }); if (!prodEnv) throw new Error('Production environment not found'); return prodEnv; } async function publish(targetEnvId: string) { return session.testAgent .post(`/v2/environments/${targetEnvId}/publish`) .send({ sourceEnvironmentId: session.environment._id, dryRun: false }) .expect(200); } it('should copy stepResolverHash and resolver schema to production on publish', async () => { const prodEnv = await getProdEnv(); const { body: deployBody } = await deployStep({ workflowId, stepId }); const devHash = deployBody.data.stepResolverHash; await publish(prodEnv._id); const prodWorkflow = await workflowRepository.findOne({ _environmentId: prodEnv._id, _organizationId: session.organization._id, triggers: { $elemMatch: { identifier: workflowId } }, }); const prodStepInternalId = String(prodWorkflow!.steps[0]._templateId); const prodTemplate = await messageTemplateRepository.findOne({ _id: prodStepInternalId, _environmentId: prodEnv._id, }); expect(prodTemplate?.stepResolverHash).to.equal(devHash); expect(prodTemplate?.controls?.schema).to.include({ type: 'object', additionalProperties: false }); }); it('should clear stepResolverHash from production when dev step is disconnected and republished', async () => { const prodEnv = await getProdEnv(); await deployStep({ workflowId, stepId }); await publish(prodEnv._id); await session.testAgent .delete(`/v2/step-resolvers/${stepInternalId}/disconnect`) .send({ stepType: StepTypeEnum.EMAIL }) .expect(200); await publish(prodEnv._id); const prodWorkflow = await workflowRepository.findOne({ _environmentId: prodEnv._id, _organizationId: session.organization._id, triggers: { $elemMatch: { identifier: workflowId } }, }); const prodStepInternalId = String(prodWorkflow!.steps[0]._templateId); const prodTemplate = await messageTemplateRepository.findOne({ _id: prodStepInternalId, _environmentId: prodEnv._id, }); expect(prodTemplate?.stepResolverHash).to.not.exist; expect(prodTemplate?.controls?.schema).to.have.property('type', 'object'); expect(prodTemplate?.controls?.schema).to.have.property('additionalProperties', false); }); it('should promote stepResolverHash to production for a delay step on publish', async () => { const prodEnv = await getProdEnv(); const { workflowId: delayWorkflowId, stepId: delayStepId, stepInternalId: delayStepInternalId } = await createActionWorkflow(StepTypeEnum.DELAY); const { body: deployBody } = await deployStep({ workflowId: delayWorkflowId, stepId: delayStepId, stepType: StepTypeEnum.DELAY, }); const devHash = deployBody.data.stepResolverHash; await publish(prodEnv._id); const prodDelayWorkflow = await workflowRepository.findOne({ _environmentId: prodEnv._id, _organizationId: session.organization._id, triggers: { $elemMatch: { identifier: delayWorkflowId } }, }); if (!prodDelayWorkflow) throw new Error('Prod delay workflow not found'); const prodStepInternalId = String(prodDelayWorkflow.steps[0]._templateId); const prodTemplate = await messageTemplateRepository.findOne({ _id: prodStepInternalId, _environmentId: prodEnv._id, }); expect(prodTemplate?.stepResolverHash).to.equal(devHash); }); }); }); ================================================ FILE: apps/api/src/app/step-resolvers/services/cloudflare-step-resolver-deploy.service.ts ================================================ import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { PinoLogger } from '@novu/application-generic'; const CF_COMPATIBILITY_DATE = '2025-11-18'; const WORKER_SCRIPT_NAME = 'worker.js'; const DEPLOY_TIMEOUT_MS = 30_000; interface DeployStepResolverToCloudflareCommand { workerId: string; organizationId: string; stepResolverHash: string; bundleBuffer: Buffer; } interface CloudflareDeploymentConfig { accountId: string; apiToken: string; dispatchNamespace: string; compatibilityDate: string; } interface CloudflareDeploymentError { message?: string; } interface CloudflareDeploymentResponse { success?: boolean; errors?: CloudflareDeploymentError[]; } @Injectable() export class CloudflareStepResolverDeployService { constructor(private logger: PinoLogger) { this.logger.setContext(this.constructor.name); } async deploy(command: DeployStepResolverToCloudflareCommand): Promise { const config = this.getConfigOrThrow(); const url = this.buildDeployUrl(config, command.workerId); const logContext = this.buildLogContext(command); try { this.logger.info(logContext, 'Sending Cloudflare step resolver deploy request'); const response = await this.sendDeployRequest(url, config, command); const rawBody = await response.text(); const parsedBody = this.safeJsonParse(rawBody); this.logger.info( { ...logContext, statusCode: response.status, ok: response.ok, }, 'Cloudflare step resolver deploy response' ); const isSuccess = response.ok && parsedBody?.success !== false; if (isSuccess) { return; } const errorMessage = this.extractCloudflareErrorMessage(parsedBody, rawBody, response.status); throw this.toServiceUnavailableException(response.status, errorMessage); } catch (error) { if (error instanceof ServiceUnavailableException) { throw error; } if (error instanceof Error && error.name === 'TimeoutError') { this.logger.error(logContext, `Cloudflare deploy request timed out after ${DEPLOY_TIMEOUT_MS}ms`); throw new ServiceUnavailableException(`Cloudflare deployment request timed out after ${DEPLOY_TIMEOUT_MS}ms`); } const formattedError = this.formatUnknownError(error); this.logger.error( { ...logContext, error: formattedError, }, 'Cloudflare deploy request failed' ); throw new ServiceUnavailableException(`Cloudflare deployment request failed: ${formattedError}`); } } private buildLogContext(command: DeployStepResolverToCloudflareCommand) { return { workerId: command.workerId, organizationId: command.organizationId, stepResolverHash: command.stepResolverHash, }; } private async sendDeployRequest( url: string, config: CloudflareDeploymentConfig, command: DeployStepResolverToCloudflareCommand ): Promise { const metadata = { main_module: WORKER_SCRIPT_NAME, compatibility_date: config.compatibilityDate, tags: this.buildTags(command.organizationId, command.stepResolverHash), }; const formData = new FormData(); formData.append( WORKER_SCRIPT_NAME, new Blob([command.bundleBuffer], { type: 'application/javascript+module' }), WORKER_SCRIPT_NAME ); formData.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' })); return fetch(url, { method: 'PUT', headers: { Authorization: `Bearer ${config.apiToken}`, }, body: formData, signal: AbortSignal.timeout(DEPLOY_TIMEOUT_MS), }); } private getConfigOrThrow(): CloudflareDeploymentConfig { const accountId = process.env.STEP_RESOLVER_CF_ACCOUNT_ID; const apiToken = process.env.STEP_RESOLVER_CF_API_TOKEN; const dispatchNamespace = process.env.STEP_RESOLVER_CF_DISPATCH_NAMESPACE; const missingVariables = [ ['STEP_RESOLVER_CF_ACCOUNT_ID', accountId], ['STEP_RESOLVER_CF_API_TOKEN', apiToken], ['STEP_RESOLVER_CF_DISPATCH_NAMESPACE', dispatchNamespace], ] .filter(([, value]) => !value) .map(([name]) => name); if (missingVariables.length > 0) { throw new ServiceUnavailableException( `Step resolver deployment is not configured. Missing: ${missingVariables.join(', ')}` ); } return { accountId: accountId!, apiToken: apiToken!, dispatchNamespace: dispatchNamespace!, compatibilityDate: CF_COMPATIBILITY_DATE, }; } private buildDeployUrl(config: CloudflareDeploymentConfig, workerId: string): string { const accountId = encodeURIComponent(config.accountId); const namespace = encodeURIComponent(config.dispatchNamespace); const scriptName = encodeURIComponent(workerId); return `https://api.cloudflare.com/client/v4/accounts/${accountId}/workers/dispatch/namespaces/${namespace}/scripts/${scriptName}`; } private buildTags(organizationId: string, stepResolverHash: string): string[] { return [`orgId:${organizationId}`, `stepResolverHash:${stepResolverHash}`]; } private toServiceUnavailableException(statusCode: number, message: string): ServiceUnavailableException { if (statusCode === 401 || statusCode === 403) { return new ServiceUnavailableException(`Cloudflare authentication failed: ${message}`); } if (statusCode === 429 || statusCode >= 500) { return new ServiceUnavailableException(`Cloudflare deployment temporarily unavailable: ${message}`); } return new ServiceUnavailableException(`Cloudflare deployment failed: ${message}`); } private extractCloudflareErrorMessage( payload: CloudflareDeploymentResponse | undefined, rawBody: string, statusCode: number ): string { return ( payload?.errors?.find((error) => error?.message)?.message || rawBody.trim() || `Cloudflare responded with status ${statusCode}` ); } private safeJsonParse(raw: string): T | undefined { if (!raw) { return undefined; } try { return JSON.parse(raw) as T; } catch { return undefined; } } private formatUnknownError(error: unknown): string { if (error instanceof Error) { return error.message; } return String(error); } } ================================================ FILE: apps/api/src/app/step-resolvers/step-resolvers.controller.ts ================================================ import { BadRequestException, Body, ClassSerializerInterceptor, Controller, Delete, Get, Param, Post, UploadedFile, UseInterceptors, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { ApiExcludeController } from '@nestjs/swagger'; import { DisconnectStepResolverCommand, DisconnectStepResolverUsecase, ExternalApiAccessible, RequirePermissions, } from '@novu/application-generic'; import { ApiRateLimitCategoryEnum, PermissionsEnum, UserSessionData } from '@novu/shared'; import { plainToInstance } from 'class-transformer'; import { ValidationError, validateSync } from 'class-validator'; import { RequireAuthentication } from '../auth/framework/auth.decorator'; import { ThrottlerCategory } from '../rate-limiting/guards/throttler.decorator'; import { UserSession } from '../shared/framework/user.decorator'; import { DeployStepResolverManifestDto, DeployStepResolverRequestDto, DeployStepResolverResponseDto, DisconnectStepResolverRequestDto, StepResolversCountResponseDto, } from './dtos'; import { DeployStepResolverCommand, DeployStepResolverUsecase } from './usecases/deploy-step-resolver'; import { GetStepResolversCountUsecase } from './usecases/get-step-resolvers-count'; interface UploadedBundleFile { buffer: Buffer; size: number; mimetype: string; originalname: string; } @Controller({ path: '/step-resolvers', version: '2' }) @ApiExcludeController() @UseInterceptors(ClassSerializerInterceptor) @ThrottlerCategory(ApiRateLimitCategoryEnum.CONFIGURATION) @RequireAuthentication() export class StepResolversController { constructor( private deployStepResolverUsecase: DeployStepResolverUsecase, private disconnectStepResolverUsecase: DisconnectStepResolverUsecase, private getStepResolversCountUsecase: GetStepResolversCountUsecase ) {} @Get('/count') @ExternalApiAccessible() @RequirePermissions(PermissionsEnum.WORKFLOW_READ) async getCount(@UserSession() user: UserSessionData): Promise { return this.getStepResolversCountUsecase.execute(user.environmentId); } @Post('/deploy') @ExternalApiAccessible() @RequirePermissions(PermissionsEnum.WORKFLOW_WRITE) @UseInterceptors( FileInterceptor('bundle', { limits: { files: 1, fileSize: 10 * 1024 * 1024, }, }) ) async deploy( @UserSession() user: UserSessionData, @Body() body: DeployStepResolverRequestDto, @UploadedFile() bundle: UploadedBundleFile ): Promise { if (!bundle) { throw new BadRequestException('Bundle file is required'); } const bundleBuffer = bundle.buffer; if (!bundleBuffer || bundleBuffer.byteLength === 0 || bundle.size === 0) { throw new BadRequestException('Bundle file must not be empty'); } const manifest = parseManifestOrThrow(body.manifest); return this.deployStepResolverUsecase.execute( DeployStepResolverCommand.create({ user, manifestSteps: manifest.steps, bundleBuffer, }) ); } @Delete('/:stepInternalId/disconnect') @ExternalApiAccessible() @RequirePermissions(PermissionsEnum.WORKFLOW_WRITE) async disconnect( @UserSession() user: UserSessionData, @Param('stepInternalId') stepInternalId: string, @Body() body: DisconnectStepResolverRequestDto ): Promise { await this.disconnectStepResolverUsecase.execute( DisconnectStepResolverCommand.create({ stepInternalId, stepType: body.stepType, user, }) ); } } function parseManifestOrThrow(rawManifest: string): DeployStepResolverManifestDto { let parsedManifest: unknown; try { parsedManifest = JSON.parse(rawManifest); } catch { throw new BadRequestException('Invalid manifest JSON'); } const manifestDto = plainToInstance(DeployStepResolverManifestDto, parsedManifest); const validationErrors = validateSync(manifestDto, { whitelist: true, }); if (validationErrors.length > 0) { throw new BadRequestException({ message: 'Invalid manifest', errors: formatValidationErrors(validationErrors), }); } return manifestDto; } function formatValidationErrors(errors: ValidationError[]): string[] { const formatted: string[] = []; const visit = (error: ValidationError, parentPath?: string) => { const currentPath = parentPath ? `${parentPath}.${error.property}` : error.property; if (error.constraints) { for (const message of Object.values(error.constraints)) { formatted.push(`${currentPath}: ${message}`); } } if (error.children) { for (const child of error.children) { visit(child, currentPath); } } }; for (const error of errors) { visit(error); } return formatted; } ================================================ FILE: apps/api/src/app/step-resolvers/step-resolvers.module.ts ================================================ import { Module } from '@nestjs/common'; import { BuildStepIssuesUsecase, BuildVariableSchemaUsecase, CreateVariablesObject, DisconnectStepResolverUsecase, GetWorkflowByIdsUseCase, ResourceValidatorService, TierRestrictionsValidateUsecase, } from '@novu/application-generic'; import { CommunityOrganizationRepository } from '@novu/dal'; import { SharedModule } from '../shared/shared.module'; import { CloudflareStepResolverDeployService } from './services/cloudflare-step-resolver-deploy.service'; import { StepResolversController } from './step-resolvers.controller'; import { DeployStepResolverUsecase } from './usecases/deploy-step-resolver'; import { GetStepResolversCountUsecase } from './usecases/get-step-resolvers-count'; import { SyncStepResolverToEnvironmentUsecase } from './usecases/sync-step-resolver-to-environment'; const USE_CASES = [ DeployStepResolverUsecase, DisconnectStepResolverUsecase, GetStepResolversCountUsecase, SyncStepResolverToEnvironmentUsecase, ]; const SERVICES = [CloudflareStepResolverDeployService]; @Module({ imports: [SharedModule], controllers: [StepResolversController], providers: [ ...USE_CASES, ...SERVICES, GetWorkflowByIdsUseCase, BuildStepIssuesUsecase, BuildVariableSchemaUsecase, TierRestrictionsValidateUsecase, CreateVariablesObject, CommunityOrganizationRepository, ResourceValidatorService, ], exports: [...USE_CASES], }) export class StepResolversModule {} ================================================ FILE: apps/api/src/app/step-resolvers/usecases/deploy-step-resolver/deploy-step-resolver.command.ts ================================================ import { EnvironmentWithUserObjectCommand } from '@novu/application-generic'; import { StepTypeEnum } from '@novu/shared'; import { Type } from 'class-transformer'; import { ArrayMinSize, IsArray, IsDefined, IsEnum, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested, } from 'class-validator'; export class DeployStepResolverManifestStepCommand { @IsString() @IsNotEmpty() workflowId: string; @IsString() @IsNotEmpty() stepId: string; @IsEnum(StepTypeEnum) @IsNotEmpty() stepType: StepTypeEnum; @IsOptional() @IsObject() controlSchema?: Record; } export class DeployStepResolverCommand extends EnvironmentWithUserObjectCommand { @IsArray() @ArrayMinSize(1) @ValidateNested({ each: true }) @Type(() => DeployStepResolverManifestStepCommand) manifestSteps: DeployStepResolverManifestStepCommand[]; @IsDefined() bundleBuffer: Buffer; } ================================================ FILE: apps/api/src/app/step-resolvers/usecases/deploy-step-resolver/deploy-step-resolver.usecase.ts ================================================ import { createHash } from 'node:crypto'; import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; import { AnalyticsService, BuildStepIssuesUsecase, FeatureFlagsService, GetWorkflowByIdsCommand, GetWorkflowByIdsUseCase, getStepResolverControlSchema, InstrumentUsecase, isStepResolverSupportedType, PinoLogger, ResourceValidatorService, reconcileStepResolverControlValues, } from '@novu/application-generic'; import { ClientSession, ControlValuesEntity, ControlValuesRepository, MessageTemplateRepository, NotificationTemplateRepository, } from '@novu/dal'; import { ControlValuesLevelEnum, FeatureFlagsKeysEnum, StepTypeEnum, UNLIMITED_VALUE } from '@novu/shared'; import { DeployStepResolverResponseDto, SkippedStepDto } from '../../dtos'; import { CloudflareStepResolverDeployService } from '../../services/cloudflare-step-resolver-deploy.service'; import { generateStepResolverWorkerId } from '../../utils/generate-step-resolver-worker-id'; import { DeployStepResolverCommand, DeployStepResolverManifestStepCommand } from './deploy-step-resolver.command'; const MAX_BUNDLE_SIZE_BYTES = 10 * 1024 * 1024; const ACTION_STEP_TYPES = new Set([StepTypeEnum.DELAY, StepTypeEnum.DIGEST, StepTypeEnum.THROTTLE]); // cspell:disable-next-line const STEP_RESOLVER_HASH_ALPHABET = '0123456789abcdefghjkmnpqrstvwxyz'; const STEP_RESOLVER_HASH_LENGTH = 10; interface ResolvedManifestStep { workflowId: string; workflowInternalId: string; stepId: string; stepInternalId: string; stepType: StepTypeEnum; controlSchema: Record; existingStepResolverHash: string | undefined; existingControlValues: ControlValuesEntity | null; } @Injectable() export class DeployStepResolverUsecase { constructor( private getWorkflowByIdsUseCase: GetWorkflowByIdsUseCase, private cloudflareStepResolverDeployService: CloudflareStepResolverDeployService, private controlValuesRepository: ControlValuesRepository, private messageTemplateRepository: MessageTemplateRepository, private notificationTemplateRepository: NotificationTemplateRepository, private buildStepIssuesUsecase: BuildStepIssuesUsecase, private featureFlagsService: FeatureFlagsService, private resourceValidatorService: ResourceValidatorService, private analyticsService: AnalyticsService, private logger: PinoLogger ) { this.logger.setContext(this.constructor.name); } @InstrumentUsecase() async execute(command: DeployStepResolverCommand): Promise { const [isStepResolverEnabled, isActionStepResolverEnabled] = await Promise.all([ this.featureFlagsService.getFlag({ key: FeatureFlagsKeysEnum.IS_STEP_RESOLVER_ENABLED, defaultValue: false, organization: { _id: command.user.organizationId }, }), this.featureFlagsService.getFlag({ key: FeatureFlagsKeysEnum.IS_ACTION_STEP_RESOLVER_ENABLED, defaultValue: false, organization: { _id: command.user.organizationId }, }), ]); if (!isStepResolverEnabled && !isActionStepResolverEnabled) { throw new ForbiddenException('Step resolver feature is not enabled for this organization'); } this.assertBundleSize(command.bundleBuffer); const resolvedManifestSteps = await this.resolveManifestSteps(command, command.manifestSteps, { isStepResolverEnabled, isActionStepResolverEnabled, }); const availableSlots = await this.resourceValidatorService.getStepResolversAvailableSlots( command.user.environmentId, command.user.organizationId ); const redeploySteps = resolvedManifestSteps.filter((s) => s.existingStepResolverHash); const newSteps = resolvedManifestSteps.filter((s) => !s.existingStepResolverHash); const stepsToActivate = availableSlots >= UNLIMITED_VALUE ? newSteps : newSteps.slice(0, availableSlots); const skippedNewSteps = availableSlots >= UNLIMITED_VALUE ? [] : newSteps.slice(availableSlots); const stepsToProcess = [...redeploySteps, ...stepsToActivate]; const skippedSteps: SkippedStepDto[] = skippedNewSteps.map((step) => ({ workflowId: step.workflowId, stepId: step.stepId, reason: 'Code steps limit reached. Upgrade your plan to deploy more code steps.', })); const stepResolverHash = this.generateStepResolverHash(command.bundleBuffer); const workerId = generateStepResolverWorkerId(command.user.organizationId, stepResolverHash); this.logger.info( { workerId, stepResolverHash, deployedStepsCount: stepsToProcess.length, skippedStepsCount: skippedSteps.length, bundleSizeBytes: command.bundleBuffer.byteLength, userId: command.user._id, organizationId: command.user.organizationId, environmentId: command.user.environmentId, }, 'Deploying step resolver release' ); await this.cloudflareStepResolverDeployService.deploy({ workerId, organizationId: command.user.organizationId, stepResolverHash, bundleBuffer: command.bundleBuffer, }); await this.controlValuesRepository.withTransaction(async (session) => { await this.writeHashToMessageTemplates(command, stepsToProcess, stepResolverHash, session); await this.upsertControlValues(command, stepsToProcess, session); await this.updateStepControlSchemas(command, stepsToProcess, session); }); await this.recalculateAndPersistStepIssues(command, stepsToProcess); this.analyticsService.mixpanelTrack('Step resolver deployed - [Step Resolvers]', command.user._id, { deployedStepsCount: stepsToProcess.length, stepTypes: stepsToProcess.map((s) => s.stepType), _organization: command.user.organizationId, _environment: command.user.environmentId, workerId, }); return { stepResolverHash, workerId, deployedStepsCount: stepsToProcess.length, skippedSteps, deployedAt: new Date().toISOString(), }; } private async resolveManifestSteps( command: DeployStepResolverCommand, manifestSteps: DeployStepResolverManifestStepCommand[], flags: { isStepResolverEnabled: boolean; isActionStepResolverEnabled: boolean } ): Promise { const workflowCache = new Map>>(); const partialSteps: Omit[] = []; for (const manifestStep of manifestSteps) { let workflow = workflowCache.get(manifestStep.workflowId); if (!workflow) { workflow = await this.getWorkflowByIdsUseCase.execute( GetWorkflowByIdsCommand.create({ workflowIdOrInternalId: manifestStep.workflowId, environmentId: command.user.environmentId, organizationId: command.user.organizationId, userId: command.user._id, }) ); workflowCache.set(manifestStep.workflowId, workflow); } const step = workflow.steps.find((workflowStep) => workflowStep.stepId === manifestStep.stepId); if (!step || !step._templateId) { throw new BadRequestException({ message: 'Step cannot be found in workflow', workflowId: manifestStep.workflowId, stepId: manifestStep.stepId, }); } const actualStepType = step.template?.type; if (!actualStepType || !isStepResolverSupportedType(actualStepType)) { throw new BadRequestException({ message: `Step type '${actualStepType ?? 'unknown'}' is not supported for step resolvers. Trigger steps cannot use step resolvers.`, workflowId: manifestStep.workflowId, stepId: manifestStep.stepId, }); } const isActionStep = ACTION_STEP_TYPES.has(actualStepType); const isFlagEnabled = isActionStep ? flags.isActionStepResolverEnabled : flags.isStepResolverEnabled; if (!isFlagEnabled) { throw new ForbiddenException( `Step resolver feature is not enabled for step type '${actualStepType}' in this organization` ); } if (actualStepType !== manifestStep.stepType) { throw new BadRequestException({ message: `Manifest stepType '${manifestStep.stepType}' does not match the actual step type '${actualStepType}'`, workflowId: manifestStep.workflowId, stepId: manifestStep.stepId, }); } partialSteps.push({ workflowId: manifestStep.workflowId, workflowInternalId: String(workflow._id), stepId: manifestStep.stepId, stepInternalId: String(step._templateId), stepType: actualStepType, controlSchema: getStepResolverControlSchema(manifestStep.controlSchema), existingStepResolverHash: step.template?.stepResolverHash ?? undefined, }); } const existingControlValuesResults = await Promise.all( partialSteps.map((step) => this.controlValuesRepository.findOne({ _environmentId: command.user.environmentId, _organizationId: command.user.organizationId, _workflowId: step.workflowInternalId, _stepId: step.stepInternalId, level: ControlValuesLevelEnum.STEP_CONTROLS, }) ) ); return partialSteps.map((step, index) => ({ ...step, existingControlValues: existingControlValuesResults[index], })); } private async writeHashToMessageTemplates( command: DeployStepResolverCommand, resolvedSteps: ResolvedManifestStep[], stepResolverHash: string, session: ClientSession | null ): Promise { for (const step of resolvedSteps) { // transactions can't be called in Promise.all, so we need to call it sequentially await this.messageTemplateRepository.update( { _id: step.stepInternalId, _environmentId: command.user.environmentId }, { $set: { stepResolverHash } }, { session } ); } } private async upsertControlValues( command: DeployStepResolverCommand, resolvedSteps: ResolvedManifestStep[], session: ClientSession | null ): Promise { for (const step of resolvedSteps) { const mergedControls = reconcileStepResolverControlValues( this.readControlObject(step.existingControlValues), step.controlSchema ); if (step.existingControlValues) { await this.controlValuesRepository.update( { _id: step.existingControlValues._id, _organizationId: command.user.organizationId, }, { priority: 0, controls: mergedControls, }, { session } ); } else { await this.controlValuesRepository.create( { _organizationId: command.user.organizationId, _environmentId: command.user.environmentId, _workflowId: step.workflowInternalId, _stepId: step.stepInternalId, level: ControlValuesLevelEnum.STEP_CONTROLS, priority: 0, controls: mergedControls, }, { session } ); } } } private async updateStepControlSchemas( command: DeployStepResolverCommand, resolvedSteps: ResolvedManifestStep[], session: ClientSession | null ): Promise { for (const step of resolvedSteps) { await this.messageTemplateRepository.update( { _id: step.stepInternalId, _environmentId: command.user.environmentId }, { $set: { 'controls.schema': step.controlSchema }, $unset: { 'controls.uiSchema': 1 } }, { session } ); } } private async recalculateAndPersistStepIssues( command: DeployStepResolverCommand, resolvedSteps: ResolvedManifestStep[] ): Promise { const workflowInternalIds = [...new Set(resolvedSteps.map((s) => s.workflowInternalId))]; for (const workflowInternalId of workflowInternalIds) { const workflow = await this.getWorkflowByIdsUseCase.execute( GetWorkflowByIdsCommand.create({ workflowIdOrInternalId: workflowInternalId, environmentId: command.user.environmentId, organizationId: command.user.organizationId, userId: command.user._id, }) ); for (const step of resolvedSteps.filter((s) => s.workflowInternalId === workflowInternalId)) { const workflowStep = workflow.steps.find((s) => s._templateId === step.stepInternalId); if (!workflowStep?._templateId || !workflowStep.template?.type || !workflow.origin) continue; const issues = await this.buildStepIssuesUsecase.execute({ workflowOrigin: workflow.origin, user: command.user, stepInternalId: workflowStep._templateId, workflow, controlSchema: workflowStep.template.controls?.schema ?? step.controlSchema, stepType: workflowStep.template.type, }); await this.notificationTemplateRepository.update( { _id: workflowInternalId, _environmentId: command.user.environmentId, 'steps._templateId': step.stepInternalId, }, { $set: { 'steps.$.issues': issues } } ); } } } private readControlObject(controlValues: ControlValuesEntity | null): Record { if (!controlValues || !isPlainObject(controlValues.controls)) { return {}; } return controlValues.controls; } private generateStepResolverHash(bundleBuffer: Buffer): string { const digest = createHash('sha256').update(bundleBuffer).digest(); const readableToken = this.encodeBase32(digest).slice(0, STEP_RESOLVER_HASH_LENGTH); return `${readableToken.slice(0, 5)}-${readableToken.slice(5, 10)}`; } private encodeBase32(bytes: Uint8Array): string { let output = ''; let bitBuffer = 0; let bitCount = 0; for (const byte of bytes) { bitBuffer = (bitBuffer << 8) | byte; bitCount += 8; while (bitCount >= 5) { bitCount -= 5; output += STEP_RESOLVER_HASH_ALPHABET[(bitBuffer >> bitCount) & 0x1f]; } } if (bitCount > 0) { output += STEP_RESOLVER_HASH_ALPHABET[(bitBuffer << (5 - bitCount)) & 0x1f]; } return output; } private assertBundleSize(bundleBuffer: Buffer): void { if (bundleBuffer.byteLength <= MAX_BUNDLE_SIZE_BYTES) { return; } throw new BadRequestException( `Bundle too large (${(bundleBuffer.byteLength / 1024 / 1024).toFixed(2)} MB). Maximum allowed size is ${ MAX_BUNDLE_SIZE_BYTES / 1024 / 1024 } MB.` ); } } function isPlainObject(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); } ================================================ FILE: apps/api/src/app/step-resolvers/usecases/deploy-step-resolver/index.ts ================================================ export * from './deploy-step-resolver.command'; export * from './deploy-step-resolver.usecase'; ================================================ FILE: apps/api/src/app/step-resolvers/usecases/get-step-resolvers-count/get-step-resolvers-count.usecase.ts ================================================ import { Injectable } from '@nestjs/common'; import { InstrumentUsecase } from '@novu/application-generic'; import { MessageTemplateRepository } from '@novu/dal'; @Injectable() export class GetStepResolversCountUsecase { constructor(private messageTemplateRepository: MessageTemplateRepository) {} @InstrumentUsecase() async execute(environmentId: string): Promise<{ count: number }> { const count = await this.messageTemplateRepository.count({ _environmentId: environmentId, stepResolverHash: { $exists: true, $nin: [null, ''] }, }); return { count }; } } ================================================ FILE: apps/api/src/app/step-resolvers/usecases/get-step-resolvers-count/index.ts ================================================ export * from './get-step-resolvers-count.usecase'; ================================================ FILE: apps/api/src/app/step-resolvers/usecases/sync-step-resolver-to-environment/index.ts ================================================ export * from './sync-step-resolver-to-environment.command'; export * from './sync-step-resolver-to-environment.usecase'; ================================================ FILE: apps/api/src/app/step-resolvers/usecases/sync-step-resolver-to-environment/sync-step-resolver-to-environment.command.ts ================================================ import { EnvironmentWithUserObjectCommand } from '@novu/application-generic'; import { ClientSession } from '@novu/dal'; import { StepTypeEnum } from '@novu/shared'; import { Exclude, Type } from 'class-transformer'; import { IsArray, IsDefined, IsEnum, IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator'; export class StepResolverSourceData { @IsString() @IsNotEmpty() stepId: string; @IsEnum(StepTypeEnum) stepType: StepTypeEnum; @IsOptional() @IsString() stepResolverHash?: string; @IsOptional() controlSchema?: Record | null; } export class StepResolverTargetData { @IsString() @IsNotEmpty() stepId: string; @IsString() @IsNotEmpty() templateId: string; @IsOptional() @IsString() stepResolverHash?: string; } export class SyncStepResolverToEnvironmentCommand extends EnvironmentWithUserObjectCommand { @IsString() @IsDefined() targetEnvironmentId: string; @IsArray() @ValidateNested({ each: true }) @Type(() => StepResolverSourceData) sourceSteps: StepResolverSourceData[]; @IsArray() @ValidateNested({ each: true }) @Type(() => StepResolverTargetData) targetSteps: StepResolverTargetData[]; /** * Exclude session from the command to avoid serializing it in the response */ @IsOptional() @Exclude() session?: ClientSession | null; } ================================================ FILE: apps/api/src/app/step-resolvers/usecases/sync-step-resolver-to-environment/sync-step-resolver-to-environment.usecase.ts ================================================ import { Injectable } from '@nestjs/common'; import { getStepResolverControlSchema, InstrumentUsecase, isStepResolverSupportedType, ResourceValidatorService, stepTypeToControlSchema, } from '@novu/application-generic'; import { ClientSession, MessageTemplateRepository } from '@novu/dal'; import { StepResolverSourceData, StepResolverTargetData, SyncStepResolverToEnvironmentCommand, } from './sync-step-resolver-to-environment.command'; @Injectable() export class SyncStepResolverToEnvironmentUsecase { constructor( private messageTemplateRepository: MessageTemplateRepository, private resourceValidatorService: ResourceValidatorService ) {} @InstrumentUsecase() async execute(command: SyncStepResolverToEnvironmentCommand): Promise { const newResolverStepsOnTarget = this.countNewResolverAssignments(command); await this.resourceValidatorService.validateStepResolversLimit( command.targetEnvironmentId, command.user.organizationId, newResolverStepsOnTarget ); const targetStepsByStepId = new Map(command.targetSteps.map((step) => [step.stepId, step])); const relevantSteps = command.sourceSteps.filter((sourceStep) => { const targetStep = targetStepsByStepId.get(sourceStep.stepId); if (!targetStep) { return false; } if (!isStepResolverSupportedType(sourceStep.stepType)) { return false; } return sourceStep.stepResolverHash != null || targetStep.stepResolverHash != null; }); if (command.session) { for (const sourceStep of relevantSteps) { const targetStep = targetStepsByStepId.get(sourceStep.stepId); if (!targetStep) { continue; } if (sourceStep.stepResolverHash != null) { await this.promoteStepResolver(targetStep, command.targetEnvironmentId, sourceStep, command.session); } else { await this.clearStepResolver(targetStep, command.targetEnvironmentId, sourceStep, command.session); } } return; } await Promise.all( relevantSteps.map((sourceStep) => { const targetStep = targetStepsByStepId.get(sourceStep.stepId); if (!targetStep) { return Promise.resolve(); } return sourceStep.stepResolverHash != null ? this.promoteStepResolver(targetStep, command.targetEnvironmentId, sourceStep) : this.clearStepResolver(targetStep, command.targetEnvironmentId, sourceStep); }) ); } private countNewResolverAssignments(command: SyncStepResolverToEnvironmentCommand): number { const targetStepsByStepId = new Map(command.targetSteps.map((step) => [step.stepId, step])); let count = 0; for (const sourceStep of command.sourceSteps) { if (sourceStep.stepResolverHash == null || sourceStep.stepResolverHash === '') { continue; } const targetStep = targetStepsByStepId.get(sourceStep.stepId); if (!targetStep) { continue; } const targetHasResolver = targetStep.stepResolverHash != null && targetStep.stepResolverHash !== ''; if (!targetHasResolver) { count += 1; } } return count; } private async promoteStepResolver( targetStep: StepResolverTargetData, targetEnvironmentId: string, sourceStep: StepResolverSourceData, session?: ClientSession | null ): Promise { await this.messageTemplateRepository.update( { _id: targetStep.templateId, _environmentId: targetEnvironmentId }, { $set: { stepResolverHash: sourceStep.stepResolverHash, 'controls.schema': getStepResolverControlSchema(sourceStep.controlSchema), }, $unset: { 'controls.uiSchema': 1 }, }, { session } ); } private async clearStepResolver( targetStep: StepResolverTargetData, targetEnvironmentId: string, sourceStep: StepResolverSourceData, session?: ClientSession | null ): Promise { const controlSchema = sourceStep.controlSchema ?? stepTypeToControlSchema[sourceStep.stepType]?.schema; await this.messageTemplateRepository.update( { _id: targetStep.templateId, _environmentId: targetEnvironmentId }, { $unset: { stepResolverHash: 1, }, $set: { 'controls.schema': controlSchema, 'controls.uiSchema': stepTypeToControlSchema[sourceStep.stepType]?.uiSchema, }, }, { session } ); } } ================================================ FILE: apps/api/src/app/step-resolvers/utils/generate-step-resolver-worker-id.ts ================================================ export function generateStepResolverWorkerId(organizationId: string, stepResolverHash: string): string { return `sr-${organizationId}-${stepResolverHash}`; } ================================================ FILE: apps/api/src/app/storage/dtos/upload-url-response.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; export class UploadUrlResponse { @ApiProperty() signedUrl: string; @ApiProperty() path: string; } ================================================ FILE: apps/api/src/app/storage/e2e/get-signed-url.e2e.ts ================================================ import { UserSession } from '@novu/testing'; import { expect } from 'chai'; describe('Get Signed Url - /storage/upload-url (GET) #novu-v0', () => { let session: UserSession; beforeEach(async () => { session = new UserSession(); await session.initialize(); }); it('should return an S3 signed URL', async () => { const { body: { data }, } = await session.testAgent.get('/v1/storage/upload-url?extension=jpg'); expect(data.path).to.contain('.jpg'); expect(data.signedUrl).to.contain('.jpg'); expect(data.signedUrl).to.contain(`${session.organization._id}/${session.environment._id}`); }); }); ================================================ FILE: apps/api/src/app/storage/storage.controller.ts ================================================ import { ClassSerializerInterceptor, Controller, Get, Query, UseInterceptors } from '@nestjs/common'; import { ApiExcludeController, ApiOperation, ApiTags } from '@nestjs/swagger'; import { UploadTypesEnum, UserSessionData } from '@novu/shared'; import { RequireAuthentication } from '../auth/framework/auth.decorator'; import { ExternalApiAccessible } from '../auth/framework/external-api.decorator'; import { ApiCommonResponses, ApiResponse } from '../shared/framework/response.decorator'; import { UserSession } from '../shared/framework/user.decorator'; import { UploadUrlResponse } from './dtos/upload-url-response.dto'; import { GetSignedUrlCommand } from './usecases/get-signed-url/get-signed-url.command'; import { GetSignedUrl } from './usecases/get-signed-url/get-signed-url.usecase'; @ApiCommonResponses() @Controller('/storage') @ApiTags('Storage') @UseInterceptors(ClassSerializerInterceptor) @RequireAuthentication() @ApiExcludeController() export class StorageController { constructor(private getSignedUrlUsecase: GetSignedUrl) {} @Get('/upload-url') @ApiOperation({ summary: 'Get upload url', }) @ApiResponse(UploadUrlResponse) @ExternalApiAccessible() async signedUrl( @UserSession() user: UserSessionData, @Query('extension') extension: string, @Query('type') type: string ): Promise { return await this.getSignedUrlUsecase.execute( GetSignedUrlCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, userId: user._id, extension, type: (type as UploadTypesEnum) || UploadTypesEnum.BRANDING, }) ); } } ================================================ FILE: apps/api/src/app/storage/storage.module.ts ================================================ import { Module } from '@nestjs/common'; import { SharedModule } from '../shared/shared.module'; import { StorageController } from './storage.controller'; import { USE_CASES } from './usecases'; @Module({ imports: [SharedModule], providers: [...USE_CASES], controllers: [StorageController], }) export class StorageModule {} ================================================ FILE: apps/api/src/app/storage/usecases/get-signed-url/get-signed-url.command.ts ================================================ import { UploadTypesEnum } from '@novu/shared'; import { IsDefined, IsEnum, IsIn, IsString } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; export class GetSignedUrlCommand extends EnvironmentWithUserCommand { @IsString() @IsIn(['jpg', 'png', 'jpeg']) extension: string; @IsDefined() @IsEnum(UploadTypesEnum) type: UploadTypesEnum; } ================================================ FILE: apps/api/src/app/storage/usecases/get-signed-url/get-signed-url.usecase.ts ================================================ import { Injectable } from '@nestjs/common'; import { StorageService } from '@novu/application-generic'; import { FILE_EXTENSION_TO_MIME_TYPE, UploadTypesEnum } from '@novu/shared'; import { randomBytes } from 'crypto'; import { UploadUrlResponse } from '../../dtos/upload-url-response.dto'; import { GetSignedUrlCommand } from './get-signed-url.command'; @Injectable() export class GetSignedUrl { constructor(private storageService: StorageService) {} private mapTypeToPath(command: GetSignedUrlCommand) { const randomId = randomBytes(16).toString('hex'); switch (command.type) { case UploadTypesEnum.USER_PROFILE: return `users/${command.userId}/profile-pictures/${randomId}.${command.extension}`; case UploadTypesEnum.BRANDING: default: return `${command.organizationId}/${command.environmentId}/${randomId}.${command.extension}`; } } async execute(command: GetSignedUrlCommand): Promise { const response = await this.storageService.getSignedUrl( this.mapTypeToPath(command), FILE_EXTENSION_TO_MIME_TYPE[command.extension] ); return response; } } ================================================ FILE: apps/api/src/app/storage/usecases/index.ts ================================================ import { GetSignedUrl } from './get-signed-url/get-signed-url.usecase'; export const USE_CASES = [GetSignedUrl]; ================================================ FILE: apps/api/src/app/subscribers/dtos/bulk-create-subscriber-response.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class FailedOperationDto { @ApiPropertyOptional({ description: 'The error message associated with the failed operation.', }) message: string; @ApiPropertyOptional({ description: 'The subscriber ID associated with the failed operation. This field is optional.', required: false, }) subscriberId?: string; } export class UpdatedSubscriberDto { @ApiProperty({ description: 'The ID of the subscriber that was updated.', }) subscriberId: string; } export class CreatedSubscriberDto { @ApiProperty({ description: 'The ID of the subscriber that was created.', }) subscriberId: string; } export class BulkCreateSubscriberResponseDto { @ApiProperty({ description: 'An array of subscribers that were successfully updated.', type: [UpdatedSubscriberDto], }) updated: UpdatedSubscriberDto[]; @ApiProperty({ description: 'An array of subscribers that were successfully created.', type: [CreatedSubscriberDto], }) created: CreatedSubscriberDto[]; @ApiProperty({ description: 'An array of failed operations with error messages and optional subscriber IDs.', type: [FailedOperationDto], }) failed: FailedOperationDto[]; } ================================================ FILE: apps/api/src/app/subscribers/dtos/chat-oauth-request.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { IsOptional, IsString } from 'class-validator'; export class ChatOauthRequestDto { @ApiProperty({ description: 'HMAC hash for the request', type: String, }) hmacHash: string; @ApiProperty({ description: 'The ID of the environment, must be a valid MongoDB ID', type: String, required: true, }) environmentId: string; @ApiProperty({ description: 'Optional integration identifier', type: String, required: false, }) @IsOptional() @IsString() integrationIdentifier?: string; } export class ChatOauthCallbackRequestDto extends ChatOauthRequestDto { @ApiProperty({ description: 'Optional authorization code returned from the OAuth provider', type: String, required: true, }) @IsString() code: string; // Make sure to define code as optional } ================================================ FILE: apps/api/src/app/subscribers/dtos/create-subscriber-request.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ChatProviderIdEnum, IChannelCredentials, PushProviderIdEnum } from '@novu/shared'; import { Type } from 'class-transformer'; import { ArrayNotEmpty, IsArray, IsDefined, IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator'; import { BaseSubscriberFieldsDto } from '../../shared/dtos/base-subscriber-fields.dto'; export class ChannelCredentialsDto implements IChannelCredentials { @ApiPropertyOptional({ description: 'The URL for the webhook associated with the channel.', type: String, }) @IsOptional() @IsString() webhookUrl?: string; @ApiPropertyOptional({ description: 'An array of device tokens for push notifications.', type: [String], }) @IsOptional() @IsArray() deviceTokens?: string[]; } export class SubscriberChannelDto { @ApiProperty({ description: 'The ID of the chat or push provider.', enum: [...Object.values(ChatProviderIdEnum), ...Object.values(PushProviderIdEnum)], }) providerId: ChatProviderIdEnum | PushProviderIdEnum; @ApiPropertyOptional({ description: 'An optional identifier for the integration.', type: String, }) @IsOptional() integrationIdentifier?: string; @ApiProperty({ description: 'Credentials for the channel.', type: ChannelCredentialsDto, }) @ValidateNested() @Type(() => ChannelCredentialsDto) credentials: ChannelCredentialsDto; } export class CreateSubscriberRequestDto extends BaseSubscriberFieldsDto { @ApiProperty({ description: 'The internal identifier you used to create this subscriber, usually correlates to the id the user in your systems', }) @IsString() @IsDefined() @IsNotEmpty({ message: 'SubscriberId is required', }) subscriberId: string; @ApiPropertyOptional({ type: [SubscriberChannelDto], description: 'An optional array of subscriber channels.', }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => SubscriberChannelDto) channels?: SubscriberChannelDto[]; } export class BulkSubscriberCreateDto { @ApiProperty({ description: 'An array of subscribers to be created in bulk.', type: [CreateSubscriberRequestDto], // Specify the type of the array elements }) @IsArray() @ArrayNotEmpty() @ValidateNested({ each: true }) @Type(() => CreateSubscriberRequestDto) subscribers: CreateSubscriberRequestDto[]; } ================================================ FILE: apps/api/src/app/subscribers/dtos/delete-subscriber-response.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { IsBoolean, IsDefined, IsString } from 'class-validator'; export class DeleteSubscriberResponseDto { @ApiProperty({ description: 'A boolean stating the success of the action', }) @IsBoolean() @IsDefined() acknowledged: boolean; @ApiProperty({ description: 'The status enum for the performed action', enum: ['deleted'], }) @IsString() @IsDefined() status: string; } ================================================ FILE: apps/api/src/app/subscribers/dtos/get-in-app-notification-feed-for-subscriber.dto.ts ================================================ import { ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { PaginationRequestDto } from '../../shared/dtos/pagination-request'; const LIMIT = { DEFAULT: 10, MAX: 100, }; function transformOptionalBoolean({ value }: { value: unknown }): boolean | undefined { if (typeof value === 'string') return value === 'true'; if (typeof value === 'boolean') return value; return undefined; } export class GetInAppNotificationsFeedForSubscriberDto extends PaginationRequestDto(LIMIT.DEFAULT, LIMIT.MAX) { @ApiPropertyOptional({ required: false, oneOf: [ { type: 'string' }, { type: 'array', items: { type: 'string', }, }, ], }) feedIdentifier: string | string[]; @ApiPropertyOptional({ required: false, type: Boolean }) @Transform(transformOptionalBoolean) read: boolean; @ApiPropertyOptional({ required: false, type: Boolean }) @Transform(transformOptionalBoolean) seen: boolean; @ApiPropertyOptional({ required: false, type: 'string', description: 'Base64 encoded string of the partial payload JSON object', example: 'btoa(JSON.stringify({ foo: 123 })) results in base64 encoded string like eyJmb28iOjEyM30=', }) payload?: string; } ================================================ FILE: apps/api/src/app/subscribers/dtos/get-subscriber-preferences-response.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { SubscriberPreferenceDto } from './subscriber-preference.dto'; import { SubscriberPreferenceTemplateResponseDto } from './subscriber-preference-template-response.dto'; export class GetSubscriberPreferencesResponseDto { @ApiPropertyOptional({ type: SubscriberPreferenceTemplateResponseDto, description: 'The workflow information and if it is critical or not', }) template?: SubscriberPreferenceTemplateResponseDto; @ApiProperty({ type: SubscriberPreferenceDto, description: 'The preferences of the subscriber regarding the related workflow', }) preference: SubscriberPreferenceDto; } ================================================ FILE: apps/api/src/app/subscribers/dtos/get-subscribers.dto.ts ================================================ import { PaginationRequestDto } from '../../shared/dtos/pagination-request'; const LIMIT = { DEFAULT: 10, MAX: 100, }; export class GetSubscribersDto extends PaginationRequestDto(LIMIT.DEFAULT, LIMIT.MAX) {} ================================================ FILE: apps/api/src/app/subscribers/dtos/index.ts ================================================ export * from './create-subscriber-request.dto'; export * from './delete-subscriber-response.dto'; export * from './get-subscriber-preferences-response.dto'; export * from './subscriber-feed-response.dto'; export * from './subscriber-preference.dto'; export * from './subscriber-preference-override.dto'; export * from './subscriber-preference-template-response.dto'; export * from './subscribers-response.dto'; export * from './update-subscriber-global-preferences-request.dto'; export * from './update-subscriber-request.dto'; ================================================ FILE: apps/api/src/app/subscribers/dtos/mark-all-messages-as-request.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { MessagesStatusEnum } from '@novu/shared'; export class MarkAllMessageAsRequestDto { @ApiPropertyOptional({ oneOf: [ { type: 'string' }, { type: 'array', items: { type: 'string', }, }, ], description: 'Optional feed identifier or array of feed identifiers', }) feedIdentifier?: string | string[]; @ApiProperty({ enum: MessagesStatusEnum, description: 'Mark all subscriber messages as read, unread, seen or unseen', }) markAs: MessagesStatusEnum; } ================================================ FILE: apps/api/src/app/subscribers/dtos/subscriber-feed-response.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class SubscriberFeedResponseDto { @ApiPropertyOptional({ description: 'The internal ID generated by Novu for your subscriber. ' + 'This ID does not match the `subscriberId` used in your queries. Refer to `subscriberId` for that identifier.', type: String, }) _id?: string; @ApiProperty({ description: 'The first name of the subscriber.', type: String, }) firstName?: string; @ApiProperty({ description: 'The last name of the subscriber.', type: String, }) lastName?: string; @ApiPropertyOptional({ description: "The URL of the subscriber's avatar image.", type: String, }) avatar?: string; @ApiProperty({ description: 'The identifier used to create this subscriber, which typically corresponds to the user ID in your system.', type: String, }) subscriberId: string; } ================================================ FILE: apps/api/src/app/subscribers/dtos/subscriber-preference-override.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { ChannelTypeEnum, IPreferenceOverride, PreferenceOverrideSourceEnum } from '@novu/shared'; export class SubscriberPreferenceOverrideDto implements IPreferenceOverride { @ApiProperty({ enum: [...Object.values(ChannelTypeEnum)], enumName: 'ChannelTypeEnum', description: 'The channel type which is overridden', }) channel: ChannelTypeEnum; @ApiProperty({ enum: [...Object.values(PreferenceOverrideSourceEnum)], enumName: 'PreferenceOverrideSourceEnum', description: 'The source of overrides', }) source: PreferenceOverrideSourceEnum; } ================================================ FILE: apps/api/src/app/subscribers/dtos/subscriber-preference-template-response.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; export class SubscriberPreferenceTemplateResponseDto { @ApiProperty({ description: 'Unique identifier of the workflow', type: String, }) _id: string; @ApiProperty({ description: 'Name of the workflow', type: String, }) name: string; @ApiProperty({ description: 'Critical templates will always be delivered to the end user and should be hidden from the subscriber preferences screen', type: Boolean, }) critical: boolean; } ================================================ FILE: apps/api/src/app/subscribers/dtos/subscriber-preference.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { SubscriberPreferenceChannels } from '../../shared/dtos/preference-channels'; import { SubscriberPreferenceOverrideDto } from './subscriber-preference-override.dto'; export class SubscriberPreferenceDto { @ApiProperty({ description: 'Sets if the workflow is fully enabled for all channels or not for the subscriber.', type: Boolean, }) enabled: boolean; @ApiProperty({ type: SubscriberPreferenceChannels, description: 'Subscriber preferences for the different channels regarding this workflow', }) channels: SubscriberPreferenceChannels; @ApiPropertyOptional({ type: [SubscriberPreferenceOverrideDto], description: 'Overrides for subscriber preferences for the different channels regarding this workflow', }) overrides?: SubscriberPreferenceOverrideDto[]; } ================================================ FILE: apps/api/src/app/subscribers/dtos/subscribers-response.dto.ts ================================================ import { SubscriberResponseDto } from '@novu/application-generic'; import { PaginatedResponseDto } from '../../shared/dtos/pagination-response'; export class SubscribersResponseDto extends PaginatedResponseDto {} ================================================ FILE: apps/api/src/app/subscribers/dtos/update-subscriber-global-preferences-request.dto.ts ================================================ import { ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsBoolean, IsOptional, ValidateNested } from 'class-validator'; import { ChannelPreference } from '../../shared/dtos/channel-preference'; export class UpdateSubscriberGlobalPreferencesRequestDto { @ApiPropertyOptional({ description: 'Enable or disable the subscriber global preferences.', type: Boolean, }) @IsBoolean() @IsOptional() enabled?: boolean; @ApiPropertyOptional({ type: [ChannelPreference], description: 'The subscriber global preferences for every ChannelTypeEnum.', }) @IsOptional() @ValidateNested() @Type(() => ChannelPreference) preferences?: ChannelPreference[]; } ================================================ FILE: apps/api/src/app/subscribers/dtos/update-subscriber-online-flag-request.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { IsBoolean, IsDefined } from 'class-validator'; export class UpdateSubscriberOnlineFlagRequestDto { @ApiProperty() @IsDefined() @IsBoolean() isOnline: boolean; } ================================================ FILE: apps/api/src/app/subscribers/dtos/update-subscriber-request.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { IsArray, IsOptional } from 'class-validator'; import { BaseSubscriberFieldsDto } from '../../shared/dtos/base-subscriber-fields.dto'; import { SubscriberChannelDto } from './create-subscriber-request.dto'; export class UpdateSubscriberRequestDto extends BaseSubscriberFieldsDto { @ApiProperty({ description: 'An array of communication channels for the subscriber.', type: SubscriberChannelDto, isArray: true, required: false, }) @IsOptional() @IsArray() channels?: SubscriberChannelDto[]; } ================================================ FILE: apps/api/src/app/subscribers/e2e/bulk-create-subscribers.e2e.ts ================================================ import { Novu } from '@novu/api'; import { SubscriberEntity, SubscriberRepository } from '@novu/dal'; import { SubscribersService, UserSession } from '@novu/testing'; import { expect } from 'chai'; import { expectSdkValidationExceptionGeneric, initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('Bulk create subscribers - /v1/subscribers/bulk (POST) #novu-v2', () => { let session: UserSession; let subscriber: SubscriberEntity; let subscriberService: SubscribersService; const subscriberRepository = new SubscriberRepository(); let novuClient: Novu; beforeEach(async () => { session = new UserSession(); await session.initialize(); subscriberService = new SubscribersService(session.organization._id, session.environment._id); subscriber = await subscriberService.createSubscriber(); novuClient = initNovuClassSdk(session); }); it('should return the response array in correct format', async () => { const bulkResult = await novuClient.subscribers.createBulk({ subscribers: [ { subscriberId: 'test1', firstName: 'sub1', email: 'sub1@test.co', }, { subscriberId: 'test2', firstName: 'sub2', email: 'sub2@test.co', }, { subscriberId: subscriber.subscriberId, firstName: 'update name' }, { subscriberId: 'test2', firstName: 'update name' }, ], }); expect(bulkResult.result).to.be.ok; const { updated, created, failed } = bulkResult.result; expect(updated?.length).to.equal(2); expect(updated[0].subscriberId).to.equal(subscriber.subscriberId); expect(updated[1].subscriberId).to.equal('test2'); expect(created?.length).to.equal(2); expect(created[0].subscriberId).to.equal('test1'); expect(created[1].subscriberId).to.equal('test2'); expect(failed?.length).to.equal(0); }); it('should create and update subscribers', async () => { const res = await novuClient.subscribers.createBulk({ subscribers: [ { subscriberId: 'sub1', firstName: 'John', lastName: 'Doe', email: 'john@doe.com', phone: '+972523333333', locale: 'en', data: { test1: 'test value1', test2: 'test value2' }, }, { subscriberId: 'test2', firstName: 'sub2', email: 'sub2@test.co', }, { subscriberId: 'test3', firstName: 'sub3', email: 'sub3@test.co', }, { subscriberId: subscriber.subscriberId, firstName: 'update' }, { subscriberId: 'test4', firstName: 'sub4', email: 'sub4@test.co', }, ], }); expect(res.result).to.be.ok; const createdSubscriber = await subscriberRepository.findBySubscriberId(session.environment._id, 'sub1'); const updatedSubscriber = await subscriberRepository.findBySubscriberId( session.environment._id, subscriber.subscriberId ); expect(updatedSubscriber?.firstName).to.equal('update'); expect(createdSubscriber?.firstName).to.equal('John'); expect(createdSubscriber?.email).to.equal('john@doe.com'); expect(createdSubscriber?.phone).to.equal('+972523333333'); expect(createdSubscriber?.locale).to.equal('en'); expect(createdSubscriber?.data?.test1).to.equal('test value1'); }); it('should throw an error when sending more than 500 subscribers', async () => { const payload = { subscriberId: 'test2', firstName: 'sub2', email: 'sub2@test.co', }; const { error } = await expectSdkValidationExceptionGeneric(() => novuClient.subscribers.createBulk({ subscribers: Array.from({ length: 501 }, () => payload), }) ); expect(error?.statusCode, JSON.stringify(error)).to.equal(422); expect(error?.errors.subscribers.messages[0], JSON.stringify(error)).to.equal( 'subscribers must contain no more than 500 elements' ); }); it('should recreate deleted subscribers', async () => { const existingSubscriber = { subscriberId: subscriber.subscriberId, firstName: 'existingSubscriber' }; const newSubscriber1 = { subscriberId: 'test1', firstName: 'sub1', email: 'sub1@test.co', }; const newSubscriber2 = { subscriberId: 'test2', firstName: 'sub2', email: 'sub2@test.co', }; let bulkResponse = await novuClient.subscribers.createBulk({ subscribers: [existingSubscriber, newSubscriber1, newSubscriber2], }); const { result } = bulkResponse; expect(result.created?.length).to.equal(2); expect(result.updated?.length).to.equal(1); expect(result.created[0].subscriberId).to.equal(newSubscriber1.subscriberId); expect(result.created[1].subscriberId).to.equal(newSubscriber2.subscriberId); expect(result.updated[0].subscriberId).to.equal(existingSubscriber.subscriberId); await novuClient.subscribers.delete(newSubscriber1.subscriberId); await novuClient.subscribers.delete(newSubscriber2.subscriberId); bulkResponse = await novuClient.subscribers.createBulk({ subscribers: [existingSubscriber, newSubscriber1, newSubscriber2], }); const secondResponseData = bulkResponse.result; expect(secondResponseData.created?.length).to.equal(2); expect(secondResponseData.updated?.length).to.equal(1); expect(secondResponseData.created[0].subscriberId).to.equal(newSubscriber1.subscriberId); expect(secondResponseData.created[1].subscriberId).to.equal(newSubscriber2.subscriberId); expect(secondResponseData.updated[0].subscriberId).to.equal(existingSubscriber.subscriberId); }); }); ================================================ FILE: apps/api/src/app/subscribers/e2e/create-subscriber.e2e.ts ================================================ import { Novu } from '@novu/api'; import { SubscriberRepository } from '@novu/dal'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('Create Subscriber - /subscribers (POST) #novu-v2', () => { let session: UserSession; const subscriberRepository = new SubscriberRepository(); let novuClient: Novu; beforeEach(async () => { session = new UserSession(); await session.initialize(); novuClient = initNovuClassSdk(session); }); it('should create a new subscriber', async () => { const response = await novuClient.subscribers.create({ subscriberId: '123', firstName: 'John', lastName: 'Doe', email: 'john@doe.com', phone: '+972523333333', locale: 'en', data: { test1: 'test value1', test2: 'test value2' }, }); const body = response.result; expect(body).to.be.ok; const createdSubscriber = await subscriberRepository.findBySubscriberId(session.environment._id, '123'); expect(createdSubscriber?.firstName).to.equal('John'); expect(createdSubscriber?.email).to.equal('john@doe.com'); expect(createdSubscriber?.phone).to.equal('+972523333333'); expect(createdSubscriber?.locale).to.equal('en'); expect(createdSubscriber?.data?.test1).to.equal('test value1'); }); }); ================================================ FILE: apps/api/src/app/subscribers/e2e/get-notifications-feed.e2e.ts ================================================ import { Novu } from '@novu/api'; import { NotificationTemplateEntity, SubscriberRepository } from '@novu/dal'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { expectSdkExceptionGeneric, initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('Get Notifications feed - /:subscriberId/notifications/feed (GET) #novu-v2', () => { let session: UserSession; let template: NotificationTemplateEntity; let subscriberId: string; let novuClient: Novu; beforeEach(async () => { session = new UserSession(); await session.initialize(); novuClient = initNovuClassSdk(session); template = await session.createTemplate({ noFeedId: false, }); subscriberId = SubscriberRepository.createObjectId(); }); it('should throw exception on invalid subscriber id', async () => { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); const notificationsFeedResponse = (await novuClient.subscribers.notifications.feed({ limit: 5, subscriberId })) .result; expect(notificationsFeedResponse.pageSize).to.equal(5); const { error } = await expectSdkExceptionGeneric(() => novuClient.subscribers.notifications.feed({ subscriberId: `${subscriberId}111`, seen: false, limit: 5, }) ); expect(error).to.be.ok; expect(error?.statusCode).to.equals(400); expect(error?.message).to.eq( `Subscriber not found for this environment with the id: ${`${subscriberId}111`}. Make sure to create a subscriber before fetching the feed.` ); }); it('should throw exception when invalid payload query param is passed', async () => { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); const { error: err } = await expectSdkExceptionGeneric(() => novuClient.subscribers.notifications.feed({ limit: 5, payload: 'invalid', subscriberId, }) ); expect(err?.statusCode).to.equals(400); expect(err?.message).to.eq(`Invalid payload, the JSON object should be encoded to base64 string.`); }); it('should allow filtering by custom data from the payload', async () => { const partialPayload = { foo: 123 }; const payload = { ...partialPayload, bar: 'bar' }; await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId, payload }); await session.waitForJobCompletion(template._id); const payloadQueryValue = Buffer.from(JSON.stringify(partialPayload)).toString('base64'); const { data } = ( await novuClient.subscribers.notifications.feed({ limit: 5, payload: payloadQueryValue, subscriberId }) ).result; expect(data.length).to.equal(1); expect(data[0].payload).to.deep.equal(payload); }); it('should allow filtering by custom nested data from the payload', async () => { const partialPayload = { foo: { bar: 123 } }; const payload = { ...partialPayload, baz: 'baz' }; await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId, payload }); await session.waitForJobCompletion(template._id); const payloadQueryValue = Buffer.from(JSON.stringify(partialPayload)).toString('base64'); const { data } = ( await novuClient.subscribers.notifications.feed({ limit: 5, payload: payloadQueryValue, subscriberId, }) ).result; expect(data.length).to.equal(1); expect(data[0].payload).to.deep.equal(payload); }); }); ================================================ FILE: apps/api/src/app/subscribers/e2e/get-subscriber.e2e.ts ================================================ import { Novu } from '@novu/api'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('Get Subscriber - /subscribers/:id (GET) #novu-v2', () => { let session: UserSession; let novuClient: Novu; beforeEach(async () => { session = new UserSession(); await session.initialize(); novuClient = initNovuClassSdk(session); }); const subscriberId = 'sub_42'; it('should return a subscriber by id', async () => { const createResponse = await novuClient.subscribers.create({ subscriberId, firstName: 'John', lastName: 'Doe', email: 'john@doe.com', }); const response = await novuClient.subscribers.retrieve(subscriberId); const subscriber = response.result; expect(subscriber.subscriberId).to.equal(subscriberId); expect(subscriber.topics).to.be.undefined; }); }); ================================================ FILE: apps/api/src/app/subscribers/e2e/get-unseen-count.e2e.ts ================================================ import { Novu } from '@novu/api'; import { SubscribersV1ControllerGetUnseenCountRequest } from '@novu/api/models/operations'; import { NotificationTemplateEntity, SubscriberRepository } from '@novu/dal'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { expectSdkExceptionGeneric, initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('Get Unseen Count - /:subscriberId/notifications/unseen (GET) #novu-v2', () => { let session: UserSession; let template: NotificationTemplateEntity; let subscriberId: string; let novuClient: Novu; beforeEach(async () => { session = new UserSession(); await session.initialize(); novuClient = initNovuClassSdk(session); template = await session.createTemplate({ noFeedId: true, }); subscriberId = SubscriberRepository.createObjectId(); }); it('should throw exception on invalid subscriber id', async () => { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); const seenCount = await getUnSeenCount({ seen: false, subscriberId }); expect(seenCount).to.equal(1); const { error } = await expectSdkExceptionGeneric(() => getUnSeenCount({ seen: false, subscriberId: `${subscriberId}111` }) ); expect(error?.statusCode, JSON.stringify(error)).to.equals(400); expect(error?.message, JSON.stringify(error)).to.contain( `Subscriber ${`${subscriberId}111`} is not exist in environment` ); }); async function getUnSeenCount(query: SubscribersV1ControllerGetUnseenCountRequest) { const response = await novuClient.subscribers.notifications.unseenCount(query); return response.result.count; } }); ================================================ FILE: apps/api/src/app/subscribers/e2e/helpers/index.ts ================================================ import { IUpdateNotificationTemplateDto } from '@novu/shared'; import { UserSession } from '@novu/testing'; import axios from 'axios'; const axiosInstance = axios.create(); export async function getNotificationTemplate(session: UserSession, id: string) { return await axiosInstance.get(`${session.serverUrl}/v1/workflows/${id}`, { headers: { authorization: `ApiKey ${session.apiKey}`, }, }); } export async function updateNotificationTemplate( session: UserSession, id: string, data: IUpdateNotificationTemplateDto ) { return await axiosInstance.put(`${session.serverUrl}/v1/workflows/${id}`, data, { headers: { authorization: `ApiKey ${session.apiKey}`, }, }); } ================================================ FILE: apps/api/src/app/subscribers/e2e/mark-all-subscriber-messages.e2e.ts ================================================ import { Novu } from '@novu/api'; import { MessageRepository, NotificationTemplateEntity, SubscriberRepository } from '@novu/dal'; import { ChannelTypeEnum, MessagesStatusEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { expectSdkExceptionGeneric, initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('Mark All Subscriber Messages - /subscribers/:subscriberId/messages/mark-all (POST) #novu-v2', () => { let session: UserSession; let template: NotificationTemplateEntity; const messageRepository = new MessageRepository(); const subscriberRepository = new SubscriberRepository(); let novuClient: Novu; beforeEach(async () => { session = new UserSession(); await session.initialize(); template = await session.createTemplate(); novuClient = initNovuClassSdk(session); await messageRepository.delete({ _environmentId: session.environment._id, _subscriberId: session.subscriberId, }); }); it("should throw not found when subscriberId doesn't exist", async () => { const fakeSubscriberId = 'fake-subscriber-id'; const { error } = await expectSdkExceptionGeneric(() => markAllSubscriberMessagesAs(fakeSubscriberId, MessagesStatusEnum.READ) ); if (!error) { throw new Error('Call Should fail'); } expect(error.statusCode).to.equal(404); expect(error.message, JSON.stringify(error)).to.equal( `Subscriber ${fakeSubscriberId} does not exist in environment ${session.environment._id}, ` + 'please provide a valid subscriber identifier' ); }); it('should mark all the subscriber messages as read', async () => { const { subscriberId } = session; await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); const notificationsFeedResponse = await getSubscriberNotifications(subscriberId); expect(notificationsFeedResponse.totalCount, 'notificationsFeedResponse.totalCount').to.equal(5); const messagesMarkedAsReadResponse = await markAllSubscriberMessagesAs(subscriberId, MessagesStatusEnum.READ); expect(messagesMarkedAsReadResponse, 'messagesMarkedAsReadResponse').to.equal(5); const subscriber = await subscriberRepository.findBySubscriberId(session.environment._id, subscriberId); const feed = await messageRepository.find({ _environmentId: session.environment._id, _subscriberId: subscriber?._id, channel: ChannelTypeEnum.IN_APP, seen: true, read: true, }); expect(feed.length, 'feed.length').to.equal(5); for (const message of feed) { expect(message.seen, 'message.seen').to.equal(true); expect(message.read, 'message.read').to.equal(true); } }); it('should not mark all the messages as read if they are already read', async () => { const { subscriberId } = session; await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); const notificationsFeedResponse = await getSubscriberNotifications(subscriberId); expect(notificationsFeedResponse.totalCount).to.equal(5); const subscriber = await subscriberRepository.findBySubscriberId(session.environment._id, subscriberId); await messageRepository.update( { _environmentId: session.environment._id, _subscriberId: subscriber?._id, channel: ChannelTypeEnum.IN_APP, seen: false, read: false, }, { $set: { read: true, seen: true } } ); const messagesMarkedAsReadResponse = await markAllSubscriberMessagesAs(subscriberId, MessagesStatusEnum.READ); expect(messagesMarkedAsReadResponse).to.equal(0); const feed = await messageRepository.find({ _environmentId: session.environment._id, _subscriberId: subscriber?._id, channel: ChannelTypeEnum.IN_APP, seen: true, read: true, }); expect(feed.length).to.equal(5); for (const message of feed) { expect(message.seen).to.equal(true); expect(message.read).to.equal(true); } }); it('should mark all the subscriber messages as unread', async () => { const { subscriberId } = session; await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); const notificationsFeedResponse = await getSubscriberNotifications(subscriberId); expect(notificationsFeedResponse.totalCount).to.equal(5); const subscriber = await subscriberRepository.findBySubscriberId(session.environment._id, subscriberId); await messageRepository.update( { _environmentId: session.environment._id, _subscriberId: subscriber?._id, channel: ChannelTypeEnum.IN_APP, seen: false, read: false, }, { $set: { read: true, seen: true } } ); const messagesMarkedAsReadResponse = await markAllSubscriberMessagesAs(subscriberId, MessagesStatusEnum.UNREAD); expect(messagesMarkedAsReadResponse).to.equal(5); const feed = await messageRepository.find({ _environmentId: session.environment._id, _subscriberId: subscriber?._id, channel: ChannelTypeEnum.IN_APP, seen: true, read: false, }); expect(feed.length).to.equal(5); for (const message of feed) { expect(message.seen).to.equal(true); expect(message.read).to.equal(false); } }); it('should mark all the subscriber messages as seen', async () => { const { subscriberId } = session; await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); const notificationsFeedResponse = await getSubscriberNotifications(subscriberId); expect(notificationsFeedResponse.totalCount).to.equal(5); const messagesMarkedAsReadResponse = await markAllSubscriberMessagesAs(subscriberId, MessagesStatusEnum.SEEN); expect(messagesMarkedAsReadResponse).to.equal(5); const subscriber = await subscriberRepository.findBySubscriberId(session.environment._id, subscriberId); const feed = await messageRepository.find({ _environmentId: session.environment._id, _subscriberId: subscriber?._id, channel: ChannelTypeEnum.IN_APP, seen: true, read: false, }); expect(feed.length).to.equal(5); for (const message of feed) { expect(message.seen).to.equal(true); expect(message.read).to.equal(false); } }); it('should mark all the subscriber messages as unseen', async () => { const { subscriberId } = session; await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); const notificationsFeedResponse = await getSubscriberNotifications(subscriberId); expect(notificationsFeedResponse.totalCount).to.equal(5); const subscriber = await subscriberRepository.findBySubscriberId(session.environment._id, subscriberId); await messageRepository.update( { _environmentId: session.environment._id, _subscriberId: subscriber?._id, channel: ChannelTypeEnum.IN_APP, seen: false, read: false, }, { $set: { seen: true } } ); const messagesMarkedAsReadResponse = await markAllSubscriberMessagesAs(subscriberId, MessagesStatusEnum.UNSEEN); expect(messagesMarkedAsReadResponse).to.equal(5); const feed = await messageRepository.find({ _environmentId: session.environment._id, _subscriberId: subscriber?._id, channel: ChannelTypeEnum.IN_APP, seen: false, read: false, }); expect(feed.length).to.equal(5); for (const message of feed) { expect(message.seen).to.equal(false); expect(message.read).to.equal(false); } }); async function markAllSubscriberMessagesAs(subscriberId: string, markAs: MessagesStatusEnum) { const res = await novuClient.subscribers.messages.markAll({ markAs }, subscriberId); return res.result; } async function getSubscriberNotifications(subscriberId: string) { const res = await novuClient.subscribers.notifications.feed({ subscriberId, limit: 100, }); return res.result; } }); ================================================ FILE: apps/api/src/app/subscribers/e2e/mark-as-by-mark.e2e.ts ================================================ import { Novu } from '@novu/api'; import { MessageEntity, MessageRepository, NotificationTemplateEntity, SubscriberEntity, SubscriberRepository, } from '@novu/dal'; import { ChannelTypeEnum, MessagesStatusEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import axios from 'axios'; import { expect } from 'chai'; import { expectSdkExceptionGeneric, expectSdkValidationExceptionGeneric, initNovuClassSdk, } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; const axiosInstance = axios.create(); describe('Mark as Seen - /widgets/messages/mark-as (POST) #novu-v2', async () => { const messageRepository = new MessageRepository(); const subscriberRepository = new SubscriberRepository(); let session: UserSession; let template: NotificationTemplateEntity; let subscriberId; let subscriber: SubscriberEntity; let message: MessageEntity; let novuClient: Novu; before(async () => { session = new UserSession(); await session.initialize(); subscriberId = SubscriberRepository.createObjectId(); template = await session.createTemplate(); novuClient = initNovuClassSdk(session); }); beforeEach(async () => { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); subscriber = await getSubscriber(session, subscriberRepository, subscriberId); message = await getMessage(session, messageRepository, subscriber); expect(message.seen).to.equal(false); expect(message.read).to.equal(false); expect(message.lastSeenDate).to.be.not.ok; expect(message.lastReadDate).to.be.not.ok; }); afterEach(async () => { await pruneMessages(messageRepository); }); it('should change the seen status', async () => { await novuClient.subscribers.messages.markAllAs( { messageId: message._id, markAs: MessagesStatusEnum.SEEN, }, subscriberId ); const updatedMessage = await getMessage(session, messageRepository, subscriber); expect(updatedMessage.seen).to.equal(true); expect(updatedMessage.read).to.equal(false); expect(updatedMessage.lastSeenDate).to.be.ok; expect(updatedMessage.lastReadDate).to.be.not.ok; }); it('should change the read status', async () => { await novuClient.subscribers.messages.markAllAs( { messageId: message._id, markAs: MessagesStatusEnum.READ, }, subscriberId ); const updatedMessage = await getMessage(session, messageRepository, subscriber); expect(updatedMessage.seen).to.equal(true); expect(updatedMessage.read).to.equal(true); expect(updatedMessage.lastSeenDate).to.be.ok; expect(updatedMessage.lastReadDate).to.be.ok; }); it('should change the seen status to unseen', async () => { // simulate user seen await novuClient.subscribers.messages.markAllAs( { messageId: message._id, markAs: MessagesStatusEnum.SEEN, }, subscriberId ); const seenMessage = await getMessage(session, messageRepository, subscriber); expect(seenMessage.seen).to.equal(true); expect(seenMessage.read).to.equal(false); expect(seenMessage.lastSeenDate).to.be.ok; expect(seenMessage.lastReadDate).to.be.not.ok; await novuClient.subscribers.messages.markAllAs( { messageId: message._id, markAs: MessagesStatusEnum.UNSEEN, }, subscriberId ); const updatedMessage = await getMessage(session, messageRepository, subscriber); expect(updatedMessage.seen).to.equal(false); expect(updatedMessage.read).to.equal(false); expect(updatedMessage.lastSeenDate).to.be.ok; expect(updatedMessage.lastReadDate).to.be.not.ok; }); it('should change the read status to unread', async () => { // simulate user read await novuClient.subscribers.messages.markAllAs( { messageId: message._id, markAs: MessagesStatusEnum.READ, }, subscriberId ); const readMessage = await getMessage(session, messageRepository, subscriber); expect(readMessage.seen).to.equal(true); expect(readMessage.read).to.equal(true); expect(readMessage.lastSeenDate).to.be.ok; expect(readMessage.lastReadDate).to.be.ok; await novuClient.subscribers.messages.markAllAs( { messageId: message._id, markAs: MessagesStatusEnum.UNREAD, }, subscriberId ); const updateMessage = await getMessage(session, messageRepository, subscriber); expect(updateMessage.seen).to.equal(true); expect(updateMessage.read).to.equal(false); expect(updateMessage.lastSeenDate).to.be.ok; expect(updateMessage.lastReadDate).to.be.ok; }); it('should throw exception if messages were not provided', async () => { const failureMessage = 'should not reach here, should throw error'; try { await markAs(session.apiKey, undefined, MessagesStatusEnum.SEEN, subscriberId); expect.fail(failureMessage); } catch (e) { if (e.message === failureMessage) { expect(e.message).to.be.empty; } expect(e.response.data.message).to.equal('Validation Error'); expect(e.response.data.statusCode).to.equal(422); expect(e.response.data.errors.general.messages).to.include('messageId should not be null or undefined'); expect(e.response.data.errors.general.messages).to.include( 'messageId must be a valid MongoDB ObjectId or an array of valid MongoDB ObjectIds' ); } const { error } = await expectSdkValidationExceptionGeneric(() => novuClient.subscribers.messages.markAllAs( { messageId: [], markAs: MessagesStatusEnum.SEEN, }, subscriberId ) ); expect(error?.message).to.equal('Validation Error'); expect(error?.statusCode).to.equal(422); expect(error?.errors.general.messages).to.include( 'messageId must be a valid MongoDB ObjectId or an array of valid MongoDB ObjectIds' ); }); }); async function getMessage( session: UserSession, messageRepository: MessageRepository, subscriber: SubscriberEntity ): Promise { const message = await messageRepository.findOne({ _environmentId: session.environment._id, _subscriberId: subscriber._id, channel: ChannelTypeEnum.IN_APP, }); if (!message) { expect(message).to.be.ok; throw new Error('message not found'); } return message; } async function markAs( apiKey: string, messageIds: string | string[] | undefined, mark: MessagesStatusEnum, subscriberId: string ) { return await axiosInstance.post( `http://127.0.0.1:${process.env.PORT}/v1/subscribers/${subscriberId}/messages/mark-as`, { messageId: messageIds, markAs: mark, }, { headers: { authorization: `ApiKey ${apiKey}`, }, } ); } async function getSubscriber( session: UserSession, subscriberRepository: SubscriberRepository, subscriberId: string ): Promise { const subscriberRes = await subscriberRepository.findOne({ _environmentId: session.environment._id, subscriberId, }); if (!subscriberRes) { expect(subscriberRes).to.be.ok; throw new Error('subscriber not found'); } return subscriberRes; } async function pruneMessages(messageRepository) { await messageRepository.delete({}); } ================================================ FILE: apps/api/src/app/subscribers/e2e/remove-subscriber.e2e.ts ================================================ import { Novu } from '@novu/api'; import { TopicResponseDto } from '@novu/api/models/components'; import { SubscriberEntity, SubscriberRepository, TopicSubscribersRepository } from '@novu/dal'; import { ExternalSubscriberId, TopicKey, TopicName } from '@novu/shared'; import { SubscribersService, UserSession } from '@novu/testing'; import { expect } from 'chai'; import { initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; const subscriberId = '123'; describe('Delete Subscriber - /subscribers/:subscriberId (DELETE) #novu-v2', () => { let session: UserSession; let subscriberService: SubscribersService; const subscriberRepository = new SubscriberRepository(); const topicSubscribersRepository = new TopicSubscribersRepository(); let novuClient: Novu; beforeEach(async () => { session = new UserSession(); await session.initialize(); subscriberService = new SubscribersService(session.organization._id, session.environment._id); novuClient = initNovuClassSdk(session); }); it('should delete an existing subscriber', async () => { await novuClient.subscribers.create({ subscriberId, firstName: 'John', lastName: 'Doe', email: 'john@doe.com', phone: '+972523333333', }); const createdSubscriber = await subscriberRepository.findBySubscriberId(session.environment._id, subscriberId); expect(createdSubscriber?.subscriberId).to.equal(subscriberId); await novuClient.subscribers.delete(subscriberId); const subscriber = await subscriberRepository.findBySubscriberId(session.environment._id, subscriberId); expect(subscriber).to.be.null; }); it('should dispose subscriber relations to topic once he was removed', async () => { const subscriber = await subscriberService.createSubscriber({ subscriberId }); for (let i = 0; i < 50; i += 1) { const firstTopicKey = `topic-key-${i}-trigger-event`; const firstTopicName = `topic-name-${i}-trigger-event`; const newTopic = await createTopic(firstTopicKey, firstTopicName); await addSubscribersToTopic(newTopic, [subscriber]); } const createdRelations = await topicSubscribersRepository.find({ _environmentId: session.environment._id, _organizationId: session.organization._id, externalSubscriberId: subscriberId, }); expect(createdRelations.length).to.equal(50); await novuClient.subscribers.delete(subscriberId); const deletedRelations = await topicSubscribersRepository.find({ _environmentId: session.environment._id, _organizationId: session.organization._id, externalSubscriberId: subscriberId, }); expect(deletedRelations.length).to.equal(0); }); const createTopic = async (key: TopicKey, name: TopicName) => { const response = await novuClient.topics.create({ key, name, }); const body = response.result; expect(body.id).to.exist; expect(body.key).to.eql(key); return body; }; const addSubscribersToTopic = async (createdTopicDto: TopicResponseDto, subscribers: SubscriberEntity[]) => { const subscriberIds: ExternalSubscriberId[] = subscribers.map( (subscriber: SubscriberEntity) => subscriber.subscriberId ); const response = await novuClient.topics.subscriptions.create( { subscriberIds, }, createdTopicDto.key ); expect(response.result.data).to.be.ok; }; }); ================================================ FILE: apps/api/src/app/subscribers/e2e/update-online-flag.e2e.ts ================================================ import { SubscriberEntity } from '@novu/dal'; import { SubscribersService, UserSession } from '@novu/testing'; import { expect } from 'chai'; import { sub } from 'date-fns'; import { initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('Update Subscriber online flag - /subscribers/:subscriberId/online-status (PATCH) #novu-v2', () => { let session: UserSession; let onlineSubscriber: SubscriberEntity; let offlineSubscriber: SubscriberEntity; beforeEach(async () => { session = new UserSession(); await session.initialize(); const subscribersService = new SubscribersService(session.organization._id, session.environment._id); onlineSubscriber = await subscribersService.createSubscriber({ subscriberId: '123', isOnline: true, }); offlineSubscriber = await subscribersService.createSubscriber({ subscriberId: '456', isOnline: false, lastOnlineAt: sub(new Date(), { minutes: 1 }).toISOString(), }); }); it('should set the online status to false', async () => { const body = { isOnline: false, }; const { result: data } = await initNovuClassSdk(session).subscribers.properties.updateOnlineFlag( body, onlineSubscriber.subscriberId ); expect(data.isOnline).to.equal(false); expect(data.lastOnlineAt).to.be.a('string'); }); it('should set the online status to true', async () => { const body = { isOnline: true, }; const { result: data } = await initNovuClassSdk(session).subscribers.properties.updateOnlineFlag( body, offlineSubscriber.subscriberId ); expect(data.isOnline).to.equal(true); }); }); ================================================ FILE: apps/api/src/app/subscribers/params/get-subscriber-preferences-by-level.params.ts ================================================ import { PreferenceLevelEnum } from '@novu/shared'; import { IsEnum, IsString } from 'class-validator'; export class GetSubscriberPreferencesByLevelParams { @IsEnum(PreferenceLevelEnum) parameter: PreferenceLevelEnum; @IsString() subscriberId: string; } ================================================ FILE: apps/api/src/app/subscribers/params/index.ts ================================================ export * from './get-subscriber-preferences-by-level.params'; ================================================ FILE: apps/api/src/app/subscribers/query-objects/unseen-count.query.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; export class UnseenCountQueryDto { @ApiProperty({ description: 'Identifier for the feed. Can be a single string or an array of strings.', oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }], required: false, }) feedId?: string | string[]; @ApiProperty({ description: 'Indicates whether to count seen notifications.', required: false, default: false, type: Boolean, }) @Transform(({ value }) => { if (typeof value === 'string') return value === 'true'; if (typeof value === 'boolean') return value; return undefined; }) seen?: boolean; @ApiProperty({ description: 'The maximum number of notifications to return.', required: false, default: 100, type: Number, }) @Transform(({ value }) => Number(value)) // Convert string to integer limit?: number; } ================================================ FILE: apps/api/src/app/subscribers/subscribersV1.controller.ts ================================================ import { BadRequestException, Body, Controller, Delete, Get, HttpCode, HttpStatus, NotFoundException, Param, Patch, Post, Put, Query, Res, } from '@nestjs/common'; import { ApiExcludeEndpoint, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; import { CreateOrUpdateSubscriberCommand, CreateOrUpdateSubscriberUseCase, OAuthHandlerEnum, SubscriberResponseDto, UpdateSubscriber, UpdateSubscriberChannel, UpdateSubscriberChannelCommand, UpdateSubscriberChannelRequestDto, UpdateSubscriberCommand, } from '@novu/application-generic'; import { MessageEntity } from '@novu/dal'; import { ApiRateLimitCategoryEnum, ApiRateLimitCostEnum, ButtonTypeEnum, ChatProviderIdEnum, IPreferenceChannels, PreferenceLevelEnum, TriggerTypeEnum, UserSessionData, } from '@novu/shared'; import { RequireAuthentication } from '../auth/framework/auth.decorator'; import { ExternalApiAccessible } from '../auth/framework/external-api.decorator'; import { UpdatePreferencesCommand } from '../inbox/usecases/update-preferences/update-preferences.command'; import { UpdatePreferences } from '../inbox/usecases/update-preferences/update-preferences.usecase'; import { ThrottlerCategory, ThrottlerCost } from '../rate-limiting/guards'; import { PaginatedResponseDto } from '../shared/dtos/pagination-response'; import { ApiOkPaginatedResponse } from '../shared/framework/paginated-ok-response.decorator'; import { ApiCommonResponses, ApiCreatedResponse, ApiFoundResponse, ApiNoContentResponse, ApiResponse, } from '../shared/framework/response.decorator'; import { SdkGroupName, SdkMethodName, SdkUsePagination } from '../shared/framework/swagger/sdk.decorators'; import { UserSession } from '../shared/framework/user.decorator'; import { FeedResponseDto } from '../widgets/dtos/feeds-response.dto'; import { MessageMarkAsRequestDto } from '../widgets/dtos/mark-as-request.dto'; import { MarkMessageActionAsSeenDto } from '../widgets/dtos/mark-message-action-as-seen.dto'; import { MarkMessageAsRequestDto } from '../widgets/dtos/mark-message-as-request.dto'; import { MessageResponseDto } from '../widgets/dtos/message-response.dto'; import { UnseenCountResponse } from '../widgets/dtos/unseen-count-response.dto'; import { UpdateSubscriberPreferenceRequestDto } from '../widgets/dtos/update-subscriber-preference-request.dto'; import { UpdateSubscriberPreferenceGlobalResponseDto, UpdateSubscriberPreferenceResponseDto, } from '../widgets/dtos/update-subscriber-preference-response.dto'; import { GetFeedCountCommand } from '../widgets/usecases/get-feed-count/get-feed-count.command'; import { GetFeedCount } from '../widgets/usecases/get-feed-count/get-feed-count.usecase'; import { GetNotificationsFeedCommand } from '../widgets/usecases/get-notifications-feed/get-notifications-feed.command'; import { GetNotificationsFeed } from '../widgets/usecases/get-notifications-feed/get-notifications-feed.usecase'; import { UpdateMessageActionsCommand } from '../widgets/usecases/mark-action-as-done/update-message-actions.command'; import { UpdateMessageActions } from '../widgets/usecases/mark-action-as-done/update-message-actions.usecase'; import { MarkAllMessagesAsCommand } from '../widgets/usecases/mark-all-messages-as/mark-all-messages-as.command'; import { MarkAllMessagesAs } from '../widgets/usecases/mark-all-messages-as/mark-all-messages-as.usecase'; import { MarkMessageAsCommand } from '../widgets/usecases/mark-message-as/mark-message-as.command'; import { MarkMessageAs } from '../widgets/usecases/mark-message-as/mark-message-as.usecase'; import { MarkMessageAsByMarkCommand } from '../widgets/usecases/mark-message-as-by-mark/mark-message-as-by-mark.command'; import { MarkMessageAsByMark } from '../widgets/usecases/mark-message-as-by-mark/mark-message-as-by-mark.usecase'; import { BulkSubscriberCreateDto, CreateSubscriberRequestDto, DeleteSubscriberResponseDto, GetSubscriberPreferencesResponseDto, UpdateSubscriberGlobalPreferencesRequestDto, UpdateSubscriberRequestDto, } from './dtos'; import { BulkCreateSubscriberResponseDto } from './dtos/bulk-create-subscriber-response.dto'; import { ChatOauthCallbackRequestDto, ChatOauthRequestDto } from './dtos/chat-oauth-request.dto'; import { GetInAppNotificationsFeedForSubscriberDto } from './dtos/get-in-app-notification-feed-for-subscriber.dto'; import { GetSubscribersDto } from './dtos/get-subscribers.dto'; import { MarkAllMessageAsRequestDto } from './dtos/mark-all-messages-as-request.dto'; import { UpdateSubscriberOnlineFlagRequestDto } from './dtos/update-subscriber-online-flag-request.dto'; import { GetSubscriberPreferencesByLevelParams } from './params'; import { UnseenCountQueryDto } from './query-objects/unseen-count.query'; import { BulkCreateSubscribersCommand } from './usecases/bulk-create-subscribers'; import { BulkCreateSubscribers } from './usecases/bulk-create-subscribers/bulk-create-subscribers.usecase'; import { ChatOauthCommand } from './usecases/chat-oauth/chat-oauth.command'; import { ChatOauth } from './usecases/chat-oauth/chat-oauth.usecase'; import { ChatOauthCallbackCommand } from './usecases/chat-oauth-callback/chat-oauth-callback.command'; import { ResponseTypeEnum } from './usecases/chat-oauth-callback/chat-oauth-callback.result'; import { ChatOauthCallback } from './usecases/chat-oauth-callback/chat-oauth-callback.usecase'; import { DeleteSubscriberCredentials, DeleteSubscriberCredentialsCommand, } from './usecases/delete-subscriber-credentials'; import { GetPreferencesByLevelCommand } from './usecases/get-preferences-by-level/get-preferences-by-level.command'; import { GetPreferencesByLevel } from './usecases/get-preferences-by-level/get-preferences-by-level.usecase'; import { GetSubscriber, GetSubscriberCommand } from './usecases/get-subscriber'; import { GetSubscribers, GetSubscribersCommand } from './usecases/get-subscribers'; import { RemoveSubscriber, RemoveSubscriberCommand } from './usecases/remove-subscriber'; import { UpdateSubscriberOnlineFlag, UpdateSubscriberOnlineFlagCommand, } from './usecases/update-subscriber-online-flag'; @ThrottlerCategory(ApiRateLimitCategoryEnum.CONFIGURATION) @ApiCommonResponses() @ApiTags('Subscribers') @Controller('/subscribers') export class SubscribersV1Controller { constructor( private createSubscriberUsecase: CreateOrUpdateSubscriberUseCase, private bulkCreateSubscribersUsecase: BulkCreateSubscribers, private updateSubscriberUsecase: UpdateSubscriber, private updateSubscriberChannelUsecase: UpdateSubscriberChannel, private removeSubscriberUsecase: RemoveSubscriber, private getSubscriberUseCase: GetSubscriber, private getSubscribersUsecase: GetSubscribers, private getPreferenceUsecase: GetPreferencesByLevel, private updatePreferencesUsecase: UpdatePreferences, private getNotificationsFeedUsecase: GetNotificationsFeed, private getFeedCountUsecase: GetFeedCount, private markMessageAsUsecase: MarkMessageAs, private markMessageAsByMarkUsecase: MarkMessageAsByMark, private updateMessageActionsUsecase: UpdateMessageActions, private updateSubscriberOnlineFlagUsecase: UpdateSubscriberOnlineFlag, private chatOauthCallbackUsecase: ChatOauthCallback, private chatOauthUsecase: ChatOauth, private deleteSubscriberCredentialsUsecase: DeleteSubscriberCredentials, private markAllMessagesAsUsecase: MarkAllMessagesAs ) {} @Get('') @ExternalApiAccessible() @ApiExcludeEndpoint() @RequireAuthentication() @ApiOkPaginatedResponse(SubscriberResponseDto) @ApiOperation({ summary: 'List all subscribers', description: `Returns a list of subscribers, could be paginated using the **page** and **limit** query parameter. This API is deprecated, use v2 API instead.`, deprecated: true, }) @SdkUsePagination() async listSubscribers( @UserSession() user: UserSessionData, @Query() query: GetSubscribersDto ): Promise> { return await this.getSubscribersUsecase.execute( GetSubscribersCommand.create({ organizationId: user.organizationId, environmentId: user.environmentId, page: query.page, limit: query.limit, }) ); } @Get('/:subscriberId') @ExternalApiAccessible() @RequireAuthentication() @ApiExcludeEndpoint() @ApiResponse(SubscriberResponseDto) @ApiOperation({ summary: 'Retrieve a subscriber', description: `Retrieve a subscriber by its unique key identifier **subscriberId**. This API is deprecated, use v2 API instead.`, deprecated: true, }) @ApiQuery({ name: 'includeTopics', type: Boolean, description: 'Includes the topics associated with the subscriber', required: false, }) async getSubscriber( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string, @Query('includeTopics') includeTopics: string ): Promise { return this.getSubscriberUseCase.execute( GetSubscriberCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, subscriberId, includeTopics: includeTopics === 'true', }) ); } @Post('/') @ExternalApiAccessible() @ApiExcludeEndpoint() @ApiOperation({ summary: 'Create a subscriber', description: `Create a new subscriber if it does not exist, or update an existing subscriber if it already exists. This API is deprecated, use v2 API instead.`, deprecated: true, }) @RequireAuthentication() async createSubscriber( @UserSession() user: UserSessionData, @Body() body: CreateSubscriberRequestDto ): Promise { return await this.createSubscriberUsecase.execute( CreateOrUpdateSubscriberCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, subscriberId: body.subscriberId, firstName: body.firstName, lastName: body.lastName, email: body.email, phone: body.phone, avatar: body.avatar, locale: body.locale, data: body.data, channels: body.channels, }) ); } @ThrottlerCost(ApiRateLimitCostEnum.BULK) @Post('/bulk') @ExternalApiAccessible() @RequireAuthentication() @ApiOperation({ summary: 'Bulk create subscribers', description: ` Using this endpoint multiple subscribers can be created at once. The bulk API is limited to 500 subscribers per request. `, }) @ApiResponse(BulkCreateSubscriberResponseDto, 201) @SdkMethodName('createBulk') async bulkCreateSubscribers( @UserSession() user: UserSessionData, @Body() body: BulkSubscriberCreateDto ): Promise { return await this.bulkCreateSubscribersUsecase.execute( BulkCreateSubscribersCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, subscribers: body.subscribers, }) ); } @Put('/:subscriberId') @ExternalApiAccessible() @ApiExcludeEndpoint() @RequireAuthentication() @ApiResponse(SubscriberResponseDto) @ApiOperation({ summary: 'Update a subscriber', description: `Update a subscriber by its unique key identifier **subscriberId**. **firstName**, **lastName**, **email**, **phone**, **avatar**, **locale**, **data**, **channels** fields are optional. This API is deprecated, use v2 API instead.`, deprecated: true, }) @SdkMethodName('upsert') async updateSubscriber( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string, @Body() body: UpdateSubscriberRequestDto ): Promise { return await this.updateSubscriberUsecase.execute( UpdateSubscriberCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, subscriberId, firstName: body.firstName, lastName: body.lastName, email: body.email, phone: body.phone, avatar: body.avatar, locale: body.locale, data: body.data, channels: body.channels, }) ); } @Put('/:subscriberId/credentials') @ExternalApiAccessible() @RequireAuthentication() @ApiResponse(SubscriberResponseDto) @ApiOperation({ summary: 'Update provider credentials', description: `Update credentials for a provider such as **slack** and **FCM**. **providerId** is required field. This API creates the **deviceTokens** or replaces the existing ones.`, }) @SdkGroupName('Subscribers.Credentials') async updateSubscriberChannel( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string, @Body() body: UpdateSubscriberChannelRequestDto ): Promise { return await this.updateSubscriberChannelUsecase.execute( UpdateSubscriberChannelCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, subscriberId, providerId: body.providerId, credentials: body.credentials, integrationIdentifier: body.integrationIdentifier, oauthHandler: OAuthHandlerEnum.EXTERNAL, isIdempotentOperation: true, }) ); } @Patch('/:subscriberId/credentials') @ExternalApiAccessible() @RequireAuthentication() @ApiResponse(SubscriberResponseDto) @ApiOperation({ summary: 'Upsert provider credentials', description: `Upsert credentials for a provider such as **slack** and **FCM**. **providerId** is required field. This API creates **deviceTokens** or appends to the existing ones.`, }) @SdkGroupName('Subscribers.Credentials') @SdkMethodName('append') async modifySubscriberChannel( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string, @Body() body: UpdateSubscriberChannelRequestDto ): Promise { return await this.updateSubscriberChannelUsecase.execute( UpdateSubscriberChannelCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, subscriberId, providerId: body.providerId, credentials: body.credentials, integrationIdentifier: body.integrationIdentifier, oauthHandler: OAuthHandlerEnum.EXTERNAL, isIdempotentOperation: false, }) ); } @Delete('/:subscriberId/credentials/:providerId') @ExternalApiAccessible() @RequireAuthentication() @ApiNoContentResponse() @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Delete provider credentials', description: `Delete subscriber credentials for a provider such as **slack** and **FCM** by **providerId**. This action is irreversible and will remove the credentials for the provider for particular **subscriberId**.`, }) @SdkGroupName('Subscribers.Credentials') async deleteSubscriberCredentials( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string, @Param('providerId') providerId: string ): Promise { return await this.deleteSubscriberCredentialsUsecase.execute( DeleteSubscriberCredentialsCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, subscriberId, providerId, }) ); } @Patch('/:subscriberId/online-status') @ExternalApiAccessible() @RequireAuthentication() @ApiResponse(SubscriberResponseDto) @ApiOperation({ summary: 'Update subscriber online status', description: 'Update the subscriber online status by its unique key identifier **subscriberId**', }) @SdkGroupName('Subscribers.properties') @SdkMethodName('updateOnlineFlag') async updateSubscriberOnlineFlag( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string, @Body() body: UpdateSubscriberOnlineFlagRequestDto ): Promise { return await this.updateSubscriberOnlineFlagUsecase.execute( UpdateSubscriberOnlineFlagCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, subscriberId, isOnline: body.isOnline, }) ); } @Delete('/:subscriberId') @ExternalApiAccessible() @RequireAuthentication() @ApiResponse(DeleteSubscriberResponseDto) @ApiOperation({ summary: 'Delete a subscriber', description: `Delete a subscriber by its unique key identifier **subscriberId**. This action is irreversible. This API is deprecated, use v2 API instead.`, deprecated: true, }) @ApiExcludeEndpoint() async removeSubscriber( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string ): Promise { return await this.removeSubscriberUsecase.execute( RemoveSubscriberCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, subscriberId, }) ); } @Get('/:subscriberId/preferences') @ExternalApiAccessible() @RequireAuthentication() @ApiResponse(UpdateSubscriberPreferenceResponseDto, 200, true) @ApiOperation({ summary: 'Retrieve subscriber preferences', description: `Retrieve subscriber channel preferences by its unique key identifier **subscriberId**. This API returns all five channels preferences for all workflows.`, deprecated: true, }) @ApiQuery({ name: 'includeInactiveChannels', type: Boolean, required: false, description: 'A flag which specifies if the inactive workflow channels should be included in the retrieved preferences. Default is false', }) @SdkGroupName('Subscribers.Preferences') @ApiExcludeEndpoint() async listSubscriberPreferences( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string, @Query('includeInactiveChannels') includeInactiveChannels: boolean ): Promise { const command = GetPreferencesByLevelCommand.create({ organizationId: user.organizationId, subscriberId, environmentId: user.environmentId, level: PreferenceLevelEnum.TEMPLATE, includeInactiveChannels: includeInactiveChannels ?? false, }); return (await this.getPreferenceUsecase.execute(command)) as UpdateSubscriberPreferenceResponseDto[]; } @Get('/:subscriberId/preferences/:parameter') @ExternalApiAccessible() @RequireAuthentication() @ApiExcludeEndpoint() @ApiOperation({ summary: 'Retrieve subscriber preferences', description: `Retrieve subscriber channel preferences by its unique key identifier **subscriberId** and level field **parameter**. **parameter** field can be **global** or **template**. **template** value is default value, it is synonym with workflow. This API is deprecated, use v2 API instead.`, deprecated: true, }) async getSubscriberPreferenceByLevel( @UserSession() user: UserSessionData, @Param() { parameter, subscriberId }: GetSubscriberPreferencesByLevelParams, @Query('includeInactiveChannels') includeInactiveChannels: boolean ): Promise { const command = GetPreferencesByLevelCommand.create({ organizationId: user.organizationId, subscriberId, environmentId: user.environmentId, level: parameter, includeInactiveChannels: includeInactiveChannels ?? true, }); return await this.getPreferenceUsecase.execute(command); } @Patch('/:subscriberId/preferences/:parameter') @ExternalApiAccessible() @RequireAuthentication() @ApiExcludeEndpoint() @ApiOperation({ summary: 'Update subscriber preferences', description: `Update subscriber channel preferences by its unique key identifier **subscriberId** and level field **parameter**. **parameter** field can be **global** or **template**. **template** value is default value, it is synonym with workflow. This API is deprecated, use v2 API instead.`, deprecated: true, }) async updateSubscriberPreference( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string, @Param('parameter') workflowId: string, @Body() body: UpdateSubscriberPreferenceRequestDto ): Promise { const result = await this.updatePreferencesUsecase.execute( UpdatePreferencesCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, subscriberId, workflowIdOrIdentifier: workflowId, level: PreferenceLevelEnum.TEMPLATE, includeInactiveChannels: false, ...(body.channel && { [body.channel.type]: body.channel.enabled }), }) ); if (!result.workflow) throw new NotFoundException('Workflow not found'); return { preference: { channels: result.channels, enabled: result.enabled, }, template: { _id: result.workflow.id, name: result.workflow.name, critical: result.workflow.critical, tags: result.workflow.tags, data: result.workflow.data, triggers: [ { identifier: result.workflow.identifier, type: TriggerTypeEnum.EVENT, variables: [], }, ], }, }; } @Patch('/:subscriberId/preferences') @ExternalApiAccessible() @RequireAuthentication() @ApiExcludeEndpoint() @ApiOperation({ summary: 'Update subscriber global preferences', description: `Update subscriber global preferences by its unique key identifier **subscriberId**. This API is deprecated, use v2 API instead.`, deprecated: true, }) async updateSubscriberGlobalPreferences( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string, @Body() body: UpdateSubscriberGlobalPreferencesRequestDto ): Promise { const channels = body.preferences?.reduce((acc, curr) => { acc[curr.type] = curr.enabled; return acc; }, {} as IPreferenceChannels); const result = await this.updatePreferencesUsecase.execute( UpdatePreferencesCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, subscriberId, level: PreferenceLevelEnum.GLOBAL, includeInactiveChannels: false, ...channels, }) ); return { preference: { channels: result.channels, enabled: result.enabled, }, }; } @ExternalApiAccessible() @RequireAuthentication() @Get('/:subscriberId/notifications/feed') @ApiOperation({ summary: 'Retrieve subscriber notifications', description: `Retrieve subscriber in-app (inbox) notifications by its unique key identifier **subscriberId**.`, }) @ApiResponse(FeedResponseDto) @SdkGroupName('Subscribers.Notifications') @SdkMethodName('feed') async getNotificationsFeed( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string, @Query() query: GetInAppNotificationsFeedForSubscriberDto ): Promise { let feedsQuery: string[] | undefined; if (query.feedIdentifier) { feedsQuery = Array.isArray(query.feedIdentifier) ? query.feedIdentifier : [query.feedIdentifier]; } const command = GetNotificationsFeedCommand.create({ organizationId: user.organizationId, environmentId: user.environmentId, subscriberId, page: query.page, feedId: feedsQuery, query: { seen: query.seen, read: query.read }, limit: query.limit, payload: query.payload, }); return await this.getNotificationsFeedUsecase.execute(command); } @ExternalApiAccessible() @RequireAuthentication() @Get('/:subscriberId/notifications/unseen') @ApiResponse(UnseenCountResponse) @ApiOperation({ summary: 'Retrieve unseen notifications count', description: `Retrieve unseen in-app (inbox) notifications count for a subscriber by its unique key identifier **subscriberId**.`, }) @SdkGroupName('Subscribers.Notifications') @SdkMethodName('unseenCount') async getUnseenCount( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string, @Query() query: UnseenCountQueryDto ): Promise { let feedsQuery: string[] | undefined; if (query.feedId) { feedsQuery = Array.isArray(query.feedId) ? query.feedId : [query.feedId]; } if (query.seen === undefined) { query.seen = false; } const command = GetFeedCountCommand.create({ organizationId: user.organizationId, subscriberId, environmentId: user.environmentId, feedId: feedsQuery, seen: query.seen, limit: query.limit || 100, }); return await this.getFeedCountUsecase.execute(command); } @ApiExcludeEndpoint() @ExternalApiAccessible() @RequireAuthentication() @Post('/:subscriberId/messages/markAs') @ApiOperation({ summary: 'Mark a subscriber feed messages as seen or as read', description: `Introducing '/:subscriberId/messages/mark-as endpoint for consistent read and seen message handling, deprecating old legacy endpoint.`, deprecated: true, }) @SdkGroupName('Subscribers.Messages') @SdkMethodName('markAs') @ApiResponse(MessageResponseDto, 201, true) async markMessageAs( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string, @Body() body: MarkMessageAsRequestDto ): Promise { if (!body.messageId) throw new BadRequestException('messageId is required'); const messageIds = this.toArray(body.messageId); if (!messageIds) throw new BadRequestException('messageId is required'); const command = MarkMessageAsCommand.create({ organizationId: user.organizationId, subscriberId, environmentId: user.environmentId, messageIds, mark: body.mark, }); return await this.markMessageAsUsecase.execute(command); } @ApiOperation({ summary: 'Update notifications state', description: `Update subscriber's multiple in-app (inbox) notifications state such as seen, read, unseen or unread by **subscriberId**. **messageId** is of type mongodbId of notifications`, }) @ExternalApiAccessible() @RequireAuthentication() @Post('/:subscriberId/messages/mark-as') @SdkGroupName('Subscribers.Messages') @SdkMethodName('markAllAs') @ApiResponse(MessageResponseDto, 201, true) async markMessagesAs( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string, @Body() body: MessageMarkAsRequestDto ): Promise { const messageIds = this.toArray(body.messageId); if (!messageIds || messageIds.length === 0) throw new BadRequestException('messageId is required'); return await this.markMessageAsByMarkUsecase.execute( MarkMessageAsByMarkCommand.create({ organizationId: user.organizationId, subscriberId, environmentId: user.environmentId, messageIds, markAs: body.markAs, __source: 'api', }) ); } @ExternalApiAccessible() @RequireAuthentication() @Post('/:subscriberId/messages/mark-all') @ApiOperation({ summary: 'Update all notifications state', description: `Update all subscriber in-app (inbox) notifications state such as read, unread, seen or unseen by **subscriberId**.`, }) @ApiCreatedResponse({ type: Number, }) @SdkGroupName('Subscribers.Messages') @SdkMethodName('markAll') async markAllUnreadAsRead( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string, @Body() body: MarkAllMessageAsRequestDto ): Promise { const feedIdentifiers = this.toArray(body.feedIdentifier); return await this.markAllMessagesAsUsecase.execute( MarkAllMessagesAsCommand.create({ organizationId: user.organizationId, subscriberId, environmentId: user.environmentId, markAs: body.markAs, feedIdentifiers, }) ); } @ExternalApiAccessible() @RequireAuthentication() @Post('/:subscriberId/messages/:messageId/actions/:type') @ApiOperation({ summary: 'Update notification action status', description: `Update in-app (inbox) notification's action status by its unique key identifier **messageId** and type field **type**. **type** field can be **primary** or **secondary**`, }) @ApiResponse(MessageResponseDto, 201) @SdkGroupName('Subscribers.Messages') @SdkMethodName('updateAsSeen') async markActionAsSeen( @UserSession() user: UserSessionData, @Param('messageId') messageId: string, @Param('type') type: ButtonTypeEnum, @Body() body: MarkMessageActionAsSeenDto, @Param('subscriberId') subscriberId: string ): Promise { return await this.updateMessageActionsUsecase.execute( UpdateMessageActionsCommand.create({ organizationId: user.organizationId, environmentId: user.environmentId, subscriberId, messageId, type, payload: body.payload, status: body.status, }) ); } /** * @deprecated Use the new channel management approach. * @see channel-endpoints and channel-connections modules */ @ExternalApiAccessible() @Get('/:subscriberId/credentials/:providerId/oauth/callback') @ApiExcludeEndpoint() @ApiOperation({ summary: 'Handle slack oauth redirect', description: `Handle slack oauth redirect by its unique key identifier **subscriberId** and providerId **providerId**.`, deprecated: true, }) @ApiResponse(String, 200, false, false, { status: 200, description: 'Returns plain text response.', schema: undefined, content: { 'text/html': { schema: { type: 'string', }, }, }, }) @ApiFoundResponse({ type: String, status: 302, description: 'Redirects to the specified URL.', headers: { Location: { description: 'The URL to redirect to.', schema: { type: 'string', example: 'https://www.novu.co' } }, }, }) // Link to the interface @SdkGroupName('Subscribers.Authentication') @SdkMethodName('chatAccessOauthCallBack') async chatOauthCallback( @Param('subscriberId') subscriberId: string, @Param('providerId') providerId: ChatProviderIdEnum, @Query() query: ChatOauthCallbackRequestDto, @Res() res: any ): Promise { const callbackResult = await this.chatOauthCallbackUsecase.execute( ChatOauthCallbackCommand.create({ providerCode: query?.code, hmacHash: query?.hmacHash, environmentId: query?.environmentId, integrationIdentifier: query?.integrationIdentifier, subscriberId, providerId, }) ); if (callbackResult.typeOfResponse !== ResponseTypeEnum.URL) { res.setHeader('Content-Type', 'text/html'); res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline'"); res.send(callbackResult.resultString); return; } res.redirect(callbackResult.resultString); // Return the URL to redirect to } /** * @deprecated Use the new channel management approach. * @see channel-endpoints and channel-connections modules */ @ExternalApiAccessible() @ApiExcludeEndpoint() @Get('/:subscriberId/credentials/:providerId/oauth') @ApiOperation({ summary: 'Handle chat oauth', deprecated: true, }) @SdkGroupName('Subscribers.Authentication') @SdkMethodName('chatAccessOauth') async chatAccessOauth( @Param('subscriberId') subscriberId: string, @Param('providerId') providerId: ChatProviderIdEnum, @Res() res, @Query() query: ChatOauthRequestDto ): Promise { const data = await this.chatOauthUsecase.execute( ChatOauthCommand.create({ hmacHash: query?.hmacHash, environmentId: query?.environmentId, integrationIdentifier: query?.integrationIdentifier, subscriberId, providerId, }) ); res.redirect(data); } private toArray(param?: string[] | string): string[] | undefined { let paramArray: string[] | undefined; if (param) { paramArray = Array.isArray(param) ? param : param.split(','); } return paramArray; } } ================================================ FILE: apps/api/src/app/subscribers/subscribersV1.module.ts ================================================ import { forwardRef, Module } from '@nestjs/common'; import { TerminusModule } from '@nestjs/terminus'; import { AuthModule } from '../auth/auth.module'; import { ChannelEndpointsModule } from '../channel-endpoints/channel-endpoints.module'; import { OutboundWebhooksModule } from '../outbound-webhooks/outbound-webhooks.module'; import { PreferencesModule } from '../preferences'; import { SharedModule } from '../shared/shared.module'; import { WidgetsModule } from '../widgets/widgets.module'; import { SubscribersV1Controller } from './subscribersV1.controller'; import { USE_CASES } from './usecases'; @Module({ imports: [ SharedModule, AuthModule, TerminusModule, forwardRef(() => WidgetsModule), PreferencesModule, ChannelEndpointsModule, OutboundWebhooksModule.forRoot(), ], controllers: [SubscribersV1Controller], providers: [...USE_CASES], exports: [...USE_CASES], }) export class SubscribersV1Module {} ================================================ FILE: apps/api/src/app/subscribers/unit/update-subscriber-channel.spec.ts ================================================ import { faker } from '@faker-js/faker'; import { Test } from '@nestjs/testing'; import { OAuthHandlerEnum, SYSTEM_LIMITS, UpdateSubscriberChannel, UpdateSubscriberChannelCommand, } from '@novu/application-generic'; import { IntegrationRepository, SubscriberRepository } from '@novu/dal'; import { ChannelTypeEnum, ChatProviderIdEnum, PushProviderIdEnum } from '@novu/shared'; import { SubscribersService, UserSession } from '@novu/testing'; import { expect } from 'chai'; import { SharedModule } from '../../shared/shared.module'; describe('Update Subscriber channel credentials', () => { let updateSubscriberChannelUsecase: UpdateSubscriberChannel; let session: UserSession; const subscriberRepository = new SubscriberRepository(); const integrationRepository = new IntegrationRepository(); beforeEach(async () => { const moduleRef = await Test.createTestingModule({ imports: [SharedModule], providers: [UpdateSubscriberChannel], }).compile(); session = new UserSession(); await session.initialize(); updateSubscriberChannelUsecase = moduleRef.get(UpdateSubscriberChannel); }); it('should add subscriber new discord channel credentials', async () => { const subscriberService = new SubscribersService(session.organization._id, session.environment._id); const subscriber = await subscriberService.createSubscriber(); const subscriberChannel = { providerId: ChatProviderIdEnum.Discord, credentials: { webhookUrl: 'newWebhookUrl' }, }; await updateSubscriberChannelUsecase.execute( UpdateSubscriberChannelCommand.create({ organizationId: subscriber._organizationId, subscriberId: subscriber.subscriberId, environmentId: session.environment._id, providerId: subscriberChannel.providerId, credentials: subscriberChannel.credentials, oauthHandler: OAuthHandlerEnum.NOVU, isIdempotentOperation: true, }) ); const updatedSubscriber = await subscriberRepository.findOne({ _id: subscriber._id, _environmentId: subscriber._environmentId, }); const newChannel = updatedSubscriber?.channels?.find( (channel) => channel.providerId === subscriberChannel.providerId ); expect(newChannel?.credentials.webhookUrl).to.equal(subscriberChannel.credentials.webhookUrl); }); it('should update subscriber existing slack channel credentials', async () => { const subscriberService = new SubscribersService(session.organization._id, session.environment._id); const subscriber = await subscriberService.createSubscriber(); await updateSubscriberChannelUsecase.execute( UpdateSubscriberChannelCommand.create({ organizationId: subscriber._organizationId, subscriberId: subscriber.subscriberId, environmentId: session.environment._id, providerId: ChatProviderIdEnum.Discord, credentials: { webhookUrl: 'webhookUrl' }, oauthHandler: OAuthHandlerEnum.NOVU, isIdempotentOperation: true, }) ); const newSlackSubscribersChannel = { providerId: ChatProviderIdEnum.Slack, credentials: { webhookUrl: 'webhookUrlNew' }, }; await updateSubscriberChannelUsecase.execute( UpdateSubscriberChannelCommand.create({ organizationId: subscriber._organizationId, subscriberId: subscriber.subscriberId, environmentId: session.environment._id, providerId: newSlackSubscribersChannel.providerId, credentials: newSlackSubscribersChannel.credentials, oauthHandler: OAuthHandlerEnum.NOVU, isIdempotentOperation: true, }) ); const updatedSubscriber = await subscriberRepository.findOne({ _id: subscriber._id, _environmentId: subscriber._environmentId, }); const updatedChannel = updatedSubscriber?.channels?.find( (channel) => channel.providerId === newSlackSubscribersChannel.providerId ); expect(updatedChannel?.credentials.webhookUrl).to.equal(newSlackSubscribersChannel.credentials.webhookUrl); }); it('should update only webhookUrl on existing slack channel credentials', async () => { const subscriberService = new SubscribersService(session.organization._id, session.environment._id); const subscriber = await subscriberService.createSubscriber(); const slackIntegration = await integrationRepository.findOne({ _environmentId: session.environment._id, _organizationId: session.organization._id, providerId: ChatProviderIdEnum.Slack, }); const newSlackCredentials = { providerId: ChatProviderIdEnum.Slack, credentials: { webhookUrl: 'new-secret-webhookUrl' }, }; await updateSubscriberChannelUsecase.execute( UpdateSubscriberChannelCommand.create({ organizationId: subscriber._organizationId, subscriberId: subscriber.subscriberId, environmentId: session.environment._id, providerId: newSlackCredentials.providerId, credentials: newSlackCredentials.credentials, oauthHandler: OAuthHandlerEnum.NOVU, isIdempotentOperation: true, }) ); const updatedSubscriber = await subscriberRepository.findOne({ _id: subscriber._id, _environmentId: subscriber._environmentId, }); const newChannel = updatedSubscriber?.channels?.find( (channel) => channel.providerId === newSlackCredentials.providerId ); expect(newChannel?._integrationId).to.equal(slackIntegration?._id); expect(newChannel?.providerId).to.equal('slack'); expect(newChannel?.credentials.webhookUrl).to.equal('new-secret-webhookUrl'); }); it('should update slack channel credentials for a specific integration', async () => { const identifier = 'identifier_slack'; const webhookUrl = 'webhookUrl'; const integration = await integrationRepository.create({ _environmentId: session.environment._id, _organizationId: session.organization._id, identifier, providerId: ChatProviderIdEnum.Slack, channel: ChannelTypeEnum.CHAT, credentials: {}, active: true, }); const subscriberService = new SubscribersService(session.organization._id, session.environment._id); const subscriber = await subscriberService.createSubscriber(); await updateSubscriberChannelUsecase.execute( UpdateSubscriberChannelCommand.create({ organizationId: subscriber._organizationId, subscriberId: subscriber.subscriberId, environmentId: session.environment._id, integrationIdentifier: identifier, providerId: ChatProviderIdEnum.Slack, credentials: { webhookUrl }, oauthHandler: OAuthHandlerEnum.NOVU, isIdempotentOperation: true, }) ); const updatedSubscriber = await subscriberRepository.findOne({ _id: subscriber._id, _environmentId: subscriber._environmentId, }); const updatedChannel = updatedSubscriber?.channels?.find( (channel) => channel.providerId === ChatProviderIdEnum.Slack && channel._integrationId === integration._id ); expect(updatedChannel?.credentials.webhookUrl).to.equal(webhookUrl); }); it('should not add duplicated token when the operation IS idempotent', async () => { const subscriberService = new SubscribersService(session.organization._id, session.environment._id); const subscriber = await subscriberService.createSubscriber(); const fcmCredentials = { providerId: PushProviderIdEnum.FCM, credentials: { deviceTokens: ['token_1', 'token_1'] }, }; await updateSubscriberChannelUsecase.execute( UpdateSubscriberChannelCommand.create({ organizationId: subscriber._organizationId, subscriberId: subscriber.subscriberId, environmentId: session.environment._id, providerId: fcmCredentials.providerId, credentials: fcmCredentials.credentials, oauthHandler: OAuthHandlerEnum.NOVU, isIdempotentOperation: true, }) ); const updatedSubscriber = await subscriberRepository.findOne({ _id: subscriber._id, _environmentId: subscriber._environmentId, }); const addedFcmToken = updatedSubscriber?.channels?.find( (channel) => channel.providerId === fcmCredentials.providerId ); expect(addedFcmToken?.providerId).to.equal(PushProviderIdEnum.FCM); expect(addedFcmToken?.credentials?.deviceTokens?.length).to.equal(1); expect(addedFcmToken?.credentials?.deviceTokens).to.deep.equal(['token_1']); }); it('should not add duplicated token when the operation IS NOT idempotent', async () => { const subscriberService = new SubscribersService(session.organization._id, session.environment._id); const subscriber = await subscriberService.createSubscriber(); const fcmCredentials = { providerId: PushProviderIdEnum.FCM, credentials: { deviceTokens: ['token_1', 'token_1'] }, }; await updateSubscriberChannelUsecase.execute( UpdateSubscriberChannelCommand.create({ organizationId: subscriber._organizationId, subscriberId: subscriber.subscriberId, environmentId: session.environment._id, providerId: fcmCredentials.providerId, credentials: fcmCredentials.credentials, oauthHandler: OAuthHandlerEnum.NOVU, isIdempotentOperation: false, }) ); const updatedSubscriber = await subscriberRepository.findOne({ _id: subscriber._id, _environmentId: subscriber._environmentId, }); const addedFcmToken = updatedSubscriber?.channels?.find( (channel) => channel.providerId === fcmCredentials.providerId ); expect(addedFcmToken?.providerId).to.equal(PushProviderIdEnum.FCM); expect(addedFcmToken?.credentials?.deviceTokens?.length).to.equal(2); expect(addedFcmToken?.credentials?.deviceTokens).to.deep.equal(['identifier', 'token_1']); }); it('should append to existing device token array when the operation IS NOT idempotent', async () => { const subscriberService = new SubscribersService(session.organization._id, session.environment._id); const subscriber = await subscriberService.createSubscriber(); const fcmCredentials = { providerId: PushProviderIdEnum.FCM, credentials: { deviceTokens: ['token_1'] }, }; await updateSubscriberChannelUsecase.execute( UpdateSubscriberChannelCommand.create({ organizationId: subscriber._organizationId, subscriberId: subscriber.subscriberId, environmentId: session.environment._id, providerId: fcmCredentials.providerId, credentials: fcmCredentials.credentials, oauthHandler: OAuthHandlerEnum.NOVU, isIdempotentOperation: false, }) ); const updatedSubscriber = await subscriberRepository.findOne({ _id: subscriber._id, _environmentId: subscriber._environmentId, }); const addedFcmToken = updatedSubscriber?.channels?.find( (channel) => channel.providerId === fcmCredentials.providerId ); expect(addedFcmToken?.providerId).to.equal(PushProviderIdEnum.FCM); expect(addedFcmToken?.credentials?.deviceTokens?.length).to.equal(2); expect(addedFcmToken?.credentials?.deviceTokens).to.deep.equal(['identifier', 'token_1']); }); it('should update deviceTokens with empty array', async () => { const subscriberService = new SubscribersService(session.organization._id, session.environment._id); const subscriber = await subscriberService.createSubscriber(); const fcmCredentials = { providerId: PushProviderIdEnum.FCM, credentials: { deviceTokens: ['token_1'] }, }; await updateSubscriberChannelUsecase.execute( UpdateSubscriberChannelCommand.create({ organizationId: subscriber._organizationId, subscriberId: subscriber.subscriberId, environmentId: session.environment._id, providerId: fcmCredentials.providerId, credentials: fcmCredentials.credentials, oauthHandler: OAuthHandlerEnum.NOVU, isIdempotentOperation: true, }) ); let updatedSubscriber = await subscriberRepository.findOne({ _id: subscriber._id, _environmentId: subscriber._environmentId, }); const addedFcmToken = updatedSubscriber?.channels?.find( (channel) => channel.providerId === fcmCredentials.providerId ); expect(addedFcmToken?.credentials?.deviceTokens?.length).to.equal(1); expect(addedFcmToken?.credentials?.deviceTokens).to.deep.equal(['token_1']); await updateSubscriberChannelUsecase.execute( UpdateSubscriberChannelCommand.create({ organizationId: subscriber._organizationId, subscriberId: subscriber.subscriberId, environmentId: session.environment._id, providerId: fcmCredentials.providerId, credentials: { deviceTokens: [] }, oauthHandler: OAuthHandlerEnum.NOVU, isIdempotentOperation: true, }) ); updatedSubscriber = await subscriberRepository.findOne({ _id: subscriber._id, _environmentId: subscriber._environmentId, }); const updatedProviderWithEmptyDeviceToken = updatedSubscriber?.channels?.find( (channel) => channel.providerId === fcmCredentials.providerId ); expect(updatedProviderWithEmptyDeviceToken?.credentials?.deviceTokens?.length).to.equal(0); expect(updatedProviderWithEmptyDeviceToken?.credentials?.deviceTokens).to.deep.equal([]); }); it('should update deviceTokens with new token after stress adding', async () => { const subscriberService = new SubscribersService(session.organization._id, session.environment._id); const subscriber = await subscriberService.createSubscriber(); await updateSubscriberChannelUsecase.execute( UpdateSubscriberChannelCommand.create({ organizationId: subscriber._organizationId, subscriberId: subscriber.subscriberId, environmentId: session.environment._id, providerId: PushProviderIdEnum.FCM, credentials: { deviceTokens: ['token_1'] }, oauthHandler: OAuthHandlerEnum.NOVU, isIdempotentOperation: true, }) ); let updatedSubscriber = await subscriberRepository.findOne({ _id: subscriber._id, _environmentId: subscriber._environmentId, }); let updateToken = updatedSubscriber?.channels?.find((channel) => channel.providerId === PushProviderIdEnum.FCM); expect(updateToken?.credentials?.deviceTokens?.length).to.equal(1); expect(updateToken?.credentials?.deviceTokens).to.deep.equal(['token_1']); await updateSubscriberChannelUsecase.execute( UpdateSubscriberChannelCommand.create({ organizationId: subscriber._organizationId, subscriberId: subscriber.subscriberId, environmentId: session.environment._id, providerId: PushProviderIdEnum.FCM, credentials: { deviceTokens: ['token_1', 'token_2', 'token_2', 'token_3'] }, oauthHandler: OAuthHandlerEnum.NOVU, isIdempotentOperation: true, }) ); updatedSubscriber = await subscriberRepository.findOne({ _id: subscriber._id, _environmentId: subscriber._environmentId, }); updateToken = updatedSubscriber?.channels?.find((channel) => channel.providerId === PushProviderIdEnum.FCM); expect(updateToken?.credentials?.deviceTokens?.length).to.equal(3); expect(updateToken?.credentials?.deviceTokens).to.deep.equal(['token_1', 'token_2', 'token_3']); await updateSubscriberChannelUsecase.execute( UpdateSubscriberChannelCommand.create({ organizationId: subscriber._organizationId, subscriberId: subscriber.subscriberId, environmentId: session.environment._id, providerId: PushProviderIdEnum.FCM, credentials: { deviceTokens: ['token_555'] }, oauthHandler: OAuthHandlerEnum.NOVU, isIdempotentOperation: true, }) ); updatedSubscriber = await subscriberRepository.findOne({ _id: subscriber._id, _environmentId: subscriber._environmentId, }); updateToken = updatedSubscriber?.channels?.find((channel) => channel.providerId === PushProviderIdEnum.FCM); expect(updateToken?.credentials?.deviceTokens?.length).to.equal(1); expect(updateToken?.credentials?.deviceTokens).to.deep.equal(['token_555']); }); it('should update deviceTokens without duplication on channel creation (addChannelToSubscriber)', async () => { const subscriberId = SubscriberRepository.createObjectId(); const test = await subscriberRepository.create({ firstName: faker.name.firstName(), lastName: faker.name.lastName(), email: faker.internet.email(), phone: faker.phone.phoneNumber(), _environmentId: session.environment._id, _organizationId: session.organization._id, subscriberId, }); await updateSubscriberChannelUsecase.execute( UpdateSubscriberChannelCommand.create({ organizationId: session.organization._id, subscriberId, environmentId: session.environment._id, providerId: PushProviderIdEnum.FCM, credentials: { deviceTokens: ['token_1', 'token_1', 'token_1'] }, oauthHandler: OAuthHandlerEnum.NOVU, isIdempotentOperation: true, }) ); const updatedSubscriber = await subscriberRepository.findBySubscriberId(session.environment._id, subscriberId); const addedFcmToken = updatedSubscriber?.channels?.find((channel) => channel.providerId === PushProviderIdEnum.FCM); expect(addedFcmToken?.credentials?.deviceTokens?.length).to.equal(1); expect(addedFcmToken?.credentials?.deviceTokens).to.deep.equal(['token_1']); }); it('should reject device tokens exceeding the system limit when creating a new channel', async () => { const subscriberService = new SubscribersService(session.organization._id, session.environment._id); const subscriber = await subscriberService.createSubscriber(); const tokens = Array.from({ length: SYSTEM_LIMITS.SUBSCRIBER_DEVICE_TOKENS + 1 }, (_, i) => `token_${i}`); try { await updateSubscriberChannelUsecase.execute( UpdateSubscriberChannelCommand.create({ organizationId: subscriber._organizationId, subscriberId: subscriber.subscriberId, environmentId: session.environment._id, providerId: PushProviderIdEnum.FCM, credentials: { deviceTokens: tokens }, oauthHandler: OAuthHandlerEnum.NOVU, isIdempotentOperation: true, }) ); expect.fail('Should have thrown BadRequestException'); } catch (error: any) { expect(error.response.message).to.contain('Device tokens limit exceeded'); expect(error.response.limit).to.equal(SYSTEM_LIMITS.SUBSCRIBER_DEVICE_TOKENS); } }); it('should reject device tokens exceeding the system limit when appending to existing channel', async () => { const subscriberService = new SubscribersService(session.organization._id, session.environment._id); const subscriber = await subscriberService.createSubscriber(); const initialTokens = Array.from({ length: 50 }, (_, i) => `token_${i}`); await updateSubscriberChannelUsecase.execute( UpdateSubscriberChannelCommand.create({ organizationId: subscriber._organizationId, subscriberId: subscriber.subscriberId, environmentId: session.environment._id, providerId: PushProviderIdEnum.FCM, credentials: { deviceTokens: initialTokens }, oauthHandler: OAuthHandlerEnum.NOVU, isIdempotentOperation: true, }) ); const additionalTokens = Array.from({ length: 60 }, (_, i) => `new_token_${i}`); try { await updateSubscriberChannelUsecase.execute( UpdateSubscriberChannelCommand.create({ organizationId: subscriber._organizationId, subscriberId: subscriber.subscriberId, environmentId: session.environment._id, providerId: PushProviderIdEnum.FCM, credentials: { deviceTokens: additionalTokens }, oauthHandler: OAuthHandlerEnum.NOVU, isIdempotentOperation: false, }) ); expect.fail('Should have thrown BadRequestException'); } catch (error: any) { expect(error.response.message).to.contain('Device tokens limit exceeded'); expect(error.response.limit).to.equal(SYSTEM_LIMITS.SUBSCRIBER_DEVICE_TOKENS); } }); it('should allow device tokens at exactly the system limit', async () => { const subscriberService = new SubscribersService(session.organization._id, session.environment._id); const subscriber = await subscriberService.createSubscriber(); const tokens = Array.from({ length: SYSTEM_LIMITS.SUBSCRIBER_DEVICE_TOKENS }, (_, i) => `token_${i}`); await updateSubscriberChannelUsecase.execute( UpdateSubscriberChannelCommand.create({ organizationId: subscriber._organizationId, subscriberId: subscriber.subscriberId, environmentId: session.environment._id, providerId: PushProviderIdEnum.FCM, credentials: { deviceTokens: tokens }, oauthHandler: OAuthHandlerEnum.NOVU, isIdempotentOperation: true, }) ); const updatedSubscriber = await subscriberRepository.findOne({ _id: subscriber._id, _environmentId: subscriber._environmentId, }); const fcmChannel = updatedSubscriber?.channels?.find((channel) => channel.providerId === PushProviderIdEnum.FCM); expect(fcmChannel?.credentials?.deviceTokens?.length).to.equal(SYSTEM_LIMITS.SUBSCRIBER_DEVICE_TOKENS); }); }); ================================================ FILE: apps/api/src/app/subscribers/usecases/bulk-create-subscribers/bulk-create-subscribers.command.ts ================================================ import { EnvironmentCommand } from '@novu/application-generic'; import { Type } from 'class-transformer'; import { ArrayMaxSize, ArrayNotEmpty, IsArray, ValidateNested } from 'class-validator'; import { CreateSubscriberRequestDto } from '../../dtos'; export class BulkCreateSubscribersCommand extends EnvironmentCommand { @IsArray() @ArrayNotEmpty() @ArrayMaxSize(500) @ValidateNested({ each: true }) @Type(() => CreateSubscriberRequestDto) subscribers: CreateSubscriberRequestDto[]; } ================================================ FILE: apps/api/src/app/subscribers/usecases/bulk-create-subscribers/bulk-create-subscribers.usecase.ts ================================================ import { BadRequestException, Injectable } from '@nestjs/common'; import { SubscriberRepository } from '@novu/dal'; import { BulkCreateSubscriberResponseDto } from '../../dtos/bulk-create-subscriber-response.dto'; import { BulkCreateSubscribersCommand } from './bulk-create-subscribers.command'; @Injectable() export class BulkCreateSubscribers { constructor(private subscriberRepository: SubscriberRepository) {} async execute(command: BulkCreateSubscribersCommand): Promise { try { return await this.subscriberRepository.bulkCreateSubscribers( command.subscribers, command.environmentId, command.organizationId ); } catch (e) { throw new BadRequestException(e.message); } } } ================================================ FILE: apps/api/src/app/subscribers/usecases/bulk-create-subscribers/index.ts ================================================ export { BulkCreateSubscribersCommand } from './bulk-create-subscribers.command'; export { BulkCreateSubscribers } from './bulk-create-subscribers.usecase'; ================================================ FILE: apps/api/src/app/subscribers/usecases/chat-oauth/chat-oauth.command.ts ================================================ import { BaseCommand } from '@novu/application-generic'; import { ChatProviderIdEnum } from '@novu/shared'; import { IsEnum, IsMongoId, IsOptional, IsString } from 'class-validator'; import { IsNotEmpty } from '../chat-oauth-callback/chat-oauth-callback.command'; export class ChatOauthCommand extends BaseCommand { @IsMongoId() @IsString() readonly environmentId: string; @IsNotEmpty() @IsEnum(ChatProviderIdEnum) readonly providerId: ChatProviderIdEnum; @IsNotEmpty() @IsString() readonly subscriberId: string; @IsOptional() @IsString() readonly integrationIdentifier?: string; readonly hmacHash?: string; } ================================================ FILE: apps/api/src/app/subscribers/usecases/chat-oauth/chat-oauth.usecase.ts ================================================ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { createHash } from '@novu/application-generic'; import { EnvironmentRepository, ICredentialsEntity, IntegrationEntity, IntegrationRepository } from '@novu/dal'; import { ChannelTypeEnum } from '@novu/stateless'; import { ChatOauthCommand } from './chat-oauth.command'; @Injectable() export class ChatOauth { readonly SLACK_OAUTH_URL = 'https://slack.com/oauth/v2/authorize?'; constructor( private integrationRepository: IntegrationRepository, private environmentRepository: EnvironmentRepository ) {} async execute(command: ChatOauthCommand): Promise { const { clientId, hmac } = await this.getCredentials(command); await this.hmacValidation({ credentialHmac: hmac, environmentId: command.environmentId, subscriberId: command.subscriberId, externalHmacHash: command.hmacHash, }); return this.getOAuthUrl(command.subscriberId, command.environmentId, clientId!, command.integrationIdentifier); } private async hmacValidation({ credentialHmac, environmentId, subscriberId, externalHmacHash, }: { credentialHmac: boolean | undefined; environmentId: string; subscriberId: string; externalHmacHash: string | undefined; }) { if (credentialHmac) { if (!externalHmacHash) { throw new BadRequestException( 'Hmac is enabled on the integration, please provide a HMAC hash on the request params' ); } const apiKey = await this.getEnvironmentApiKey(environmentId); validateEncryption({ apiKey, subscriberId, externalHmacHash, }); } } private getOAuthUrl( subscriberId: string, environmentId: string, clientId: string, integrationIdentifier?: string ): string { let redirectUri = `${ process.env.API_ROOT_URL }/v1/subscribers/${subscriberId}/credentials/slack/oauth/callback?environmentId=${environmentId}`; if (integrationIdentifier) { redirectUri = `${redirectUri}&integrationIdentifier=${integrationIdentifier}`; } return `${ this.SLACK_OAUTH_URL }client_id=${clientId}&scope=incoming-webhook&user_scope=&redirect_uri=${encodeURIComponent(redirectUri)}`; } private async getCredentials(command: ChatOauthCommand): Promise { const query: Partial & { _environmentId: string } = { _environmentId: command.environmentId, channel: ChannelTypeEnum.CHAT, providerId: command.providerId, }; if (command.integrationIdentifier) { query.identifier = command.integrationIdentifier; } const integration = await this.integrationRepository.findOne(query, undefined, { query: { sort: { createdAt: -1 } }, }); if (!integration) { throw new NotFoundException( `Integration in environment ${command.environmentId} was not found, channel: ${ChannelTypeEnum.CHAT}, ` + `providerId: ${command.providerId}` ); } if (!integration.credentials) { throw new NotFoundException( `Integration in environment ${command.environmentId} missing credentials, channel: ${ChannelTypeEnum.CHAT}, ` + `providerId: ${command.providerId}` ); } if (!integration.credentials.clientId) { throw new NotFoundException( `Integration in environment ${command.environmentId} missing clientId, channel: ${ChannelTypeEnum.CHAT}, ` + `providerId: ${command.providerId}` ); } return integration.credentials; } private async getEnvironmentApiKey(environmentId: string): Promise { const apiKeys = await this.environmentRepository.getApiKeys(environmentId); if (!apiKeys.length) { throw new NotFoundException(`Environment ID: ${environmentId} not found`); } return apiKeys[0].key; } } export function validateEncryption({ apiKey, subscriberId, externalHmacHash, }: { apiKey: string; subscriberId: string; externalHmacHash: string; }) { const hmacHash = createHash(apiKey, subscriberId); if (hmacHash !== externalHmacHash) { throw new BadRequestException('Hmac is enabled on the integration, please provide a valid HMAC hash'); } } ================================================ FILE: apps/api/src/app/subscribers/usecases/chat-oauth-callback/chat-oauth-callback.command.ts ================================================ import { BaseCommand } from '@novu/application-generic'; import { ChatProviderIdEnum } from '@novu/shared'; import { IsEnum, IsMongoId, IsOptional, IsString, registerDecorator, ValidationArguments, ValidationOptions, } from 'class-validator'; export function IsNotEmpty(validationOptions?: ValidationOptions) { return (object: object, propertyName: string) => { registerDecorator({ name: 'isNotEmpty', target: object.constructor, propertyName, options: validationOptions, validator: { validate(value: any, args: ValidationArguments) { return ![null, undefined, 'null', 'undefined', ''].some((invalidValue) => invalidValue === value); }, defaultMessage(data) { const value = data?.value === '' ? 'empty string' : data?.value; return `${data?.property} should not be ${value}`; }, }, }); }; } export class ChatOauthCallbackCommand extends BaseCommand { @IsMongoId() @IsString() readonly environmentId: string; @IsNotEmpty() @IsEnum(ChatProviderIdEnum) readonly providerId: ChatProviderIdEnum; @IsNotEmpty() @IsString() readonly subscriberId: string; @IsNotEmpty() @IsString() readonly providerCode: string; readonly hmacHash?: string; @IsOptional() @IsString() readonly integrationIdentifier?: string; } ================================================ FILE: apps/api/src/app/subscribers/usecases/chat-oauth-callback/chat-oauth-callback.result.ts ================================================ export enum ResponseTypeEnum { HTML = 'HTML', URL = 'URL', } export class ChatOauthCallbackResult { typeOfResponse: ResponseTypeEnum; resultString: string; } ================================================ FILE: apps/api/src/app/subscribers/usecases/chat-oauth-callback/chat-oauth-callback.usecase.ts ================================================ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { CreateOrUpdateSubscriberCommand, CreateOrUpdateSubscriberUseCase, decryptCredentials, FeatureFlagsService, IChannelCredentialsCommand, OAuthHandlerEnum, UpdateSubscriberChannel, UpdateSubscriberChannelCommand, } from '@novu/application-generic'; import { ChannelTypeEnum, EnvironmentEntity, EnvironmentRepository, IntegrationEntity, IntegrationRepository, } from '@novu/dal'; import { ENDPOINT_TYPES, FeatureFlagsKeysEnum, ICredentialsDto } from '@novu/shared'; import axios from 'axios'; import { CreateChannelEndpointCommand } from '../../../channel-endpoints/usecases/create-channel-endpoint/create-channel-endpoint.command'; import { CreateChannelEndpoint } from '../../../channel-endpoints/usecases/create-channel-endpoint/create-channel-endpoint.usecase'; import { validateEncryption } from '../chat-oauth/chat-oauth.usecase'; import { ChatOauthCallbackCommand } from './chat-oauth-callback.command'; import { ChatOauthCallbackResult, ResponseTypeEnum } from './chat-oauth-callback.result'; /** * @deprecated Use the new channel management approach. * @see channel-endpoints and channel-connections modules */ @Injectable() export class ChatOauthCallback { readonly SLACK_ACCESS_URL = 'https://slack.com/api/oauth.v2.access'; readonly SCRIPT_CLOSE_TAB = ''; constructor( private updateSubscriberChannelUsecase: UpdateSubscriberChannel, private integrationRepository: IntegrationRepository, private environmentRepository: EnvironmentRepository, private createSubscriberUsecase: CreateOrUpdateSubscriberUseCase, private createChannelEndpoint: CreateChannelEndpoint, private featureFlagsService: FeatureFlagsService ) {} async execute(command: ChatOauthCallbackCommand): Promise { const integration = await this.getIntegration(command); const integrationCredentials = integration.credentials; const { _organizationId, apiKeys } = await this.getEnvironment(command.environmentId); await this.hmacValidation({ credentialHmac: integrationCredentials.hmac, apiKey: apiKeys[0].key, subscriberId: command.subscriberId, externalHmacHash: command.hmacHash, }); const webhookUrl = await this.getWebhook(command, integrationCredentials); await this.createSubscriber(_organizationId, command, webhookUrl, integration); if (integrationCredentials && integrationCredentials.redirectUrl) { return { typeOfResponse: ResponseTypeEnum.URL, resultString: integrationCredentials.redirectUrl }; } return { typeOfResponse: ResponseTypeEnum.HTML, resultString: this.SCRIPT_CLOSE_TAB }; } private async createSubscriber( organizationId: string, command: ChatOauthCallbackCommand, webhookUrl: string, integration: IntegrationEntity ): Promise { await this.createSubscriberUsecase.execute( CreateOrUpdateSubscriberCommand.create({ organizationId, environmentId: command.environmentId, subscriberId: command?.subscriberId, }) ); const isSlackTeamsEnabled = await this.featureFlagsService.getFlag({ key: FeatureFlagsKeysEnum.IS_SLACK_TEAMS_ENABLED, defaultValue: false, environment: { _id: command.environmentId }, organization: { _id: organizationId }, }); if (isSlackTeamsEnabled) { await this.createChannelEndpoint.execute( CreateChannelEndpointCommand.create({ organizationId: organizationId, environmentId: command.environmentId, integrationIdentifier: integration.identifier, subscriberId: command.subscriberId, type: ENDPOINT_TYPES.WEBHOOK, endpoint: { url: webhookUrl, }, }) ); return; } const subscriberCredentials: IChannelCredentialsCommand = { webhookUrl, channel: command.providerId }; await this.updateSubscriberChannelUsecase.execute( UpdateSubscriberChannelCommand.create({ organizationId, environmentId: command.environmentId, subscriberId: command.subscriberId, providerId: command.providerId, integrationIdentifier: command.integrationIdentifier, credentials: subscriberCredentials, oauthHandler: OAuthHandlerEnum.NOVU, isIdempotentOperation: false, }) ); } private async getEnvironment(environmentId: string): Promise { const environment = await this.environmentRepository.findOne({ _id: environmentId }); if (environment == null) { throw new NotFoundException(`Environment ID: ${environmentId} not found`); } return environment; } private async getWebhook( command: ChatOauthCallbackCommand, integrationCredentials: ICredentialsDto ): Promise { let redirectUri = `${ process.env.API_ROOT_URL }/v1/subscribers/${command.subscriberId}/credentials/${command.providerId}/oauth/callback?environmentId=${command.environmentId}`; if (command.integrationIdentifier) { redirectUri = `${redirectUri}&integrationIdentifier=${command.integrationIdentifier}`; } const body = { redirect_uri: redirectUri, code: command.providerCode, client_id: integrationCredentials.clientId, client_secret: integrationCredentials.secretKey, }; const config = { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, }; const res = await axios.post(this.SLACK_ACCESS_URL, body, config); const webhook = res.data?.incoming_webhook?.url; if (res?.data?.ok === false) { const metaData = res?.data?.response_metadata?.messages?.join(', '); throw new BadRequestException( `Provider ${command.providerId} returned error ${res.data.error}${metaData ? `, metadata:${metaData}` : ''}` ); } if (!webhook) { throw new BadRequestException(`Provider ${command.providerId} did not return a webhook url`); } return webhook; } private async getIntegration(command: ChatOauthCallbackCommand) { const query: Partial & { _environmentId: string } = { _environmentId: command.environmentId, channel: ChannelTypeEnum.CHAT, providerId: command.providerId, }; if (command.integrationIdentifier) { query.identifier = command.integrationIdentifier; } const integration = await this.integrationRepository.findOne(query, undefined, { query: { sort: { createdAt: -1 } }, }); if (integration == null) { throw new NotFoundException( `Integration in environment ${command.environmentId} was not found, channel: ${ChannelTypeEnum.CHAT}, ` + `providerId: ${command.providerId}` ); } integration.credentials = decryptCredentials(integration.credentials); return integration; } private async hmacValidation({ credentialHmac, apiKey, subscriberId, externalHmacHash, }: { credentialHmac: boolean | undefined; apiKey: string; subscriberId: string; externalHmacHash: string | undefined; }) { if (credentialHmac) { if (!externalHmacHash) { throw new BadRequestException( 'Hmac is enabled on the integration, please provide a HMAC hash on the request params' ); } validateEncryption({ apiKey, subscriberId, externalHmacHash, }); } } } ================================================ FILE: apps/api/src/app/subscribers/usecases/chat-oauth-callback/is-not-empty.spec.ts ================================================ // noinspection ExceptionCaughtLocallyJS import { BaseCommand, CommandValidationException } from '@novu/application-generic'; import { expect } from 'chai'; import { IsNotEmpty } from './chat-oauth-callback.command'; function assertCommandValidationError(e: CommandValidationException, fieldName: string, fieldMsg: string) { if (!(e instanceof CommandValidationException)) { throw new Error(e); } if (!e.constraintsViolated) { throw e; } expect(e.constraintsViolated[fieldName].messages[0]).to.equal(fieldMsg); } describe('@IsNotEmpty() validator', () => { it('should create command with string name', async () => { const validateNameCommand = IsNotEmptyNameCommand.create({ name: 'mike' }); expect(validateNameCommand.name).to.equal('mike'); }); it('should throw exception on string null', async () => { const noValidation = NameCommand.create({ name: 'null' } as any); try { IsNotEmptyNameCommand.create({ name: 'null' } as any); throw new Error('should not have passed validation'); } catch (e) { assertCommandValidationError(e, 'name', 'name should not be null'); } }); it('should throw exception on undefined', async () => { const noValidation = NameCommand.create({ name: undefined } as any); try { const validateNameCommand = IsNotEmptyNameCommand.create({ name: undefined } as any); throw new Error('should not have passed validation'); } catch (e) { assertCommandValidationError(e, 'name', 'name should not be undefined'); } }); it('should throw exception on undefined null', async () => { const noValidation = NameCommand.create({ name: 'undefined' } as any); try { IsNotEmptyNameCommand.create({ name: 'undefined' } as any); throw new Error('should not have passed validation'); } catch (e) { assertCommandValidationError(e, 'name', 'name should not be undefined'); } }); it('should throw exception on empty string', async () => { const noValidation = NameCommand.create({ name: '' }); try { IsNotEmptyNameCommand.create({ name: '' }); throw new Error('should not have passed validation'); } catch (e) { assertCommandValidationError(e, 'name', 'name should not be empty string'); } }); }); export class IsNotEmptyNameCommand extends BaseCommand { @IsNotEmpty() name?: string; } export class NameCommand extends BaseCommand { name: string; } ================================================ FILE: apps/api/src/app/subscribers/usecases/delete-subscriber-credentials/delete-subscriber-credentials.command.ts ================================================ import { ChatProviderIdEnum, PushProviderIdEnum } from '@novu/shared'; import { IsNotEmpty, IsString } from 'class-validator'; import { EnvironmentCommand } from '../../../shared/commands/project.command'; export class DeleteSubscriberCredentialsCommand extends EnvironmentCommand { @IsString() @IsNotEmpty() subscriberId: string; @IsString() @IsNotEmpty() providerId: string; } ================================================ FILE: apps/api/src/app/subscribers/usecases/delete-subscriber-credentials/delete-subscriber-credentials.spec.ts ================================================ import { Test } from '@nestjs/testing'; import { OAuthHandlerEnum, UpdateSubscriberChannel, UpdateSubscriberChannelCommand } from '@novu/application-generic'; import { SubscriberRepository } from '@novu/dal'; import { ChannelTypeEnum, ChatProviderIdEnum, PushProviderIdEnum } from '@novu/shared'; import { SubscribersService, UserSession } from '@novu/testing'; import { expect } from 'chai'; import { CheckIntegration } from '../../../integrations/usecases/check-integration/check-integration.usecase'; import { CheckIntegrationEMail } from '../../../integrations/usecases/check-integration/check-integration-email.usecase'; import { CreateIntegrationCommand } from '../../../integrations/usecases/create-integration/create-integration.command'; import { CreateIntegration } from '../../../integrations/usecases/create-integration/create-integration.usecase'; import { SharedModule } from '../../../shared/shared.module'; import { GetSubscriber } from '../get-subscriber/get-subscriber.usecase'; import { DeleteSubscriberCredentialsCommand } from './delete-subscriber-credentials.command'; import { DeleteSubscriberCredentials } from './delete-subscriber-credentials.usecase'; describe('Delete subscriber provider credentials', () => { let createIntegrationUseCase: CreateIntegration; let updateSubscriberChannelUsecase: UpdateSubscriberChannel; let deleteSubscriberCredentialsUsecase: DeleteSubscriberCredentials; let session: UserSession; const subscriberRepository = new SubscriberRepository(); beforeEach(async () => { const moduleRef = await Test.createTestingModule({ imports: [SharedModule], providers: [ DeleteSubscriberCredentials, UpdateSubscriberChannel, GetSubscriber, CreateIntegration, CheckIntegration, CheckIntegrationEMail, ], }).compile(); session = new UserSession(); await session.initialize(); updateSubscriberChannelUsecase = moduleRef.get(UpdateSubscriberChannel); deleteSubscriberCredentialsUsecase = moduleRef.get(DeleteSubscriberCredentials); createIntegrationUseCase = moduleRef.get(CreateIntegration); }); it('should delete subscriber discord provider credentials', async () => { const subscriberService = new SubscribersService(session.organization._id, session.environment._id); const subscriber = await subscriberService.createSubscriber(); const fcmTokens = ['token1', 'token2']; const firstDiscordIntegration = await createIntegrationUseCase.execute( CreateIntegrationCommand.create({ organizationId: subscriber._organizationId, environmentId: session.environment._id, channel: ChannelTypeEnum.CHAT, credentials: {}, providerId: ChatProviderIdEnum.Discord, active: true, check: false, userId: session.user._id, }) ); const secondDiscordIntegration = await createIntegrationUseCase.execute( CreateIntegrationCommand.create({ organizationId: subscriber._organizationId, environmentId: session.environment._id, channel: ChannelTypeEnum.CHAT, credentials: {}, providerId: ChatProviderIdEnum.Discord, active: true, check: false, userId: session.user._id, }) ); await updateSubscriberChannelUsecase.execute( UpdateSubscriberChannelCommand.create({ organizationId: subscriber._organizationId, subscriberId: subscriber.subscriberId, environmentId: session.environment._id, providerId: ChatProviderIdEnum.Discord, credentials: { webhookUrl: 'newWebhookUrl' }, integrationIdentifier: firstDiscordIntegration.identifier, oauthHandler: OAuthHandlerEnum.NOVU, isIdempotentOperation: false, }) ); await updateSubscriberChannelUsecase.execute( UpdateSubscriberChannelCommand.create({ organizationId: subscriber._organizationId, subscriberId: subscriber.subscriberId, environmentId: session.environment._id, providerId: ChatProviderIdEnum.Discord, credentials: { webhookUrl: 'newWebhookUrl' }, integrationIdentifier: secondDiscordIntegration.identifier, oauthHandler: OAuthHandlerEnum.NOVU, isIdempotentOperation: false, }) ); const fcmUpdate = await updateSubscriberChannelUsecase.execute( UpdateSubscriberChannelCommand.create({ organizationId: subscriber._organizationId, subscriberId: subscriber.subscriberId, environmentId: session.environment._id, providerId: PushProviderIdEnum.FCM, credentials: { deviceTokens: fcmTokens }, oauthHandler: OAuthHandlerEnum.NOVU, isIdempotentOperation: false, }) ); let updatedSubscriber = await subscriberRepository.findOne({ _id: subscriber._id, _environmentId: subscriber._environmentId, }); const newDiscordProviders = updatedSubscriber?.channels?.filter( (channel) => channel.providerId === ChatProviderIdEnum.Discord ); expect(newDiscordProviders?.length).to.equal(2); await deleteSubscriberCredentialsUsecase.execute( DeleteSubscriberCredentialsCommand.create({ organizationId: subscriber._organizationId, subscriberId: subscriber.subscriberId, environmentId: session.environment._id, providerId: ChatProviderIdEnum.Discord, }) ); updatedSubscriber = await subscriberRepository.findOne({ _id: subscriber._id, _environmentId: subscriber._environmentId, }); const areDiscordProviderIntegrationsDeleted = updatedSubscriber?.channels?.find( (channel) => channel.providerId === ChatProviderIdEnum.Discord ); const fcmCredentials = updatedSubscriber?.channels?.find( (channel) => channel.providerId === PushProviderIdEnum.FCM ); expect(areDiscordProviderIntegrationsDeleted).to.equal(undefined); expect(fcmCredentials?.credentials.deviceTokens).to.deep.equal(['identifier', ...fcmTokens]); }); }); ================================================ FILE: apps/api/src/app/subscribers/usecases/delete-subscriber-credentials/delete-subscriber-credentials.usecase.ts ================================================ import { Injectable } from '@nestjs/common'; import { AnalyticsService, buildSubscriberKey, InvalidateCacheService } from '@novu/application-generic'; import { SubscriberRepository } from '@novu/dal'; import { GetSubscriber, GetSubscriberCommand } from '../get-subscriber'; import { DeleteSubscriberCredentialsCommand } from './delete-subscriber-credentials.command'; @Injectable() export class DeleteSubscriberCredentials { constructor( private invalidateCache: InvalidateCacheService, private subscriberRepository: SubscriberRepository, private analyticsService: AnalyticsService, private getSubscriberUseCase: GetSubscriber ) {} async execute(command: DeleteSubscriberCredentialsCommand): Promise { const foundSubscriber = await this.getSubscriberUseCase.execute( GetSubscriberCommand.create({ ...command, }) ); await this.deleteSubscriberCredentialsOfOneProvider( foundSubscriber.subscriberId, command.environmentId, command.providerId, foundSubscriber._id ); this.analyticsService.mixpanelTrack('Delete Subscriber Credentials - [Subscribers]', '', { providerId: command.providerId, _organization: command.organizationId, _subscriberId: foundSubscriber._id, }); } private async deleteSubscriberCredentialsOfOneProvider( subscriberId: string, environmentId: string, providerId: string, _subscriberId: string ) { await this.invalidateCache.invalidateByKey({ key: buildSubscriberKey({ subscriberId, _environmentId: environmentId, }), }); return await this.subscriberRepository.updateOne( { _id: _subscriberId, _environmentId: environmentId, }, { $pull: { channels: { providerId } } } ); } } ================================================ FILE: apps/api/src/app/subscribers/usecases/delete-subscriber-credentials/index.ts ================================================ export * from './delete-subscriber-credentials.command'; export * from './delete-subscriber-credentials.usecase'; ================================================ FILE: apps/api/src/app/subscribers/usecases/get-preferences-by-level/get-preferences-by-level.command.ts ================================================ import { PreferenceLevelEnum } from '@novu/shared'; import { IsBoolean, IsDefined, IsEnum, IsOptional, IsString } from 'class-validator'; import { EnvironmentCommand } from '../../../shared/commands/project.command'; export class GetPreferencesByLevelCommand extends EnvironmentCommand { @IsString() @IsDefined() subscriberId: string; @IsEnum(PreferenceLevelEnum) @IsDefined() level: PreferenceLevelEnum; @IsBoolean() @IsDefined() includeInactiveChannels: boolean; } ================================================ FILE: apps/api/src/app/subscribers/usecases/get-preferences-by-level/get-preferences-by-level.usecase.ts ================================================ import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { FeatureFlagsService } from '@novu/application-generic'; import { FeatureFlagsKeysEnum, PreferenceLevelEnum, WorkflowCriticalityEnum } from '@novu/shared'; import { GetSubscriberGlobalPreference, GetSubscriberGlobalPreferenceCommand, } from '../get-subscriber-global-preference'; import { GetSubscriberPreference, GetSubscriberPreferenceCommand } from '../get-subscriber-preference'; import { GetPreferencesByLevelCommand } from './get-preferences-by-level.command'; @Injectable() export class GetPreferencesByLevel { constructor( private getSubscriberPreferenceUsecase: GetSubscriberPreference, private getSubscriberGlobalPreference: GetSubscriberGlobalPreference, private featureFlagsService: FeatureFlagsService ) {} async execute(command: GetPreferencesByLevelCommand) { const isGetPreferencesDisabled = await this.featureFlagsService.getFlag({ key: FeatureFlagsKeysEnum.IS_GET_PREFERENCES_DISABLED, defaultValue: false, organization: { _id: command.organizationId }, environment: { _id: command.environmentId }, }); if (isGetPreferencesDisabled) { throw new ServiceUnavailableException('Get preferences service is currently unavailable'); } if (command.level === PreferenceLevelEnum.GLOBAL) { const globalPreferenceCommand = GetSubscriberGlobalPreferenceCommand.create({ organizationId: command.organizationId, environmentId: command.environmentId, subscriberId: command.subscriberId, includeInactiveChannels: command.includeInactiveChannels, }); const globalPreferences = await this.getSubscriberGlobalPreference.execute(globalPreferenceCommand); return [globalPreferences]; } const preferenceCommand = GetSubscriberPreferenceCommand.create({ organizationId: command.organizationId, environmentId: command.environmentId, subscriberId: command.subscriberId, includeInactiveChannels: command.includeInactiveChannels, criticality: WorkflowCriticalityEnum.NON_CRITICAL, }); return await this.getSubscriberPreferenceUsecase.execute(preferenceCommand); } } ================================================ FILE: apps/api/src/app/subscribers/usecases/get-subscriber/get-subscriber.command.ts ================================================ import { IsBoolean, IsDefined, IsOptional, IsString } from 'class-validator'; import { EnvironmentCommand } from '../../../shared/commands/project.command'; export class GetSubscriberCommand extends EnvironmentCommand { @IsString() @IsDefined() subscriberId: string; @IsBoolean() @IsOptional() includeTopics?: boolean; } ================================================ FILE: apps/api/src/app/subscribers/usecases/get-subscriber/get-subscriber.spec.ts ================================================ import { NotFoundException } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { SubscribersService, UserSession } from '@novu/testing'; import { expect } from 'chai'; import { SharedModule } from '../../../shared/shared.module'; import { SubscribersV1Module } from '../../subscribersV1.module'; import { GetSubscriberCommand } from './get-subscriber.command'; import { GetSubscriber } from './get-subscriber.usecase'; describe('Get Subscriber', () => { let useCase: GetSubscriber; let session: UserSession; beforeEach(async () => { const moduleRef = await Test.createTestingModule({ imports: [SharedModule, SubscribersV1Module], providers: [], }).compile(); session = new UserSession(); await session.initialize(); useCase = moduleRef.get(GetSubscriber); }); it('should get a subscriber', async () => { const subscriberService = new SubscribersService(session.organization._id, session.environment._id); const subscriber = await subscriberService.createSubscriber(); const res = await useCase.execute( GetSubscriberCommand.create({ subscriberId: subscriber.subscriberId, environmentId: session.environment._id, organizationId: session.organization._id, }) ); expect(res.subscriberId).to.equal(subscriber.subscriberId); }); it('should get a not found exception if subscriber does not exist', async () => { const subscriberService = new SubscribersService(session.organization._id, session.environment._id); try { await useCase.execute( GetSubscriberCommand.create({ subscriberId: 'invalid-subscriber-id', environmentId: session.environment._id, organizationId: session.organization._id, }) ); throw new Error('Should not reach here'); } catch (e) { expect(e).to.be.instanceOf(NotFoundException); expect(e.message).to.eql("Subscriber 'invalid-subscriber-id' was not found"); } }); }); ================================================ FILE: apps/api/src/app/subscribers/usecases/get-subscriber/get-subscriber.usecase.ts ================================================ import { Injectable, NotFoundException } from '@nestjs/common'; import { buildSubscriberKey, CachedResponse } from '@novu/application-generic'; import { SubscriberEntity, SubscriberRepository, TopicSubscribersRepository } from '@novu/dal'; import { GetSubscriberCommand } from './get-subscriber.command'; @Injectable() export class GetSubscriber { constructor( private subscriberRepository: SubscriberRepository, private topicSubscriberRepository: TopicSubscribersRepository ) {} async execute(command: GetSubscriberCommand): Promise { const { environmentId, subscriberId, includeTopics } = command; const subscribePromise = this.fetchSubscriber({ _environmentId: environmentId, subscriberId }); const subscriberTopicsPromise = includeTopics ? this.fetchSubscriberTopics({ _environmentId: environmentId, subscriberId }) : null; const [subscriber, topics] = await Promise.all([subscribePromise, subscriberTopicsPromise]); if (!subscriber) { throw new NotFoundException(`Subscriber '${subscriberId}' was not found`); } if (includeTopics) { subscriber.topics = topics || []; } return subscriber; } @CachedResponse({ builder: (command: { subscriberId: string; _environmentId: string }) => buildSubscriberKey({ _environmentId: command._environmentId, subscriberId: command.subscriberId, }), }) private async fetchSubscriber({ subscriberId, _environmentId, }: { subscriberId: string; _environmentId: string; }): Promise { return await this.subscriberRepository.findBySubscriberId(_environmentId, subscriberId); } private async fetchSubscriberTopics({ subscriberId, _environmentId, }: { subscriberId: string; _environmentId: string; }): Promise { return await this.topicSubscriberRepository._model.distinct('topicKey', { _environmentId, externalSubscriberId: subscriberId, }); } } ================================================ FILE: apps/api/src/app/subscribers/usecases/get-subscriber/index.ts ================================================ export * from './get-subscriber.command'; export * from './get-subscriber.usecase'; ================================================ FILE: apps/api/src/app/subscribers/usecases/get-subscriber-global-preference/get-subscriber-global-preference.command.ts ================================================ import { NotificationTemplateEntity, SubscriberEntity } from '@novu/dal'; import { IsBoolean, IsDefined, IsOptional } from 'class-validator'; import { EnvironmentWithSubscriber } from '../../../shared/commands/project.command'; export class GetSubscriberGlobalPreferenceCommand extends EnvironmentWithSubscriber { @IsBoolean() @IsDefined() includeInactiveChannels: boolean; @IsOptional() subscriber?: Pick; @IsOptional() workflowList?: NotificationTemplateEntity[]; } ================================================ FILE: apps/api/src/app/subscribers/usecases/get-subscriber-global-preference/get-subscriber-global-preference.usecase.ts ================================================ import { Injectable, NotFoundException } from '@nestjs/common'; import { buildSubscriberKey, CachedResponse, filteredPreference, GetPreferences, Instrument, InstrumentUsecase, } from '@novu/application-generic'; import { NotificationTemplateEntity, NotificationTemplateRepository, SubscriberEntity, SubscriberRepository, } from '@novu/dal'; import { ChannelTypeEnum, IPreferenceChannels, Schedule } from '@novu/shared'; import { GetSubscriberGlobalPreferenceCommand } from './get-subscriber-global-preference.command'; @Injectable() export class GetSubscriberGlobalPreference { constructor( private subscriberRepository: SubscriberRepository, private getPreferences: GetPreferences, private notificationTemplateRepository: NotificationTemplateRepository ) {} @InstrumentUsecase() async execute( command: GetSubscriberGlobalPreferenceCommand ): Promise<{ preference: { enabled: boolean; channels: IPreferenceChannels; schedule?: Schedule } }> { const subscriber = command.subscriber ?? (await this.getSubscriber(command)); const activeChannels = await this.getActiveChannels(command); const subscriberGlobalPreference = await this.getPreferences.getSubscriberGlobalPreference({ environmentId: command.environmentId, organizationId: command.organizationId, subscriberId: subscriber._id, contextKeys: command.contextKeys, }); const channelsWithDefaults = this.buildDefaultPreferences(subscriberGlobalPreference.channels); let channels: IPreferenceChannels; if (command.includeInactiveChannels === true) { channels = channelsWithDefaults; } else { channels = filteredPreference(channelsWithDefaults, activeChannels); } return { preference: { enabled: subscriberGlobalPreference.enabled, channels, schedule: subscriberGlobalPreference.schedule, }, }; } @Instrument() private async getActiveChannels(command: GetSubscriberGlobalPreferenceCommand): Promise { if (command.includeInactiveChannels) { return Object.values(ChannelTypeEnum); } const workflowList = command.workflowList ?? (await this.notificationTemplateRepository.filterActive({ organizationId: command.organizationId, environmentId: command.environmentId, tags: undefined, critical: undefined, severity: undefined, select: '_id steps.active steps._templateId', limit: 100, })); const activeChannels = new Set(); for (const workflow of workflowList) { const workflowChannels = this.getChannels(workflow, command.includeInactiveChannels); for (const channel of workflowChannels) { activeChannels.add(channel); } } return Array.from(activeChannels); } private getChannels(workflow: NotificationTemplateEntity, includeInactiveChannels: boolean): ChannelTypeEnum[] { if (includeInactiveChannels) { return Object.values(ChannelTypeEnum); } const channelSet = new Set(); for (const step of workflow.steps) { if (step.active && step.template?.type) { channelSet.add(step.template.type as unknown as ChannelTypeEnum); } } return Array.from(channelSet); } private async getSubscriber(command: GetSubscriberGlobalPreferenceCommand): Promise> { const subscriber = await this.subscriberRepository.findBySubscriberId( command.environmentId, command.subscriberId, false, '_id' ); if (!subscriber) { throw new NotFoundException(`Subscriber ${command.subscriberId} not found`); } return subscriber; } // adds default state for missing channels private buildDefaultPreferences(preference: IPreferenceChannels) { const defaultPreference: IPreferenceChannels = { email: true, sms: true, in_app: true, chat: true, push: true, }; return { ...defaultPreference, ...preference }; } } ================================================ FILE: apps/api/src/app/subscribers/usecases/get-subscriber-global-preference/index.ts ================================================ export * from './get-subscriber-global-preference.command'; export * from './get-subscriber-global-preference.usecase'; ================================================ FILE: apps/api/src/app/subscribers/usecases/get-subscriber-preference/get-subscriber-preference.command.ts ================================================ import { EnvironmentWithSubscriber } from '@novu/application-generic'; import { NotificationTemplateEntity, SubscriberEntity } from '@novu/dal'; import { SeverityLevelEnum, WorkflowCriticalityEnum } from '@novu/shared'; import { IsArray, IsBoolean, IsDefined, IsEnum, IsOptional, IsString } from 'class-validator'; export class GetSubscriberPreferenceCommand extends EnvironmentWithSubscriber { @IsOptional() @IsArray() @IsString({ each: true }) tags?: string[]; @IsOptional() @IsArray() @IsEnum(SeverityLevelEnum, { each: true }) severity?: SeverityLevelEnum[]; @IsBoolean() @IsDefined() includeInactiveChannels: boolean; @IsEnum(WorkflowCriticalityEnum) @IsOptional() criticality: WorkflowCriticalityEnum; @IsOptional() subscriber?: Pick; @IsOptional() workflowList?: NotificationTemplateEntity[]; } ================================================ FILE: apps/api/src/app/subscribers/usecases/get-subscriber-preference/get-subscriber-preference.usecase.ts ================================================ import { Injectable, NotFoundException } from '@nestjs/common'; import { FeatureFlagsService, filteredPreference, GetPreferences, GetPreferencesResponseDto, InMemoryLRUCacheService, InMemoryLRUCacheStore, Instrument, InstrumentUsecase, MergePreferences, MergePreferencesCommand, mapTemplateConfiguration, overridePreferences, PreferenceSet, } from '@novu/application-generic'; import { NotificationTemplateEntity, NotificationTemplateRepository, PreferencesEntity, PreferencesRepository, SubscriberEntity, SubscriberRepository, } from '@novu/dal'; import { ChannelTypeEnum, FeatureFlagsKeysEnum, IPreferenceChannels, ISubscriberPreferenceResponse, PreferencesTypeEnum, SeverityLevelEnum, WorkflowCriticalityEnum, } from '@novu/shared'; import { chunk } from 'es-toolkit'; import { GetSubscriberPreferenceCommand } from './get-subscriber-preference.command'; @Injectable() export class GetSubscriberPreference { constructor( private subscriberRepository: SubscriberRepository, private notificationTemplateRepository: NotificationTemplateRepository, private preferencesRepository: PreferencesRepository, private featureFlagsService: FeatureFlagsService, private inMemoryLRUCacheService: InMemoryLRUCacheService ) {} @InstrumentUsecase() async execute(command: GetSubscriberPreferenceCommand): Promise { const subscriber: Pick | null = command.subscriber ?? (await this.subscriberRepository.findBySubscriberId(command.environmentId, command.subscriberId, false, '_id')); if (!subscriber) { throw new NotFoundException(`Subscriber with id: ${command.subscriberId} not found`); } const workflowList = command.workflowList ?? (await this.getActiveWorkflows({ organizationId: command.organizationId, environmentId: command.environmentId, tags: command.tags, severity: command.severity, })); const workflowIds = workflowList.map((wf) => wf._id); const { workflowResourcePreferences, workflowUserPreferences, subscriberWorkflowPreferences, subscriberGlobalPreference, } = await this.findAllPreferences({ environmentId: command.environmentId, organizationId: command.organizationId, contextKeys: command.contextKeys, subscriberId: subscriber._id, workflowIds, }); const allWorkflowPreferences = [ ...workflowResourcePreferences, ...workflowUserPreferences, ...subscriberWorkflowPreferences, ]; const workflowPreferenceSets = allWorkflowPreferences.reduce>((acc, preference) => { const workflowId = preference._templateId; // Skip if the preference is not for a workflow if (workflowId === undefined) { return acc; } if (!acc[workflowId]) { acc[workflowId] = { workflowResourcePreference: undefined, workflowUserPreference: undefined, subscriberWorkflowPreference: undefined, }; } switch (preference.type) { case PreferencesTypeEnum.WORKFLOW_RESOURCE: acc[workflowId].workflowResourcePreference = preference as PreferenceSet['workflowResourcePreference']; break; case PreferencesTypeEnum.USER_WORKFLOW: acc[workflowId].workflowUserPreference = preference as PreferenceSet['workflowUserPreference']; break; case PreferencesTypeEnum.SUBSCRIBER_WORKFLOW: acc[workflowId].subscriberWorkflowPreference = preference; break; default: } return acc; }, {}); const workflowPreferences = await this.calculateWorkflowPreferences( workflowList, workflowPreferenceSets, subscriberGlobalPreference, command.includeInactiveChannels ); const nonCriticalWorkflowPreferences = workflowPreferences.filter( (preference): preference is ISubscriberPreferenceResponse => { if (preference === undefined) { return false; } if (command.criticality === WorkflowCriticalityEnum.ALL) { return true; } if (command.criticality === WorkflowCriticalityEnum.CRITICAL) { return preference.template.critical === true; } return preference.template.critical === false; } ); return nonCriticalWorkflowPreferences; } @Instrument() private async calculateWorkflowPreferences( workflowList: NotificationTemplateEntity[], workflowPreferenceSets: Record, subscriberGlobalPreference: PreferencesEntity | null, includeInactiveChannels: boolean ): Promise<(ISubscriberPreferenceResponse | undefined)[]> { const chunkSize = 30; const results: (ISubscriberPreferenceResponse | undefined)[] = []; const chunks = chunk(workflowList, chunkSize); for (const chunk of chunks) { // Use setImmediate to yield to the event loop between chunks await new Promise((resolve) => { setImmediate(() => resolve()); }); const chunkResults = chunk .map((workflow) => { const preferences = workflowPreferenceSets[workflow._id]; if (!preferences) { return null; } const merged = this.mergePreferences(preferences, subscriberGlobalPreference); const includedChannels = this.getChannels(workflow, includeInactiveChannels); const initialChannels = filteredPreference( { email: true, sms: true, in_app: true, chat: true, push: true, }, includedChannels ); const { channels, overrides } = this.calculateChannelsAndOverrides(merged, initialChannels); const preference: ISubscriberPreferenceResponse = { preference: { channels, enabled: true, overrides, ...(preferences.subscriberWorkflowPreference?.updatedAt && { updatedAt: preferences.subscriberWorkflowPreference.updatedAt, }), }, template: mapTemplateConfiguration({ ...workflow, critical: merged.preferences.all.readOnly, }), type: PreferencesTypeEnum.SUBSCRIBER_WORKFLOW, }; return preference; }) .filter((item): item is ISubscriberPreferenceResponse => item !== null); results.push(...chunkResults); } return results; } @Instrument() private calculateChannelsAndOverrides(merged: GetPreferencesResponseDto, initialChannels: IPreferenceChannels) { return overridePreferences( { template: GetPreferences.mapWorkflowPreferencesToChannelPreferences(merged.source.WORKFLOW_RESOURCE), subscriber: GetPreferences.mapWorkflowPreferencesToChannelPreferences(merged.preferences), workflowOverride: {}, }, initialChannels ); } @Instrument() private mergePreferences(preferences: PreferenceSet, subscriberGlobalPreference: PreferencesEntity | null) { const mergeCommand = MergePreferencesCommand.create({ workflowResourcePreference: preferences.workflowResourcePreference, workflowUserPreference: preferences.workflowUserPreference, subscriberWorkflowPreference: preferences.subscriberWorkflowPreference, ...(subscriberGlobalPreference ? { subscriberGlobalPreference } : {}), }); return MergePreferences.execute(mergeCommand); } private getChannels(workflow: NotificationTemplateEntity, includeInactiveChannels: boolean): ChannelTypeEnum[] { if (includeInactiveChannels) { return Object.values(ChannelTypeEnum); } const channelSet = new Set(); for (const step of workflow.steps) { if (step.active && step.template?.type) { channelSet.add(step.template.type as unknown as ChannelTypeEnum); } } return Array.from(channelSet); } @Instrument() private async findAllPreferences({ environmentId, organizationId, subscriberId, workflowIds, contextKeys, }: { environmentId: string; organizationId: string; subscriberId: string; workflowIds: string[]; contextKeys?: string[]; }) { const baseQuery = { _environmentId: environmentId, _organizationId: organizationId, }; const readOptions = { readPreference: 'secondaryPreferred' as const }; const useContextFiltering = await this.featureFlagsService.getFlag({ key: FeatureFlagsKeysEnum.IS_CONTEXT_PREFERENCES_ENABLED, defaultValue: false, organization: { _id: organizationId }, }); const contextQuery = this.preferencesRepository.buildContextExactMatchQuery(contextKeys, { enabled: useContextFiltering, }); const [ workflowResourcePreferences, workflowUserPreferences, subscriberWorkflowPreferences, subscriberGlobalPreferences, ] = await Promise.all([ this.preferencesRepository.find( { ...baseQuery, _templateId: { $in: workflowIds }, type: PreferencesTypeEnum.WORKFLOW_RESOURCE, }, undefined, readOptions ), this.preferencesRepository.find( { ...baseQuery, _templateId: { $in: workflowIds }, type: PreferencesTypeEnum.USER_WORKFLOW, }, undefined, readOptions ), this.preferencesRepository.find( { ...baseQuery, _subscriberId: subscriberId, _templateId: { $in: workflowIds }, type: PreferencesTypeEnum.SUBSCRIBER_WORKFLOW, ...contextQuery, }, undefined, readOptions ), this.preferencesRepository.find( { ...baseQuery, _subscriberId: subscriberId, type: PreferencesTypeEnum.SUBSCRIBER_GLOBAL, ...contextQuery, }, undefined, readOptions ), ]); return { workflowResourcePreferences, workflowUserPreferences, subscriberWorkflowPreferences, subscriberGlobalPreference: subscriberGlobalPreferences[0] ?? null, }; } @Instrument() private async getActiveWorkflows({ organizationId, environmentId, tags, severity, }: { organizationId: string; environmentId: string; tags?: string[]; severity?: SeverityLevelEnum[]; }): Promise { const cacheKey = `${organizationId}:${environmentId}`; const cacheVariant = this.buildCacheVariant(tags, severity); return this.inMemoryLRUCacheService.get( InMemoryLRUCacheStore.ACTIVE_WORKFLOWS, cacheKey, () => this.notificationTemplateRepository.filterActive({ organizationId, environmentId, tags, severity, }), { organizationId, environmentId, cacheVariant, } ); } private buildCacheVariant(tags?: string[], severity?: SeverityLevelEnum[]): string { const filters = { ...(tags && tags.length > 0 && { tags: [...tags].sort() }), ...(severity && severity.length > 0 && { severity: [...severity].sort() }), }; return Object.keys(filters).length > 0 ? JSON.stringify(filters) : 'default'; } } ================================================ FILE: apps/api/src/app/subscribers/usecases/get-subscriber-preference/index.ts ================================================ export * from './get-subscriber-preference.command'; export * from './get-subscriber-preference.usecase'; ================================================ FILE: apps/api/src/app/subscribers/usecases/get-subscribers/get-subscribers.command.ts ================================================ import { IsNumber, IsOptional } from 'class-validator'; import { EnvironmentCommand } from '../../../shared/commands/project.command'; export class GetSubscribersCommand extends EnvironmentCommand { @IsNumber() @IsOptional() page: number; @IsNumber() @IsOptional() limit: number; } ================================================ FILE: apps/api/src/app/subscribers/usecases/get-subscribers/get-subscribers.usecase.ts ================================================ import { Injectable } from '@nestjs/common'; import { SubscriberRepository } from '@novu/dal'; import { GetSubscribersCommand } from './get-subscribers.command'; @Injectable() export class GetSubscribers { constructor(private subscriberRepository: SubscriberRepository) {} async execute(command: GetSubscribersCommand) { const query = { _environmentId: command.environmentId, _organizationId: command.organizationId, }; const data = await this.subscriberRepository.find(query, '', { limit: command.limit, skip: command.page * command.limit, }); return { page: command.page, hasMore: data?.length === command.limit, pageSize: command.limit, data, }; } } ================================================ FILE: apps/api/src/app/subscribers/usecases/get-subscribers/index.ts ================================================ export * from './get-subscribers.command'; export * from './get-subscribers.usecase'; ================================================ FILE: apps/api/src/app/subscribers/usecases/index.ts ================================================ import { CreateOrUpdateSubscriberUseCase, GetSubscriberTemplatePreference, GetWorkflowByIdsUseCase, UpdateSubscriber, UpdateSubscriberChannel, } from '@novu/application-generic'; import { UpdatePreferences } from '../../inbox/usecases/update-preferences/update-preferences.usecase'; import { CheckIntegration } from '../../integrations/usecases/check-integration/check-integration.usecase'; import { CheckIntegrationEMail } from '../../integrations/usecases/check-integration/check-integration-email.usecase'; import { CreateIntegration } from '../../integrations/usecases/create-integration/create-integration.usecase'; import { BulkCreateSubscribers } from './bulk-create-subscribers/bulk-create-subscribers.usecase'; import { ChatOauth } from './chat-oauth/chat-oauth.usecase'; import { ChatOauthCallback } from './chat-oauth-callback/chat-oauth-callback.usecase'; import { DeleteSubscriberCredentials } from './delete-subscriber-credentials/delete-subscriber-credentials.usecase'; import { GetPreferencesByLevel } from './get-preferences-by-level/get-preferences-by-level.usecase'; import { GetSubscriber } from './get-subscriber'; import { GetSubscriberGlobalPreference } from './get-subscriber-global-preference/get-subscriber-global-preference.usecase'; import { GetSubscriberPreference } from './get-subscriber-preference/get-subscriber-preference.usecase'; import { GetSubscribers } from './get-subscribers'; import { RemoveSubscriber } from './remove-subscriber'; import { SearchByExternalSubscriberIds } from './search-by-external-subscriber-ids'; import { UpdateSubscriberOnlineFlag } from './update-subscriber-online-flag'; export { SearchByExternalSubscriberIds, SearchByExternalSubscriberIdsCommand, } from './search-by-external-subscriber-ids'; export const USE_CASES = [ CreateOrUpdateSubscriberUseCase, GetSubscribers, GetSubscriber, GetSubscriberPreference, GetSubscriberTemplatePreference, GetPreferencesByLevel, RemoveSubscriber, SearchByExternalSubscriberIds, UpdateSubscriber, UpdateSubscriberChannel, UpdateSubscriberOnlineFlag, ChatOauthCallback, ChatOauth, DeleteSubscriberCredentials, BulkCreateSubscribers, GetSubscriberGlobalPreference, CreateIntegration, CheckIntegration, CheckIntegrationEMail, GetWorkflowByIdsUseCase, UpdatePreferences, ]; ================================================ FILE: apps/api/src/app/subscribers/usecases/remove-subscriber/index.ts ================================================ export * from './remove-subscriber.command'; export * from './remove-subscriber.usecase'; ================================================ FILE: apps/api/src/app/subscribers/usecases/remove-subscriber/remove-subscriber.command.ts ================================================ import { IsString } from 'class-validator'; import { EnvironmentCommand } from '../../../shared/commands/project.command'; export class RemoveSubscriberCommand extends EnvironmentCommand { @IsString() subscriberId: string; } ================================================ FILE: apps/api/src/app/subscribers/usecases/remove-subscriber/remove-subscriber.spec.ts ================================================ import { NotFoundException } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { SubscribersService, UserSession } from '@novu/testing'; import { expect } from 'chai'; import { SharedModule } from '../../../shared/shared.module'; import { SubscribersV1Module } from '../../subscribersV1.module'; import { RemoveSubscriberCommand } from './remove-subscriber.command'; import { RemoveSubscriber } from './remove-subscriber.usecase'; describe('Remove Subscriber', () => { let useCase: RemoveSubscriber; let session: UserSession; beforeEach(async () => { const moduleRef = await Test.createTestingModule({ imports: [SharedModule, SubscribersV1Module], providers: [], }).compile(); session = new UserSession(); await session.initialize(); useCase = moduleRef.get(RemoveSubscriber); }); it('should remove a subscriber', async () => { const subscriberService = new SubscribersService(session.organization._id, session.environment._id); const subscriber = await subscriberService.createSubscriber(); const res = await useCase.execute( RemoveSubscriberCommand.create({ subscriberId: subscriber.subscriberId, environmentId: session.environment._id, organizationId: session.organization._id, }) ); expect(res).to.eql({ acknowledged: true, status: 'deleted' }); }); it('should throw a not found exception if subscriber to remove does not exist', async () => { try { await useCase.execute( RemoveSubscriberCommand.create({ subscriberId: 'invalid-subscriber-id', environmentId: session.environment._id, organizationId: session.organization._id, }) ); expect(true, 'Should never reach this statement').to.be.false; } catch (e) { expect(e).to.be.instanceOf(NotFoundException); } }); }); ================================================ FILE: apps/api/src/app/subscribers/usecases/remove-subscriber/remove-subscriber.usecase.ts ================================================ import { Injectable, NotFoundException } from '@nestjs/common'; import { buildFeedKey, buildMessageCountKey, buildSubscriberKey, InvalidateCacheService, } from '@novu/application-generic'; import { PreferencesRepository, SubscriberRepository, TopicSubscribersRepository } from '@novu/dal'; import { RemoveSubscriberCommand } from './remove-subscriber.command'; @Injectable() export class RemoveSubscriber { constructor( private invalidateCache: InvalidateCacheService, private subscriberRepository: SubscriberRepository, private topicSubscribersRepository: TopicSubscribersRepository, private preferenceRepository: PreferencesRepository ) {} async execute({ environmentId: _environmentId, subscriberId }: RemoveSubscriberCommand) { await Promise.all([ this.invalidateCache.invalidateByKey({ key: buildSubscriberKey({ subscriberId, _environmentId, }), }), this.invalidateCache.invalidateQuery({ key: buildMessageCountKey().invalidate({ subscriberId, _environmentId, }), }), ]); const subscriberInternalIds = await this.subscriberRepository._model.distinct('_id', { subscriberId, _environmentId, }); if (subscriberInternalIds.length === 0) { throw new NotFoundException({ message: 'Subscriber was not found', externalSubscriberId: subscriberId }); } await this.subscriberRepository.withTransaction(async () => { /* * Note about parallelism in transactions * * Running operations in parallel is not supported during a transaction. * The use of Promise.all, Promise.allSettled, Promise.race, etc. to parallelize operations * inside a transaction is undefined behaviour and should be avoided. * * Refer to https://mongoosejs.com/docs/transactions.html#note-about-parallelism-in-transactions */ await this.subscriberRepository.delete({ subscriberId, _environmentId, }); await this.topicSubscribersRepository.delete({ _environmentId, externalSubscriberId: subscriberId, }); await this.preferenceRepository.delete({ _environmentId, _subscriberId: { $in: subscriberInternalIds }, }); }); return { acknowledged: true, status: 'deleted', }; } } ================================================ FILE: apps/api/src/app/subscribers/usecases/search-by-external-subscriber-ids/index.ts ================================================ export * from './search-by-external-subscriber-ids.command'; export * from './search-by-external-subscriber-ids.use-case'; ================================================ FILE: apps/api/src/app/subscribers/usecases/search-by-external-subscriber-ids/search-by-external-subscriber-ids.command.ts ================================================ import { ExternalSubscriberId } from '@novu/shared'; import { IsArray, IsDefined } from 'class-validator'; import { EnvironmentCommand } from '../../../shared/commands/project.command'; export class SearchByExternalSubscriberIdsCommand extends EnvironmentCommand { @IsArray() @IsDefined() externalSubscriberIds: ExternalSubscriberId[]; } ================================================ FILE: apps/api/src/app/subscribers/usecases/search-by-external-subscriber-ids/search-by-external-subscriber-ids.spec.ts ================================================ import { Test } from '@nestjs/testing'; import { SubscriberEntity } from '@novu/dal'; import { SubscribersService, UserSession } from '@novu/testing'; import { expect } from 'chai'; import { SharedModule } from '../../../shared/shared.module'; import { SubscribersV1Module } from '../../subscribersV1.module'; import { SearchByExternalSubscriberIds, SearchByExternalSubscriberIdsCommand } from './index'; describe('SearchByExternalSubscriberIdsUseCase', () => { let session: UserSession; let subscribersService: SubscribersService; let useCase: SearchByExternalSubscriberIds; let firstSubscriber: SubscriberEntity; let secondSubscriber: SubscriberEntity; beforeEach(async () => { const moduleRef = await Test.createTestingModule({ imports: [SharedModule, SubscribersV1Module], providers: [], }).compile(); session = new UserSession(); await session.initialize(); useCase = moduleRef.get(SearchByExternalSubscriberIds); subscribersService = new SubscribersService(session.organization._id, session.environment._id); firstSubscriber = await subscribersService.createSubscriber(); secondSubscriber = await subscribersService.createSubscriber(); }); it('should search and find the subscribers by the external subscriber ids', async () => { const externalSubscriberIds = [firstSubscriber.subscriberId, secondSubscriber.subscriberId]; const command = SearchByExternalSubscriberIdsCommand.create({ environmentId: session.environment._id, organizationId: session.organization._id, externalSubscriberIds, }); const res = await useCase.execute(command); expect(res.length).to.eql(2); expect(res[0]._id).to.eql(firstSubscriber._id); expect(res[0].subscriberId).to.eql(firstSubscriber.subscriberId); expect(res[1]._id).to.eql(secondSubscriber._id); expect(res[1].subscriberId).to.eql(secondSubscriber.subscriberId); }); it('should search and find the subscribers existing by the external subscriber ids', async () => { const externalSubscriberIds = [secondSubscriber.subscriberId, 'non-existing-external-subscriber-id']; const command = SearchByExternalSubscriberIdsCommand.create({ environmentId: session.environment._id, organizationId: session.organization._id, externalSubscriberIds, }); const res = await useCase.execute(command); expect(res.length).to.eql(1); expect(res[0]._id).to.eql(secondSubscriber._id); expect(res[0].subscriberId).to.eql(secondSubscriber.subscriberId); }); }); ================================================ FILE: apps/api/src/app/subscribers/usecases/search-by-external-subscriber-ids/search-by-external-subscriber-ids.use-case.ts ================================================ import { Injectable } from '@nestjs/common'; import { IExternalSubscribersEntity, SubscriberEntity, SubscriberRepository } from '@novu/dal'; import { SubscriberDto } from '@novu/shared'; import { SearchByExternalSubscriberIdsCommand } from './search-by-external-subscriber-ids.command'; @Injectable() export class SearchByExternalSubscriberIds { constructor(private subscriberRepository: SubscriberRepository) {} async execute(command: SearchByExternalSubscriberIdsCommand): Promise { const entity = this.mapToEntity(command); const entities = await this.subscriberRepository.searchByExternalSubscriberIds(entity); return entities.map(this.mapFromEntity); } private mapToEntity(command: SearchByExternalSubscriberIdsCommand): IExternalSubscribersEntity { return { _environmentId: command.environmentId, _organizationId: command.organizationId, externalSubscriberIds: command.externalSubscriberIds, }; } private mapFromEntity(entity: SubscriberEntity): SubscriberDto { const { _id, ...rest } = entity; return { ...rest, _id, }; } } ================================================ FILE: apps/api/src/app/subscribers/usecases/update-subscriber-online-flag/index.ts ================================================ export * from './update-subscriber-online-flag.command'; export * from './update-subscriber-online-flag.usecase'; ================================================ FILE: apps/api/src/app/subscribers/usecases/update-subscriber-online-flag/update-subscriber-online-flag.command.ts ================================================ import { IsBoolean, IsDefined } from 'class-validator'; import { EnvironmentWithSubscriber } from '../../../shared/commands/project.command'; export class UpdateSubscriberOnlineFlagCommand extends EnvironmentWithSubscriber { @IsDefined() @IsBoolean() isOnline: boolean; } ================================================ FILE: apps/api/src/app/subscribers/usecases/update-subscriber-online-flag/update-subscriber-online-flag.usecase.ts ================================================ import { Inject, Injectable, NotFoundException } from '@nestjs/common'; import { AnalyticsService } from '@novu/application-generic'; import { MemberRepository, SubscriberEntity, SubscriberRepository } from '@novu/dal'; import { UpdateSubscriberOnlineFlagCommand } from './update-subscriber-online-flag.command'; @Injectable() export class UpdateSubscriberOnlineFlag { constructor( private subscriberRepository: SubscriberRepository, private analyticsService: AnalyticsService, private memberRepository: MemberRepository ) {} private getUpdatedFields(isOnline: boolean) { return { isOnline, ...(!isOnline && { lastOnlineAt: new Date().toISOString() }), }; } async execute(command: UpdateSubscriberOnlineFlagCommand) { const subscriber = await this.subscriberRepository.findBySubscriberId(command.environmentId, command.subscriberId); if (!subscriber) throw new NotFoundException(`Subscriber not found`); await this.subscriberRepository.update( { _id: subscriber._id, _organizationId: command.organizationId, _environmentId: command.environmentId }, { $set: this.getUpdatedFields(command.isOnline), } ); return (await this.subscriberRepository.findBySubscriberId( command.environmentId, command.subscriberId )) as SubscriberEntity; } } ================================================ FILE: apps/api/src/app/subscribers-v2/dtos/bulk-update-subscriber-preferences.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { ApiContextPayload, IsValidContextPayload, parseSlugId } from '@novu/application-generic'; import { ContextPayload } from '@novu/shared'; import { Transform, Type } from 'class-transformer'; import { ArrayMaxSize, IsArray, IsDefined, IsOptional, IsString, ValidateNested } from 'class-validator'; import { PatchPreferenceChannelsDto } from './patch-subscriber-preferences.dto'; export class BulkUpdateSubscriberPreferenceItemDto { @ApiProperty({ description: 'Channel-specific preference settings', type: PatchPreferenceChannelsDto }) @Type(() => PatchPreferenceChannelsDto) channels: PatchPreferenceChannelsDto; @ApiProperty({ description: 'Workflow internal _id, identifier or slug', }) @IsDefined() @IsString() @Transform(({ value }) => parseSlugId(value)) readonly workflowId: string; } export class BulkUpdateSubscriberPreferencesDto { @ApiProperty({ description: 'Array of workflow preferences to update (maximum 100 items)', type: [BulkUpdateSubscriberPreferenceItemDto], maxItems: 100, }) @IsDefined() @IsArray() @ArrayMaxSize(100) @Type(() => BulkUpdateSubscriberPreferenceItemDto) @ValidateNested({ each: true }) readonly preferences: BulkUpdateSubscriberPreferenceItemDto[]; @ApiContextPayload() @IsOptional() @IsValidContextPayload({ maxCount: 5 }) context?: ContextPayload; } ================================================ FILE: apps/api/src/app/subscribers-v2/dtos/context-keys-query.dto.ts ================================================ import { ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsArray, IsOptional, IsString } from 'class-validator'; export class ContextKeysQueryDto { @IsOptional() @IsArray() @IsString({ each: true }) @Transform(({ value }) => { if (value === undefined || value === null) return undefined; return Array.isArray(value) ? value : [value]; }) @ApiPropertyOptional({ description: 'Context keys for filtering notifications in multi-context scenarios', type: [String], }) contextKeys?: string[]; } ================================================ FILE: apps/api/src/app/subscribers-v2/dtos/create-subscriber.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsDefined, IsNotEmpty, IsString } from 'class-validator'; import { BaseSubscriberFieldsDto } from '../../shared/dtos/base-subscriber-fields.dto'; export class CreateSubscriberRequestDto extends BaseSubscriberFieldsDto { @ApiProperty({ type: String, description: 'Unique identifier of the subscriber', }) @IsString() @IsDefined() @IsNotEmpty({ message: 'SubscriberId is required', }) @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) subscriberId: string; } ================================================ FILE: apps/api/src/app/subscribers-v2/dtos/cursor-pagination-query.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { DirectionEnum } from '@novu/shared'; import { Transform, Type } from 'class-transformer'; import { IsOptional, IsString } from 'class-validator'; export class CursorPaginationQueryDto { @ApiProperty({ description: 'Cursor for pagination indicating the starting point after which to fetch results.', type: String, required: false, }) @IsString() @IsOptional() after?: string; @ApiProperty({ description: 'Cursor for pagination indicating the ending point before which to fetch results.', type: String, required: false, }) @IsString() @IsOptional() before?: string; @ApiPropertyOptional({ description: 'Limit the number of items to return', type: Number, example: 10, }) @IsOptional() @Type(() => Number) limit?: number; @ApiPropertyOptional({ description: 'Direction of sorting', enum: DirectionEnum, }) @IsOptional() orderDirection?: DirectionEnum; @ApiPropertyOptional({ description: 'Field to order by', type: String, }) @IsString() @IsOptional() orderBy?: K; @ApiPropertyOptional({ description: 'Include cursor item in response', type: Boolean, }) @Transform(({ value }) => value === 'true') @IsOptional() includeCursor?: boolean; } ================================================ FILE: apps/api/src/app/subscribers-v2/dtos/get-subscriber-notifications-count-query.dto.ts ================================================ import { BadRequestException } from '@nestjs/common'; import { ApiProperty } from '@nestjs/swagger'; import { SeverityLevelEnum } from '@novu/shared'; import { plainToClass, Transform, Type } from 'class-transformer'; import { ArrayMaxSize, IsArray, IsBoolean, IsDefined, IsOptional, IsString, ValidateNested } from 'class-validator'; import { NotificationFilter } from '../../inbox/utils/types'; import { IsEnumOrArray } from '../../shared/validators/is-enum-or-array'; export class SubscriberNotificationsFilter implements NotificationFilter { @IsOptional() @IsArray() @IsString({ each: true }) tags?: string[]; @IsOptional() @IsBoolean() read?: boolean; @IsOptional() @IsBoolean() archived?: boolean; @IsOptional() @IsBoolean() snoozed?: boolean; @IsOptional() @IsBoolean() seen?: boolean; @IsOptional() @IsEnumOrArray(SeverityLevelEnum) severity?: SeverityLevelEnum | SeverityLevelEnum[]; @IsOptional() @IsArray() @IsString({ each: true }) contextKeys?: string[]; } export class GetSubscriberNotificationsCountQueryDto { @IsDefined() @Transform(({ value }) => { try { const filters = JSON.parse(value); if (Array.isArray(filters)) { return filters.map((el) => plainToClass(SubscriberNotificationsFilter, el)); } return filters; } catch { throw new BadRequestException('Invalid filters, the JSON object should be provided.'); } }) @IsArray() @ArrayMaxSize(30) @ValidateNested({ each: true }) @Type(() => SubscriberNotificationsFilter) @ApiProperty({ description: 'Array of filter objects (max 30) to count notifications by different criteria', type: 'string', example: '[{"read":false,"archived":false},{"tags":["important"]}]', }) filters: SubscriberNotificationsFilter[]; } ================================================ FILE: apps/api/src/app/subscribers-v2/dtos/get-subscriber-notifications-count-response.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; export class GetSubscriberNotificationsCountResponseDto { @ApiProperty({ description: 'The count of notifications matching the filter', type: Number, }) count: number; @ApiProperty({ description: 'The filter applied', type: 'object', additionalProperties: true, }) filter: Record; } ================================================ FILE: apps/api/src/app/subscribers-v2/dtos/get-subscriber-notifications-query.dto.ts ================================================ import { ApiPropertyOptional } from '@nestjs/swagger'; import { SeverityLevelEnum } from '@novu/shared'; import { Transform } from 'class-transformer'; import { IsArray, IsBoolean, IsInt, IsOptional, IsString } from 'class-validator'; import { NotificationFilter } from '../../inbox/utils/types'; import { CursorPaginationRequestDto } from '../../shared/dtos/cursor-pagination-request'; import { IsEnumOrArray } from '../../shared/validators/is-enum-or-array'; const LIMIT = { DEFAULT: 10, MAX: 100, }; export class GetSubscriberNotificationsQueryDto extends CursorPaginationRequestDto(LIMIT.DEFAULT, LIMIT.MAX) implements NotificationFilter { @IsOptional() @IsArray() @IsString({ each: true }) @ApiPropertyOptional({ description: 'Filter by workflow tags', type: [String], }) tags?: string[]; @IsOptional() @IsBoolean() @Transform(({ value }) => (value === undefined || value === null || value === '' ? undefined : value === 'true')) @ApiPropertyOptional({ description: 'Filter by read/unread state', type: Boolean, }) read?: boolean; @IsOptional() @IsBoolean() @Transform(({ value }) => (value === undefined || value === null || value === '' ? undefined : value === 'true')) @ApiPropertyOptional({ description: 'Filter by archived state', type: Boolean, }) archived?: boolean; @IsOptional() @IsBoolean() @Transform(({ value }) => (value === undefined || value === null || value === '' ? undefined : value === 'true')) @ApiPropertyOptional({ description: 'Filter by snoozed state', type: Boolean, }) snoozed?: boolean; @IsOptional() @IsBoolean() @Transform(({ value }) => (value === undefined || value === null || value === '' ? undefined : value === 'true')) @ApiPropertyOptional({ description: 'Filter by seen state', type: Boolean, }) seen?: boolean; @IsOptional() @IsString() @ApiPropertyOptional({ description: 'Filter by data attributes (JSON string)', }) data?: string; @IsOptional() @IsEnumOrArray(SeverityLevelEnum) @ApiPropertyOptional({ description: 'Filter by severity levels', type: [String], enum: SeverityLevelEnum, }) severity?: SeverityLevelEnum | SeverityLevelEnum[]; @IsOptional() @IsInt() @Transform(({ value }) => (value ? parseInt(value, 10) : undefined)) @ApiPropertyOptional({ description: 'Filter notifications created on or after this timestamp (Unix timestamp in milliseconds)', example: 1704067200000, }) createdGte?: number; @IsOptional() @IsInt() @Transform(({ value }) => (value ? parseInt(value, 10) : undefined)) @ApiPropertyOptional({ description: 'Filter notifications created on or before this timestamp (Unix timestamp in milliseconds)', example: 1735689599999, }) createdLte?: number; @IsOptional() @IsArray() @IsString({ each: true }) @Transform(({ value }) => { if (value === undefined || value === null) return undefined; return Array.isArray(value) ? value : [value]; }) @ApiPropertyOptional({ description: 'Context keys for filtering notifications in multi-context scenarios', type: [String], }) contextKeys?: string[]; } ================================================ FILE: apps/api/src/app/subscribers-v2/dtos/get-subscriber-notifications-response.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { InboxNotificationDto } from '../../inbox/dtos/inbox-notification.dto'; import type { NotificationFilter } from '../../inbox/utils/types'; export class GetSubscriberNotificationsResponseDto { @ApiProperty({ description: 'Array of notifications', type: [InboxNotificationDto], }) data: InboxNotificationDto[]; @ApiProperty({ description: 'Indicates if there are more notifications available', type: Boolean, }) hasMore: boolean; @ApiProperty({ description: 'The filter applied to the notifications', type: 'object', }) filter: NotificationFilter; } ================================================ FILE: apps/api/src/app/subscribers-v2/dtos/get-subscriber-preferences-request.dto.ts ================================================ import { ApiPropertyOptional } from '@nestjs/swagger'; import { WorkflowCriticalityEnum } from '@novu/shared'; import { Transform } from 'class-transformer'; import { IsArray, IsEnum, IsOptional, IsString } from 'class-validator'; export class GetSubscriberPreferencesRequestDto { @IsEnum(WorkflowCriticalityEnum) @IsOptional() @ApiPropertyOptional({ enum: WorkflowCriticalityEnum, default: WorkflowCriticalityEnum.NON_CRITICAL, }) criticality?: WorkflowCriticalityEnum = WorkflowCriticalityEnum.NON_CRITICAL; @IsOptional() @Transform(({ value }) => { // No parameter = no filter if (value === undefined) return undefined; // Empty string = filter for records with no (default) context if (value === '') return []; // Normalize to array and remove empty strings const array = Array.isArray(value) ? value : [value]; return array.filter((v) => v !== ''); }) @IsArray() @IsString({ each: true }) @ApiPropertyOptional({ description: 'Context keys for filtering preferences (e.g., ["tenant:acme"])', type: [String], example: ['tenant:acme'], }) contextKeys?: string[]; } ================================================ FILE: apps/api/src/app/subscribers-v2/dtos/get-subscriber-preferences.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { SubscriberGlobalPreferenceDto } from './subscriber-global-preference.dto'; import { SubscriberWorkflowPreferenceDto } from './subscriber-workflow-preference.dto'; export class GetSubscriberPreferencesDto { @ApiProperty({ description: 'Global preference settings', type: SubscriberGlobalPreferenceDto }) @Type(() => SubscriberGlobalPreferenceDto) global: SubscriberGlobalPreferenceDto; @ApiProperty({ description: 'Workflow-specific preference settings', type: [SubscriberWorkflowPreferenceDto] }) @Type(() => SubscriberWorkflowPreferenceDto) workflows: SubscriberWorkflowPreferenceDto[]; } ================================================ FILE: apps/api/src/app/subscribers-v2/dtos/inbox-notification.dto.ts ================================================ export { InboxActionDto, InboxNotificationDto, InboxSubscriberResponseDto, NotificationWorkflowDto, RedirectDto, } from '../../inbox/dtos/inbox-notification.dto'; ================================================ FILE: apps/api/src/app/subscribers-v2/dtos/list-subscribers-query.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { SubscriberResponseDto } from '@novu/application-generic'; import { IsOptional, IsString } from 'class-validator'; import { CursorPaginationQueryDto } from './cursor-pagination-query.dto'; export class ListSubscribersQueryDto extends CursorPaginationQueryDto { @ApiProperty({ description: 'Email address of the subscriber to filter results.', type: String, required: false, }) @IsOptional() @IsString() email?: string; @ApiProperty({ description: 'Name of the subscriber to filter results.', type: String, required: false, }) @IsOptional() @IsString() name?: string; @ApiProperty({ description: 'Phone number of the subscriber to filter results.', type: String, required: false, }) @IsOptional() @IsString() phone?: string; @ApiProperty({ description: 'Unique identifier of the subscriber to filter results.', type: String, required: false, }) @IsOptional() @IsString() subscriberId?: string; } ================================================ FILE: apps/api/src/app/subscribers-v2/dtos/list-subscribers-response.dto.ts ================================================ import { SubscriberResponseDto } from '@novu/application-generic'; import { withCursorPagination } from '../../shared/dtos/cursor-paginated-response'; export class ListSubscribersResponseDto extends withCursorPagination(SubscriberResponseDto, { description: 'List of returned Subscribers', }) {} ================================================ FILE: apps/api/src/app/subscribers-v2/dtos/mark-subscriber-notifications-as-seen.dto.ts ================================================ import { ApiPropertyOptional } from '@nestjs/swagger'; import { IsArray, IsMongoId, IsOptional, IsString } from 'class-validator'; export class MarkSubscriberNotificationsAsSeenDto { @IsOptional() @IsArray() @IsMongoId({ each: true }) @ApiPropertyOptional({ description: 'Specific notification IDs to mark as seen', type: [String], }) notificationIds?: string[]; @IsOptional() @IsArray() @IsString({ each: true }) @ApiPropertyOptional({ description: 'Filter notifications by workflow tags', type: [String], }) tags?: string[]; @IsOptional() @IsString() @ApiPropertyOptional({ description: 'Filter notifications by data attributes (JSON string)', }) data?: string; @IsOptional() @IsArray() @IsString({ each: true }) @ApiPropertyOptional({ description: 'Context keys for filtering notifications', type: [String], }) contextKeys?: string[]; } ================================================ FILE: apps/api/src/app/subscribers-v2/dtos/patch-subscriber-preferences.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiContextPayload, IsValidContextPayload, parseSlugId } from '@novu/application-generic'; import { ContextPayload, IPreferenceChannels } from '@novu/shared'; import { Transform, Type } from 'class-transformer'; import { IsOptional, ValidateNested } from 'class-validator'; import { ScheduleDto } from '../../shared/dtos/schedule'; export class PatchPreferenceChannelsDto implements IPreferenceChannels { @ApiProperty({ description: 'Email channel preference' }) email?: boolean; @ApiProperty({ description: 'SMS channel preference' }) sms?: boolean; @ApiProperty({ description: 'In-app channel preference' }) in_app?: boolean; @ApiProperty({ description: 'Push channel preference' }) push?: boolean; @ApiProperty({ description: 'Chat channel preference' }) chat?: boolean; } export class PatchSubscriberPreferencesDto { @ApiPropertyOptional({ description: 'Channel-specific preference settings', type: PatchPreferenceChannelsDto }) @Type(() => PatchPreferenceChannelsDto) channels?: PatchPreferenceChannelsDto; @ApiProperty({ description: 'Workflow internal _id, identifier or slug. If provided, update workflow specific preferences, otherwise update global preferences', required: false, }) @IsOptional() @Transform(({ value }) => parseSlugId(value)) workflowId?: string; @ApiPropertyOptional({ description: 'Subscriber schedule', type: ScheduleDto }) @IsOptional() @ValidateNested() @Type(() => ScheduleDto) schedule?: ScheduleDto; @ApiContextPayload() @IsOptional() @IsValidContextPayload({ maxCount: 5 }) context?: ContextPayload; } ================================================ FILE: apps/api/src/app/subscribers-v2/dtos/patch-subscriber.dto.ts ================================================ import { BaseSubscriberFieldsDto } from '../../shared/dtos/base-subscriber-fields.dto'; export class PatchSubscriberRequestDto extends BaseSubscriberFieldsDto {} ================================================ FILE: apps/api/src/app/subscribers-v2/dtos/remove-subscriber.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; export class RemoveSubscriberResponseDto { @ApiProperty({ description: 'Indicates whether the operation was acknowledged by the server', example: true, }) acknowledged: boolean; @ApiProperty({ description: 'Status of the subscriber removal operation', example: 'success', }) status: string; } ================================================ FILE: apps/api/src/app/subscribers-v2/dtos/snooze-subscriber-notification.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsDate, registerDecorator, ValidationOptions } from 'class-validator'; function IsFutureDate( options?: { leewayMs?: number; }, validationOptions?: ValidationOptions ) { const leewayMs = options?.leewayMs ?? 1000 * 60; return (object: object, propertyName: string) => { registerDecorator({ name: 'isFutureDate', target: object.constructor, propertyName, options: { message: `Snooze time must be at least ${leewayMs / 1000} seconds in the future`, ...validationOptions, }, validator: { validate(value: Date) { if (!(value instanceof Date)) { return false; } const now = new Date(); const delay = value.getTime() - now.getTime(); return delay >= leewayMs; }, }, }); }; } export class SnoozeSubscriberNotificationDto { @Type(() => Date) @IsDate() @IsFutureDate({ leewayMs: 1000 * 60, }) @ApiProperty({ description: 'The date and time until which the notification should be snoozed', type: Date, example: '2026-03-01T10:00:00Z', }) readonly snoozeUntil: Date; } ================================================ FILE: apps/api/src/app/subscribers-v2/dtos/subscriber-global-preference.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsBoolean, IsNotEmpty, ValidateNested } from 'class-validator'; import { SubscriberPreferenceChannels } from '../../shared/dtos/preference-channels'; import { ScheduleDto } from '../../shared/dtos/schedule'; export class SubscriberGlobalPreferenceDto { @ApiProperty({ description: 'Whether notifications are enabled globally' }) @IsBoolean({ message: 'Enabled must be a boolean value' }) @IsNotEmpty({ message: 'Enabled status is required' }) enabled: boolean; @ApiProperty({ description: 'Channel-specific preference settings', type: SubscriberPreferenceChannels }) @ValidateNested() @Type(() => SubscriberPreferenceChannels) channels: SubscriberPreferenceChannels; @ApiPropertyOptional({ description: 'Subscriber schedule', type: ScheduleDto }) @ValidateNested() @Type(() => ScheduleDto) schedule?: ScheduleDto; } ================================================ FILE: apps/api/src/app/subscribers-v2/dtos/subscriber-notification-action.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { ButtonTypeEnum } from '@novu/shared'; import { IsDefined, IsEnum } from 'class-validator'; export class SubscriberNotificationActionDto { @IsEnum(ButtonTypeEnum) @IsDefined() @ApiProperty({ description: 'The type of action button (primary or secondary)', enum: ButtonTypeEnum, example: ButtonTypeEnum.PRIMARY, }) readonly actionType: ButtonTypeEnum; } ================================================ FILE: apps/api/src/app/subscribers-v2/dtos/subscriber-preferences-workflow-info.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class SubscriberPreferencesWorkflowInfoDto { @ApiProperty({ description: 'Workflow slug' }) slug: string; @ApiProperty({ description: 'Unique identifier of the workflow' }) identifier: string; @ApiProperty({ description: 'Display name of the workflow' }) name: string; @ApiPropertyOptional({ description: 'last updated date', }) updatedAt?: string; } ================================================ FILE: apps/api/src/app/subscribers-v2/dtos/subscriber-workflow-preference.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { SubscriberPreferenceChannels } from '../../shared/dtos/preference-channels'; import { SubscriberPreferenceOverrideDto } from '../../subscribers/dtos'; import { SubscriberPreferencesWorkflowInfoDto } from './subscriber-preferences-workflow-info.dto'; export class SubscriberWorkflowPreferenceDto { @ApiProperty({ description: 'Whether notifications are enabled for this workflow' }) enabled: boolean; @ApiProperty({ description: 'Channel-specific preference settings for this workflow', type: SubscriberPreferenceChannels, }) @Type(() => SubscriberPreferenceChannels) channels: SubscriberPreferenceChannels; @ApiProperty({ description: 'List of preference overrides', type: [SubscriberPreferenceOverrideDto] }) @Type(() => SubscriberPreferenceOverrideDto) overrides: SubscriberPreferenceOverrideDto[]; @ApiProperty({ description: 'Workflow information', type: SubscriberPreferencesWorkflowInfoDto }) @Type(() => SubscriberPreferencesWorkflowInfoDto) workflow: SubscriberPreferencesWorkflowInfoDto; @ApiPropertyOptional({ description: 'Timestamp when the subscriber last updated their preference. Only present if subscriber explicitly set preferences.', }) updatedAt?: string; } ================================================ FILE: apps/api/src/app/subscribers-v2/dtos/update-all-subscriber-notifications.dto.ts ================================================ import { ApiPropertyOptional } from '@nestjs/swagger'; import { IsArray, IsOptional, IsString } from 'class-validator'; export class UpdateAllSubscriberNotificationsDto { @IsOptional() @IsArray() @IsString({ each: true }) @ApiPropertyOptional({ description: 'Filter notifications by workflow tags', type: [String], }) tags?: string[]; @IsOptional() @IsString() @ApiPropertyOptional({ description: 'Filter notifications by data attributes (JSON string)', }) data?: string; @IsOptional() @IsArray() @IsString({ each: true }) @ApiPropertyOptional({ description: 'Context keys for filtering notifications', type: [String], }) contextKeys?: string[]; } ================================================ FILE: apps/api/src/app/subscribers-v2/e2e/create-subscriber.e2e.ts ================================================ import { Novu } from '@novu/api'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { randomBytes } from 'crypto'; import { expectSdkExceptionGeneric, expectSdkValidationExceptionGeneric, initNovuClassSdk, } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; let session: UserSession; describe('Create Subscriber - /subscribers (POST) #novu-v2', () => { let novuClient: Novu; beforeEach(async () => { session = new UserSession(); await session.initialize(); novuClient = initNovuClassSdk(session); }); it('should create the subscriber', async () => { const subscriberId = `test-subscriber-${`${randomBytes(4).toString('hex')}`}`; const payload = { subscriberId, firstName: 'First Name', lastName: 'Last Name', locale: 'en_US', timezone: 'America/New_York', data: { test1: 'test value1', test2: 'test value2' }, }; const { result: subscriber } = await novuClient.subscribers.create(payload); expect(subscriber.subscriberId).to.equal(payload.subscriberId); expect(subscriber.firstName).to.equal(payload.firstName); expect(subscriber.lastName).to.equal(payload.lastName); expect(subscriber.locale).to.equal(payload.locale); expect(subscriber.timezone).to.equal(payload.timezone); expect(subscriber.data).to.deep.equal(payload.data); }); it('should upsert an existing subscriber if the subscriberId matches', async () => { const subscriberId = `test-subscriber-${`${randomBytes(4).toString('hex')}`}`; const payload1 = { subscriberId, firstName: 'First Name', locale: 'en_US', data: { foo: 42 }, }; const { result: subscriber } = await novuClient.subscribers.create(payload1); expect(subscriber.subscriberId).to.equal(payload1.subscriberId); expect(subscriber.firstName).to.equal(payload1.firstName); expect(subscriber.lastName).to.be.undefined; expect(subscriber.locale).to.equal(payload1.locale); expect(subscriber.timezone).to.be.undefined; expect(subscriber.data).to.deep.equal(payload1.data); const payload2 = { subscriberId, firstName: 'First Name 2', lastName: 'Last Name 2', timezone: 'America/New_York', data: { foo: 42, bar: '42' }, }; const { result: updatedSubscriber } = await novuClient.subscribers.create(payload2); expect(updatedSubscriber.subscriberId).to.equal(payload2.subscriberId); expect(updatedSubscriber.firstName).to.equal(payload2.firstName); expect(updatedSubscriber.lastName).to.equal(payload2.lastName); expect(updatedSubscriber.timezone).to.equal(payload2.timezone); expect(updatedSubscriber.data).to.deep.equal(payload2.data); const { result: { data: subscribers }, } = await novuClient.subscribers.search({ subscriberId }); expect(subscribers.length).to.equal(1); }); it('should create the subscriber with null values', async () => { const subscriberId = `test-subscriber-${`${randomBytes(4).toString('hex')}`}`; const payload = { subscriberId, }; const { result: subscriber } = await novuClient.subscribers.create(payload); expect(subscriber.subscriberId).to.equal(payload.subscriberId); expect(subscriber.firstName).to.be.undefined; expect(subscriber.lastName).to.be.undefined; }); it('should allow empty strings for simple text fields', async () => { const subscriberId = `test-subscriber-${randomBytes(4).toString('hex')}`; const payload = { subscriberId, firstName: '', lastName: '', phone: '', avatar: '', }; const { result: subscriber } = await novuClient.subscribers.create(payload); expect(subscriber.subscriberId).to.equal(payload.subscriberId); expect(subscriber.firstName).to.equal(payload.firstName); expect(subscriber.lastName).to.equal(payload.lastName); expect(subscriber.phone).to.equal(payload.phone); expect(subscriber.avatar).to.equal(payload.avatar); }); it('should reject empty strings for complex fields (email)', async () => { const subscriberId = `test-subscriber-${randomBytes(4).toString('hex')}`; const payload = { subscriberId, email: '', }; const { error } = await expectSdkValidationExceptionGeneric(() => novuClient.subscribers.create(payload)); expect(error?.statusCode).to.equal(422); const errorMessages = JSON.stringify(error?.errors); expect(errorMessages).to.include('email'); }); it('should reject empty strings for complex fields (locale)', async () => { const subscriberId = `test-subscriber-${randomBytes(4).toString('hex')}`; const payload = { subscriberId, locale: '', }; const { error } = await expectSdkValidationExceptionGeneric(() => novuClient.subscribers.create(payload)); expect(error?.statusCode).to.equal(422); const errorMessages = JSON.stringify(error?.errors); expect(errorMessages).to.include('locale'); }); it('should reject empty strings for complex fields (timezone)', async () => { const subscriberId = `test-subscriber-${randomBytes(4).toString('hex')}`; const payload = { subscriberId, timezone: '', }; const { error } = await expectSdkValidationExceptionGeneric(() => novuClient.subscribers.create(payload)); expect(error?.statusCode).to.equal(422); const errorMessages = JSON.stringify(error?.errors); expect(errorMessages).to.include('timezone'); }); it('should accept null for complex fields', async () => { const subscriberId = `test-subscriber-${randomBytes(4).toString('hex')}`; const payload = { subscriberId, email: null, locale: null, timezone: null, }; const { result: subscriber } = await novuClient.subscribers.create(payload); expect(subscriber.subscriberId).to.equal(payload.subscriberId); expect(subscriber.email).to.be.null; expect(subscriber.locale).to.be.null; expect(subscriber.timezone).to.be.null; }); it('should validate email format', async () => { const subscriberId = `test-subscriber-${randomBytes(4).toString('hex')}`; const payload = { subscriberId, email: 'invalid-email', }; const { error } = await expectSdkValidationExceptionGeneric(() => novuClient.subscribers.create(payload)); expect(error?.statusCode).to.equal(422); const errorMessages = JSON.stringify(error?.errors); expect(errorMessages).to.include('email'); }); it('should validate locale format', async () => { const subscriberId = `test-subscriber-${randomBytes(4).toString('hex')}`; const payload = { subscriberId, locale: '!!!invalid!!!', }; const { error } = await expectSdkValidationExceptionGeneric(() => novuClient.subscribers.create(payload)); expect(error?.statusCode).to.equal(422); const errorMessages = JSON.stringify(error?.errors); expect(errorMessages).to.include('locale'); }); it('should validate timezone format', async () => { const subscriberId = `test-subscriber-${randomBytes(4).toString('hex')}`; const payload = { subscriberId, timezone: 'Invalid/Timezone', }; const { error } = await expectSdkValidationExceptionGeneric(() => novuClient.subscribers.create(payload)); expect(error?.statusCode).to.equal(422); const errorMessages = JSON.stringify(error?.errors); expect(errorMessages).to.include('timezone'); }); it('should fail if subscriberId already exists when failIfExists=true', async () => { const subscriberId = `test-subscriber-${randomBytes(4).toString('hex')}`; const payload = { subscriberId, firstName: 'First', }; await novuClient.subscribers.create(payload); const response = await session.testAgent.post('/v2/subscribers').query({ failIfExists: true }).send(payload); expect(response.status).to.equal(409); }); it('should upsert if subscriberId already exists when failIfExists=false', async () => { const subscriberId = `test-subscriber-${randomBytes(4).toString('hex')}`; const payload1 = { subscriberId, firstName: 'First', }; await novuClient.subscribers.create(payload1); const payload2 = { subscriberId, firstName: 'Updated', }; const { result: subscriber } = await novuClient.subscribers.create(payload2); expect(subscriber.subscriberId).to.equal(subscriberId); expect(subscriber.firstName).to.equal('Updated'); }); it('should allow null for data field', async () => { const subscriberId = `test-subscriber-${randomBytes(4).toString('hex')}`; const payload = { subscriberId, data: null, }; const { result: subscriber } = await novuClient.subscribers.create(payload); expect(subscriber.subscriberId).to.equal(payload.subscriberId); expect([null, undefined]).to.include(subscriber.data); }); }); ================================================ FILE: apps/api/src/app/subscribers-v2/e2e/delete-subscriber.e2e.ts ================================================ import { Novu } from '@novu/api'; import { MessageEntity, MessageRepository, PreferencesRepository, SubscriberEntity, SubscriberRepository, TopicRepository, TopicSubscribersRepository, } from '@novu/dal'; import { ChannelTypeEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { randomBytes } from 'crypto'; import { initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('Delete Subscriber - /subscribers/:subscriberId (DELETE) #novu-v2', () => { let session: UserSession; let novuClient: Novu; let messageRepository: MessageRepository; let subscriberRepository: SubscriberRepository; let topicRepository: TopicRepository; let topicSubscribersRepository: TopicSubscribersRepository; let preferencesRepository: PreferencesRepository; let subscriberId: string; let environmentId: string; let organizationId: string; beforeEach(async () => { session = new UserSession(); await session.initialize(); novuClient = initNovuClassSdk(session); messageRepository = new MessageRepository(); subscriberRepository = new SubscriberRepository(); topicRepository = new TopicRepository(); topicSubscribersRepository = new TopicSubscribersRepository(); preferencesRepository = new PreferencesRepository(); subscriberId = `test-subscriber-${randomBytes(4).toString('hex')}`; environmentId = session.environment._id; organizationId = session.organization._id; }); it('should delete subscriber and all associated data', async () => { const { result: subscriberResult } = await novuClient.subscribers.create({ subscriberId, firstName: 'Test', lastName: 'Subscriber', email: 'test@example.com', data: { test: 'value' }, }); const subscriberEntity = await subscriberRepository.findOne({ _environmentId: environmentId, subscriberId, }); expect(subscriberEntity).to.not.be.null; const subscriberInternalId = subscriberEntity?._id; const topicKey = `topic-${randomBytes(4).toString('hex')}`; const createTopicResponse = await novuClient.topics.create({ key: topicKey, name: 'Test Topic', }); await novuClient.topics.subscriptions.create( { subscriberIds: [subscriberId], }, topicKey ); const topicSubscriptions = await topicSubscribersRepository.find({ _environmentId: environmentId, _organizationId: organizationId, externalSubscriberId: subscriberId, }); expect(topicSubscriptions.length).to.be.greaterThan(0); const testMessages: MessageEntity[] = []; for (let i = 0; i < 3; i += 1) { const message = await messageRepository.create({ _environmentId: environmentId, _organizationId: organizationId, _subscriberId: subscriberInternalId, content: `Test message ${i}`, channel: ChannelTypeEnum.IN_APP, transactionId: `transaction-${i}`, }); testMessages.push(message); } const messagesBeforeDeletion = await messageRepository.find({ _environmentId: environmentId, _subscriberId: subscriberInternalId, }); expect(messagesBeforeDeletion.length).to.equal(3); await novuClient.subscribers.delete(subscriberId); const subscriberAfterDeletion = await subscriberRepository.findOne({ _environmentId: environmentId, subscriberId, }); expect(subscriberAfterDeletion).to.be.null; const messagesAfterDeletion = await messageRepository.find({ _environmentId: environmentId, _subscriberId: subscriberInternalId, }); expect(messagesAfterDeletion.length).to.equal(0); const topicSubscriptionsAfterDeletion = await topicSubscribersRepository.find({ _environmentId: environmentId, _organizationId: organizationId, externalSubscriberId: subscriberId, }); expect(topicSubscriptionsAfterDeletion.length).to.equal(0); }); }); ================================================ FILE: apps/api/src/app/subscribers-v2/e2e/get-subscriber-preferences.e2e.ts ================================================ import { Novu } from '@novu/api'; import { SubscriberResponseDto } from '@novu/api/models/components'; import { NotificationTemplateEntity } from '@novu/dal'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { randomBytes } from 'crypto'; import { expectSdkExceptionGeneric, initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; let session: UserSession; describe('Get Subscriber Preferences - /subscribers/:subscriberId/preferences (GET) #novu-v2', () => { let novuClient: Novu; let subscriber: SubscriberResponseDto; let workflow: NotificationTemplateEntity; beforeEach(async () => { (process.env as any).IS_CONTEXT_PREFERENCES_ENABLED = 'true'; const uuid = randomBytes(4).toString('hex'); session = new UserSession(); await session.initialize(); novuClient = initNovuClassSdk(session); subscriber = await createSubscriberAndValidate(uuid); workflow = await session.createTemplate({ noFeedId: true, }); }); afterEach(() => { delete (process.env as any).IS_CONTEXT_PREFERENCES_ENABLED; }); it('should fetch subscriber preferences with default values', async () => { const response = await novuClient.subscribers.preferences.list({ subscriberId: subscriber.subscriberId }); const { global, workflows } = response.result; expect(global.enabled).to.be.true; expect(workflows).to.be.an('array'); expect(workflows).to.have.lengthOf(1); }); it('should return 404 if subscriber does not exist', async () => { const invalidSubscriberId = `non-existent-${randomBytes(2).toString('hex')}`; const { error } = await expectSdkExceptionGeneric(() => novuClient.subscribers.preferences.list({ subscriberId: invalidSubscriberId }) ); expect(error?.statusCode).to.equal(404); }); it('should show all available workflowsin preferences response', async () => { // Create multiple templates const workflow2 = await session.createTemplate({ noFeedId: true }); const workflow3 = await session.createTemplate({ noFeedId: true }); const response = await novuClient.subscribers.preferences.list({ subscriberId: subscriber.subscriberId }); const { workflows } = response.result; expect(workflows).to.have.lengthOf(3); // Should show all available workflows const workflowIdentifiers = workflows.map((_wf) => _wf.workflow.identifier); expect(workflowIdentifiers).to.include(workflow.triggers[0].identifier); expect(workflowIdentifiers).to.include(workflow2.triggers[0].identifier); expect(workflowIdentifiers).to.include(workflow3.triggers[0].identifier); }); it('should inherit channel preferences from global settings when no workflow override exists', async () => { // First set global preferences await novuClient.subscribers.preferences.update( { channels: { email: false, inApp: true, }, }, subscriber.subscriberId ); // Then create a new template const newWorkflow = await session.createTemplate({ noFeedId: true }); // Check preferences const response = await novuClient.subscribers.preferences.list({ subscriberId: subscriber.subscriberId }); const { workflows } = response.result; const newWorkflowPreferences = workflows.find( (_wf) => _wf.workflow.identifier === newWorkflow.triggers[0].identifier ); // New workflow should inherit global settings expect(newWorkflowPreferences?.channels).to.deep.equal({ email: false, inApp: true }); }); it('should filter preferences by contextKeys', async () => { // Create preference for context A await novuClient.subscribers.preferences.update( { workflowId: workflow._id, channels: { email: false }, context: { tenant: 'acme' }, }, subscriber.subscriberId ); // Create preference for context B const workflow2 = await session.createTemplate({ noFeedId: true }); await novuClient.subscribers.preferences.update( { workflowId: workflow2._id, channels: { email: false }, context: { tenant: 'globex' }, }, subscriber.subscriberId ); // List with context A filter const responseA = await novuClient.subscribers.preferences.list({ subscriberId: subscriber.subscriberId, contextKeys: ['tenant:acme'], }); // Should return BOTH workflows (all workflows always returned regardless of context) const workflowIdentifiers = responseA.result.workflows.map((w) => w.workflow.identifier); expect(workflowIdentifiers).to.include(workflow.triggers[0].identifier); expect(workflowIdentifiers).to.include(workflow2.triggers[0].identifier); // workflow1 uses tenant:acme preference (email: false) const wf1 = responseA.result.workflows.find((w) => w.workflow.identifier === workflow.triggers[0].identifier); expect(wf1?.channels.email).to.equal(false); // workflow2 falls back to global/default (email: true by default) const wf2 = responseA.result.workflows.find((w) => w.workflow.identifier === workflow2.triggers[0].identifier); expect(wf2?.channels.email).to.equal(true); }); it('should return default preferences when no context-specific preference exists', async () => { // Create workflow preference for context A await novuClient.subscribers.preferences.update( { workflowId: workflow._id, channels: { email: false }, context: { tenant: 'acme' }, }, subscriber.subscriberId ); // List with different context B (no specific preference exists) const response = await novuClient.subscribers.preferences.list({ subscriberId: subscriber.subscriberId, contextKeys: ['tenant:globex'], }); // Should return workflow with default/inherited settings expect(response.result.workflows).to.have.lengthOf(1); // Default should be enabled expect(response.result.workflows[0].channels.email).to.equal(true); }); it('should isolate preferences per context', async () => { // Set global preference for context B await novuClient.subscribers.preferences.update( { channels: { email: false, inApp: false }, context: { tenant: 'globex' }, }, subscriber.subscriberId ); // Create workflow preference for context A (override email) await novuClient.subscribers.preferences.update( { workflowId: workflow._id, channels: { email: true }, // Override to true context: { tenant: 'acme' }, }, subscriber.subscriberId ); // List with context A - should see workflow override and default global const responseA = await novuClient.subscribers.preferences.list({ subscriberId: subscriber.subscriberId, contextKeys: ['tenant:acme'], }); expect(responseA.result.workflows[0].channels.email).to.equal(true); expect(responseA.result.global.channels.email).to.equal(true); // No global set for this context, uses default // List with context B - should see the global preference set for this context const responseB = await novuClient.subscribers.preferences.list({ subscriberId: subscriber.subscriberId, contextKeys: ['tenant:globex'], }); expect(responseB.result.global.channels.email).to.equal(false); // Global preference for tenant:globex expect(responseB.result.workflows[0].channels.email).to.equal(false); // Inherits from global }); }); async function createSubscriberAndValidate(id: string = '') { const payload = { subscriberId: `test-subscriber-${id}`, firstName: `Test ${id}`, lastName: 'Subscriber', email: `test-${id}@subscriber.com`, phone: '+1234567890', }; const res = await session.testAgent.post(`/v1/subscribers`).send(payload); expect(res.status).to.equal(201); const subscriber = res.body.data; expect(subscriber.subscriberId).to.equal(payload.subscriberId); expect(subscriber.firstName).to.equal(payload.firstName); expect(subscriber.lastName).to.equal(payload.lastName); expect(subscriber.email).to.equal(payload.email); expect(subscriber.phone).to.equal(payload.phone); return subscriber; } ================================================ FILE: apps/api/src/app/subscribers-v2/e2e/get-subscriber.e2e.ts ================================================ import { Novu } from '@novu/api'; import { SubscriberResponseDto } from '@novu/api/models/components'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { randomBytes } from 'crypto'; import { expectSdkExceptionGeneric, initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; let session: UserSession; describe('Get Subscriber - /subscribers/:subscriberId (GET) #novu-v2', () => { let subscriber: SubscriberResponseDto; let novuClient: Novu; beforeEach(async () => { const uuid = randomBytes(4).toString('hex'); session = new UserSession(); await session.initialize(); subscriber = await createSubscriberAndValidate(uuid); novuClient = initNovuClassSdk(session); }); it('should fetch subscriber by subscriberId', async () => { const res = await novuClient.subscribers.retrieve(subscriber.subscriberId); validateSubscriber(res.result, subscriber); }); it('should return 404 if subscriberId does not exist', async () => { const invalidSubscriberId = `non-existent-${randomBytes(2).toString('hex')}`; const { error } = await expectSdkExceptionGeneric(() => novuClient.subscribers.retrieve(invalidSubscriberId)); expect(error?.statusCode).to.equal(404); }); it('should return null values if subscriber has null or undefined values', async () => { const subscriberId = `test-subscriber-${`${randomBytes(4).toString('hex')}`}`; const payload = { subscriberId, }; await novuClient.subscribers.create(payload); const res = await novuClient.subscribers.retrieve(subscriberId); expect(res.result.firstName).to.be.undefined; expect(res.result.lastName).to.be.undefined; }); }); async function createSubscriberAndValidate(id: string = '') { const payload = { subscriberId: `test-subscriber-${id}`, firstName: `Test ${id}`, lastName: 'Subscriber', email: `test-${id}@subscriber.com`, phone: '+1234567890', }; const res = await session.testAgent.post(`/v1/subscribers`).send(payload); expect(res.status).to.equal(201); const subscriber = res.body.data; validateSubscriber(subscriber, payload); return subscriber; } function validateSubscriber(subscriber: SubscriberResponseDto, expected: Partial) { expect(subscriber.subscriberId).to.equal(expected.subscriberId); expect(subscriber.firstName).to.equal(expected.firstName); expect(subscriber.lastName).to.equal(expected.lastName); expect(subscriber.email).to.equal(expected.email); expect(subscriber.phone).to.equal(expected.phone); } ================================================ FILE: apps/api/src/app/subscribers-v2/e2e/list-subscriber-subscriptions.e2e.ts ================================================ import { Novu } from '@novu/api'; import { SubscriberEntity, TopicSubscribersRepository } from '@novu/dal'; import { SubscribersService, UserSession } from '@novu/testing'; import { expect } from 'chai'; import { initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('List subscriber subscriptions - /v2/subscribers/:subscriberId/subscriptions (GET) #novu-v2', async () => { let session: UserSession; let novuClient: Novu; let subscriber: SubscriberEntity; let topicSubscribersRepository: TopicSubscribersRepository; const topicKeys: string[] = []; before(async () => { session = new UserSession(); await session.initialize(); novuClient = initNovuClassSdk(session); topicSubscribersRepository = new TopicSubscribersRepository(); // Create a subscriber const subscribersService = new SubscribersService(session.organization._id, session.environment._id); subscriber = await subscribersService.createSubscriber(); // Create multiple topics for (let i = 0; i < 3; i++) { const topicKey = `topic-key-${Date.now()}-${i}`; topicKeys.push(topicKey); await novuClient.topics.create({ key: topicKey, name: `Test Topic ${i}`, }); } // Add subscriber to topics for (const topicKey of topicKeys) { await novuClient.topics.subscriptions.create( { subscriberIds: [subscriber.subscriberId], }, topicKey ); } }); it('should list all topic subscriptions for a subscriber', async () => { const response = await novuClient.subscribers.topics.list({ subscriberId: subscriber.subscriberId, }); expect(response).to.exist; expect(response.result.data.length).to.equal(topicKeys.length); // Check response structure for each subscription response.result.data.forEach((subscription) => { expect(subscription).to.have.property('id'); expect(subscription).to.have.property('topic'); expect(subscription).to.have.property('subscriber'); expect(subscription.subscriber.subscriberId).to.equal(subscriber.subscriberId); expect(topicKeys).to.include(subscription.topic.key); }); }); it('should filter subscriptions by topic key', async () => { const targetTopicKey = topicKeys[0]; const response = await novuClient.subscribers.topics.list({ subscriberId: subscriber.subscriberId, key: targetTopicKey, }); expect(response).to.exist; expect(response.result.data.length).to.equal(1); expect(response.result.data[0].topic.key).to.equal(targetTopicKey); expect(response.result.data[0].subscriber.subscriberId).to.equal(subscriber.subscriberId); }); it('should paginate subscriptions with limit parameter and provide correct cursors', async () => { const limit = 1; // First page const firstPageResponse = await novuClient.subscribers.topics.list({ subscriberId: subscriber.subscriberId, limit, }); expect(firstPageResponse).to.exist; expect(firstPageResponse.result.data.length).to.equal(limit); expect(firstPageResponse.result.next).to.be.a('string'); expect(firstPageResponse.result.previous).to.be.null; // Second page using 'after' cursor const secondPageResponse = await novuClient.subscribers.topics.list({ subscriberId: subscriber.subscriberId, limit, after: firstPageResponse.result.next as string, }); expect(secondPageResponse).to.exist; expect(secondPageResponse.result.data.length).to.be.at.most(limit); expect(secondPageResponse.result.previous).to.be.a('string'); // This should now be set correctly if (topicKeys.length > 2) { expect(secondPageResponse.result.next).to.be.a('string'); // Third page using 'after' cursor const thirdPageResponse = await novuClient.subscribers.topics.list({ subscriberId: subscriber.subscriberId, limit, after: secondPageResponse.result.next as string, }); expect(thirdPageResponse).to.exist; expect(thirdPageResponse.result.data.length).to.be.at.most(limit); expect(thirdPageResponse.result.previous).to.be.a('string'); // Go back to second page using 'before' cursor from third page const backToSecondResponse = await novuClient.subscribers.topics.list({ subscriberId: subscriber.subscriberId, limit, before: thirdPageResponse.result.previous as string, }); expect(backToSecondResponse).to.exist; expect(backToSecondResponse.result.data.length).to.be.at.most(limit); expect(backToSecondResponse.result.next).to.be.a('string'); expect(backToSecondResponse.result.previous).to.be.a('string'); // IDs should match the second page we got earlier expect(backToSecondResponse.result.data[0].id).to.equal(secondPageResponse.result.data[0].id); } // Verify different items on each page const firstPageIds = firstPageResponse.result.data.map((sub) => sub.id); const secondPageIds = secondPageResponse.result.data.map((sub) => sub.id); // No duplicate items between pages const intersection = firstPageIds.filter((id) => secondPageIds.includes(id)); expect(intersection.length).to.equal(0); }); it('should return 404 for non-existent subscriber', async () => { const nonExistentId = 'non-existent-subscriber-id'; try { await novuClient.subscribers.topics.list({ subscriberId: nonExistentId, }); throw new Error('Should have failed to list subscriptions for non-existent subscriber'); } catch (error) { expect(error.statusCode).to.equal(404); expect(error.message).to.include('Subscriber not found'); } }); it('should return empty array for subscriber with no subscriptions', async () => { // Create a subscriber with no subscriptions const subscribersService = new SubscribersService(session.organization._id, session.environment._id); const newSubscriber = await subscribersService.createSubscriber(); const response = await novuClient.subscribers.topics.list({ subscriberId: newSubscriber.subscriberId, }); expect(response).to.exist; expect(response.result.data).to.be.an('array').that.is.empty; expect(response.result.next).to.be.null; expect(response.result.previous).to.be.null; }); }); ================================================ FILE: apps/api/src/app/subscribers-v2/e2e/patch-subscriber-preferences.e2e.ts ================================================ import { Novu } from '@novu/api'; import { BulkUpdateSubscriberPreferencesDto, PatchSubscriberPreferencesDto, SubscriberResponseDto, } from '@novu/api/models/components'; import { buildSlug } from '@novu/application-generic'; import { NotificationTemplateEntity } from '@novu/dal'; import { ShortIsPrefixEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { randomBytes } from 'crypto'; import { expectSdkExceptionGeneric, expectSdkValidationExceptionGeneric, initNovuClassSdk, } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; let session: UserSession; describe('Patch Subscriber Preferences - /subscribers/:subscriberId/preferences (PATCH) #novu-v2', () => { let novuClient: Novu; let subscriber: SubscriberResponseDto; let workflow: NotificationTemplateEntity; beforeEach(async () => { (process.env as any).IS_CONTEXT_PREFERENCES_ENABLED = 'true'; const uuid = randomBytes(4).toString('hex'); session = new UserSession(); await session.initialize(); novuClient = initNovuClassSdk(session); subscriber = await createSubscriberAndValidate(uuid); workflow = await session.createTemplate({ noFeedId: true, }); }); afterEach(() => { delete (process.env as any).IS_CONTEXT_PREFERENCES_ENABLED; }); it('should patch workflow channel preferences', async () => { // Patch with workflow id const workflowId = workflow._id; const patchWithWorkflowId: PatchSubscriberPreferencesDto = { channels: { email: false, inApp: true, }, workflowId, }; const responseOne = await novuClient.subscribers.preferences.update(patchWithWorkflowId, subscriber.subscriberId); const { global, workflows: workflowsOne } = responseOne.result; expect(global.channels).to.deep.equal({ inApp: true, email: true }); expect(workflowsOne).to.have.lengthOf(1); expect(workflowsOne[0].channels).to.deep.equal({ inApp: true, email: false }); expect(workflowsOne[0].workflow).to.deep.include({ name: workflow.name, identifier: workflow.triggers[0].identifier, }); // Patch with trigger identifier const triggerIdentifier = workflow.triggers[0].identifier; const patchWithTriggerIdentifier: PatchSubscriberPreferencesDto = { channels: { email: true, inApp: false, }, workflowId: triggerIdentifier, }; const responseTwo = await novuClient.subscribers.preferences.update( patchWithTriggerIdentifier, subscriber.subscriberId ); const { workflows: workflowsTwo } = responseTwo.result; expect(workflowsTwo[0].channels).to.deep.equal({ inApp: false, email: true }); // Patch with slug const slug = buildSlug(workflow.name, ShortIsPrefixEnum.WORKFLOW, workflow._id); const patchData: PatchSubscriberPreferencesDto = { channels: { email: false, inApp: true, }, workflowId: slug, }; const response = await novuClient.subscribers.preferences.update(patchData, subscriber.subscriberId); const { workflows: workflowsThree } = response.result; expect(workflowsThree[0].channels).to.deep.equal({ inApp: true, email: false }); }); it('should patch global channel preferences', async () => { const patchData: PatchSubscriberPreferencesDto = { channels: { email: false, inApp: false, }, }; const response = await novuClient.subscribers.preferences.update(patchData, subscriber.subscriberId); const { global, workflows } = response.result; expect(global.channels).to.deep.equal({ inApp: false, email: false }); expect(workflows).to.have.lengthOf(1); expect(workflows[0].channels).to.deep.equal({ inApp: false, email: false }); expect(workflows[0].workflow).to.deep.include({ name: workflow.name, identifier: workflow.triggers[0].identifier }); }); it('should return 404 when patching non-existent subscriber preferences', async () => { const invalidSubscriberId = `non-existent-${randomBytes(2).toString('hex')}`; const patchData: PatchSubscriberPreferencesDto = { channels: { email: false, }, }; const { error } = await expectSdkExceptionGeneric(() => novuClient.subscribers.preferences.update(patchData, invalidSubscriberId) ); expect(error?.statusCode).to.equal(404); }); it('should return 400 when patching with invalid workflow id', async () => { const patchData: PatchSubscriberPreferencesDto = { channels: { email: false, }, workflowId: 'invalid-workflow-id', }; try { await expectSdkValidationExceptionGeneric(() => novuClient.subscribers.preferences.update(patchData, subscriber.subscriberId) ); } catch (e) { // TODO: fix in SDK util expect(e).to.be.an.instanceOf(Error); } }); it('should bulk update multiple workflow preferences', async () => { const workflow2 = await session.createTemplate({ noFeedId: true, }); const workflow3 = await session.createTemplate({ noFeedId: true, }); const bulkUpdateData: BulkUpdateSubscriberPreferencesDto = { preferences: [ { workflowId: workflow._id, channels: { email: false, inApp: true, sms: false, }, }, { workflowId: workflow2._id, channels: { email: true, inApp: false, push: true, }, }, { workflowId: workflow3.triggers[0].identifier, // Test with trigger identifier channels: { email: false, inApp: true, chat: true, }, }, ], }; const response = await novuClient.subscribers.preferences.bulkUpdate(bulkUpdateData, subscriber.subscriberId); expect(response.result).to.be.an('array'); expect(response.result).to.have.lengthOf(3); // Verify each preference was updated correctly const preferences = response.result; const pref1 = preferences.find((p) => p.workflow?.id === workflow._id); expect(pref1).to.exist; expect(pref1?.channels.email).to.equal(false); expect(pref1?.channels.inApp).to.equal(true); const pref2 = preferences.find((p) => p.workflow?.id === workflow2._id); expect(pref2).to.exist; expect(pref2?.channels.email).to.equal(true); expect(pref2?.channels.inApp).to.equal(false); const pref3 = preferences.find((p) => p.workflow?.id === workflow3._id); expect(pref3).to.exist; expect(pref3?.channels.email).to.equal(false); expect(pref3?.channels.inApp).to.equal(true); }); it('should return 422 when bulk updating with more than 100 preferences', async () => { const preferences = Array.from({ length: 101 }, (_, i) => ({ workflowId: workflow._id, channels: { email: i % 2 === 0, }, })); const bulkUpdateData = { preferences }; const { error } = await expectSdkValidationExceptionGeneric(() => novuClient.subscribers.preferences.bulkUpdate(bulkUpdateData, subscriber.subscriberId) ); expect(error?.statusCode).to.equal(422); expect(error?.message).to.include('Validation Error'); }); it('should return 404 when bulk updating preferences for non-existent subscriber', async () => { const invalidSubscriberId = `non-existent-${randomBytes(2).toString('hex')}`; const bulkUpdateData = { preferences: [ { workflowId: workflow._id, channels: { email: false, }, }, ], }; const { error } = await expectSdkExceptionGeneric(() => novuClient.subscribers.preferences.bulkUpdate(bulkUpdateData, invalidSubscriberId) ); expect(error?.statusCode).to.equal(404); }); it('should return 404 when bulk updating with non-existent workflow ids', async () => { const bulkUpdateData = { preferences: [ { workflowId: 'non-existent-workflow-id', channels: { email: false, }, }, ], }; const { error } = await expectSdkExceptionGeneric(() => novuClient.subscribers.preferences.bulkUpdate(bulkUpdateData, subscriber.subscriberId) ); expect(error?.statusCode).to.equal(404); expect(error?.message).to.include('Workflows with ids: non-existent-workflow-id not found'); }); it('should create workflow preference with context', async () => { const patchData: PatchSubscriberPreferencesDto = { workflowId: workflow._id, channels: { email: false, inApp: true, }, context: { tenant: 'acme' }, }; const response = await novuClient.subscribers.preferences.update(patchData, subscriber.subscriberId); expect(response.result.workflows).to.have.lengthOf(1); expect(response.result.workflows[0].channels).to.deep.equal({ inApp: true, email: false }); }); it('should create separate preferences for different contexts', async () => { // Create preference for context A await novuClient.subscribers.preferences.update( { workflowId: workflow._id, channels: { email: false }, context: { tenant: 'acme' }, }, subscriber.subscriberId ); // Create preference for context B await novuClient.subscribers.preferences.update( { workflowId: workflow._id, channels: { email: true }, context: { tenant: 'globex' }, }, subscriber.subscriberId ); // Both should coexist - verify by listing with different contextKeys const responseA = await novuClient.subscribers.preferences.list({ subscriberId: subscriber.subscriberId, contextKeys: ['tenant:acme'], }); expect(responseA.result.workflows[0].channels.email).to.equal(false); const responseB = await novuClient.subscribers.preferences.list({ subscriberId: subscriber.subscriberId, contextKeys: ['tenant:globex'], }); expect(responseB.result.workflows[0].channels.email).to.equal(true); }); it('should bulk update with context', async () => { const bulkUpdateData: BulkUpdateSubscriberPreferencesDto = { context: { tenant: 'acme' }, preferences: [ { workflowId: workflow._id, channels: { email: false, inApp: true, }, }, ], }; const response = await novuClient.subscribers.preferences.bulkUpdate(bulkUpdateData, subscriber.subscriberId); expect(response.result).to.have.lengthOf(1); expect(response.result[0].channels.email).to.equal(false); // Verify it's stored with context const listResponse = await novuClient.subscribers.preferences.list({ subscriberId: subscriber.subscriberId, contextKeys: ['tenant:acme'], }); expect(listResponse.result.workflows[0].channels.email).to.equal(false); }); }); async function createSubscriberAndValidate(id: string = '') { const payload = { subscriberId: `test-subscriber-${id}`, firstName: `Test ${id}`, lastName: 'Subscriber', email: `test-${id}@subscriber.com`, phone: '+1234567890', }; const res = await session.testAgent.post(`/v1/subscribers`).send(payload); expect(res.status).to.equal(201); const subscriber = res.body.data; expect(subscriber.subscriberId).to.equal(payload.subscriberId); expect(subscriber.firstName).to.equal(payload.firstName); expect(subscriber.lastName).to.equal(payload.lastName); expect(subscriber.email).to.equal(payload.email); expect(subscriber.phone).to.equal(payload.phone); return subscriber; } ================================================ FILE: apps/api/src/app/subscribers-v2/e2e/patch-subscriber.e2e.ts ================================================ import { Novu } from '@novu/api'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { randomBytes } from 'crypto'; import { expectSdkExceptionGeneric, expectSdkValidationExceptionGeneric, initNovuClassSdk, } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; import { SubscriberResponseDto } from '../../subscribers/dtos'; let session: UserSession; describe('Update Subscriber - /subscribers/:subscriberId (PATCH) #novu-v2', () => { let subscriber: SubscriberResponseDto; let novuClient: Novu; beforeEach(async () => { const uuid = randomBytes(4).toString('hex'); session = new UserSession(); await session.initialize(); subscriber = await createSubscriberAndValidate(uuid); novuClient = initNovuClassSdk(session); }); it('should update the fields of the subscriber', async () => { const payload = { firstName: 'Updated First Name', lastName: 'Updated Last Name', }; const res = await novuClient.subscribers.patch(payload, subscriber.subscriberId); const updatedSubscriber = res.result; expect(subscriber.firstName).to.not.equal(updatedSubscriber.firstName); expect(updatedSubscriber.firstName).to.equal(payload.firstName); expect(subscriber.lastName).to.not.equal(updatedSubscriber.lastName); expect(updatedSubscriber.lastName).to.equal(payload.lastName); expect(subscriber.subscriberId).to.equal(updatedSubscriber.subscriberId); expect(subscriber.email).to.equal(updatedSubscriber.email); expect(subscriber.phone).to.equal(updatedSubscriber.phone); }); it('should return 404 if subscriberId does not exist', async () => { const payload = { firstName: 'Updated First Name', lastName: 'Updated Last Name', }; const invalidSubscriberId = `non-existent-${randomBytes(2).toString('hex')}`; const { error } = await expectSdkExceptionGeneric(() => novuClient.subscribers.patch(payload, invalidSubscriberId)); expect(error?.statusCode).to.equal(404); }); it('should return the original subscriber if no fields are updated', async () => { const res = await novuClient.subscribers.patch({}, subscriber.subscriberId); const updatedSubscriber = res.result; expect(subscriber.firstName).to.equal(updatedSubscriber.firstName); expect(subscriber.lastName).to.equal(updatedSubscriber.lastName); expect(subscriber.email).to.equal(updatedSubscriber.email); expect(subscriber.phone).to.equal(updatedSubscriber.phone); }); it('should clear simple fields with null', async () => { const payload = { firstName: null, lastName: null, phone: null, avatar: null, }; const res = await novuClient.subscribers.patch(payload, subscriber.subscriberId); const updatedSubscriber = res.result; expect(updatedSubscriber.firstName).to.be.null; expect(updatedSubscriber.lastName).to.be.null; expect(updatedSubscriber.phone).to.be.null; expect(updatedSubscriber.avatar).to.be.null; }); it('should clear simple fields with empty string', async () => { const payload = { firstName: '', lastName: '', phone: '', avatar: '', }; const res = await novuClient.subscribers.patch(payload, subscriber.subscriberId); const updatedSubscriber = res.result; expect(updatedSubscriber.firstName).to.equal(payload.firstName); expect(updatedSubscriber.lastName).to.equal(payload.lastName); expect(updatedSubscriber.phone).to.equal(payload.phone); expect(updatedSubscriber.avatar).to.equal(payload.avatar); }); it('should clear complex fields with null', async () => { const payload = { email: null, locale: null, timezone: null, }; const res = await novuClient.subscribers.patch(payload, subscriber.subscriberId); const updatedSubscriber = res.result; expect(updatedSubscriber.email).to.be.null; expect(updatedSubscriber.locale).to.be.null; expect(updatedSubscriber.timezone).to.be.null; }); it('should reject empty strings for complex fields (email)', async () => { const payload = { email: '', }; const { error } = await expectSdkValidationExceptionGeneric(() => novuClient.subscribers.patch(payload, subscriber.subscriberId) ); expect(error?.statusCode).to.equal(422); const errorMessages = JSON.stringify(error?.errors); expect(errorMessages).to.include('email'); }); it('should reject empty strings for complex fields (locale)', async () => { const payload = { locale: '', }; const { error } = await expectSdkValidationExceptionGeneric(() => novuClient.subscribers.patch(payload, subscriber.subscriberId) ); expect(error?.statusCode).to.equal(422); const errorMessages = JSON.stringify(error?.errors); expect(errorMessages).to.include('locale'); }); it('should reject empty strings for complex fields (timezone)', async () => { const payload = { timezone: '', }; const { error } = await expectSdkValidationExceptionGeneric(() => novuClient.subscribers.patch(payload, subscriber.subscriberId) ); expect(error?.statusCode).to.equal(422); const errorMessages = JSON.stringify(error?.errors); expect(errorMessages).to.include('timezone'); }); it('should validate email format', async () => { const payload = { email: 'invalid-email', }; const { error } = await expectSdkValidationExceptionGeneric(() => novuClient.subscribers.patch(payload, subscriber.subscriberId) ); expect(error?.statusCode).to.equal(422); const errorMessages = JSON.stringify(error?.errors); expect(errorMessages).to.include('email'); }); it('should validate locale format', async () => { const payload = { locale: '!!!invalid!!!', }; const { error } = await expectSdkValidationExceptionGeneric(() => novuClient.subscribers.patch(payload, subscriber.subscriberId) ); expect(error?.statusCode).to.equal(422); const errorMessages = JSON.stringify(error?.errors); expect(errorMessages).to.include('locale'); }); it('should validate timezone format', async () => { const payload = { timezone: 'Invalid/Timezone', }; const { error } = await expectSdkValidationExceptionGeneric(() => novuClient.subscribers.patch(payload, subscriber.subscriberId) ); expect(error?.statusCode).to.equal(422); const errorMessages = JSON.stringify(error?.errors); expect(errorMessages).to.include('timezone'); }); it('should clear data field with null', async () => { const payload = { data: null, }; const res = await novuClient.subscribers.patch(payload, subscriber.subscriberId); const updatedSubscriber = res.result; expect(updatedSubscriber.data).to.be.null; }); it('should not change fields that are not provided (undefined semantics)', async () => { const payload = { firstName: 'Updated Name', }; const res = await novuClient.subscribers.patch(payload, subscriber.subscriberId); const updatedSubscriber = res.result; expect(updatedSubscriber.firstName).to.equal('Updated Name'); expect(updatedSubscriber.email).to.equal(subscriber.email); expect(updatedSubscriber.phone).to.equal(subscriber.phone); }); it('should not allow updating subscriberId', async () => { const newSubscriberId = `new-subscriber-${randomBytes(4).toString('hex')}`; const payload = { subscriberId: newSubscriberId, firstName: 'Updated', }; const res = await novuClient.subscribers.patch(payload as any, subscriber.subscriberId); const updatedSubscriber = res.result; expect(updatedSubscriber.subscriberId).to.equal(subscriber.subscriberId); expect(updatedSubscriber.subscriberId).to.not.equal(newSubscriberId); expect(updatedSubscriber.firstName).to.equal('Updated'); }); }); async function createSubscriberAndValidate(id: string = '') { const payload = { subscriberId: `test-subscriber-${id}`, firstName: `Test ${id}`, lastName: 'Subscriber', email: `test-${id}@subscriber.com`, phone: '+1234567890', }; const res = await session.testAgent.post(`/v1/subscribers`).send(payload); expect(res.status).to.equal(201); const subscriber = res.body.data; expect(subscriber.subscriberId).to.equal(payload.subscriberId); expect(subscriber.firstName).to.equal(payload.firstName); expect(subscriber.lastName).to.equal(payload.lastName); expect(subscriber.email).to.equal(payload.email); expect(subscriber.phone).to.equal(payload.phone); return subscriber; } ================================================ FILE: apps/api/src/app/subscribers-v2/e2e/subscriber-notifications.e2e.ts ================================================ import { Novu } from '@novu/api'; import type { InboxNotificationDto } from '@novu/api/models/components'; import { NotificationTemplateEntity } from '@novu/dal'; import { ActorTypeEnum, ButtonTypeEnum, ChannelCTATypeEnum, ChannelTypeEnum, StepTypeEnum, SystemAvatarIconEnum, TemplateVariableTypeEnum, } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { randomBytes } from 'crypto'; import { initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; function validateInboxNotificationDto(notification: InboxNotificationDto): void { expect(notification.id).to.be.a('string').that.is.not.empty; expect(notification.transactionId).to.be.a('string').that.is.not.empty; expect(notification.body).to.be.a('string'); expect(notification.to).to.be.an('object'); expect(notification.to.subscriberId).to.be.a('string').that.is.not.empty; expect(notification.to.id).to.be.a('string'); expect(notification.isRead).to.be.a('boolean'); expect(notification.isSeen).to.be.a('boolean'); expect(notification.isArchived).to.be.a('boolean'); expect(notification.isSnoozed).to.be.a('boolean'); expect(notification.createdAt) .to.be.a('string') .that.matches(/^\d{4}-/); expect(notification.channelType).to.equal(ChannelTypeEnum.IN_APP); expect(notification.severity).to.be.a('string'); if (notification.readAt !== undefined && notification.readAt !== null) { expect(notification.readAt) .to.be.a('string') .that.matches(/^\d{4}-/); } if (notification.snoozedUntil !== undefined && notification.snoozedUntil !== null) { expect(notification.snoozedUntil) .to.be.a('string') .that.matches(/^\d{4}-/); } if (notification.archivedAt !== undefined && notification.archivedAt !== null) { expect(notification.archivedAt) .to.be.a('string') .that.matches(/^\d{4}-/); } } describe('Subscriber notifications - /v2/subscribers/:subscriberId/notifications (SDK) #novu-v2', () => { let session: UserSession; let novuClient: Novu; let subscriberId: string; let notificationId: string; let template: NotificationTemplateEntity; beforeEach(async () => { session = new UserSession(); await session.initialize({ noWidgetSession: true }); novuClient = initNovuClassSdk(session); subscriberId = `test-sub-notif-${randomBytes(6).toString('hex')}`; await novuClient.subscribers.create({ subscriberId }); template = await session.createTemplate({ noFeedId: true, steps: [ { type: StepTypeEnum.IN_APP, content: 'Test content for {{firstName}}', cta: { type: ChannelCTATypeEnum.REDIRECT, data: { url: '', }, action: { buttons: [ { type: ButtonTypeEnum.PRIMARY, content: 'Primary' }, { type: ButtonTypeEnum.SECONDARY, content: 'Secondary' }, ], }, }, variables: [ { defaultValue: '', name: 'firstName', required: false, type: TemplateVariableTypeEnum.STRING, }, ], actor: { type: ActorTypeEnum.SYSTEM_ICON, data: SystemAvatarIconEnum.WARNING, }, }, ], }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: { subscriberId }, }); await session.waitForJobCompletion(template._id, undefined); const listRes = await novuClient.subscribers.notifications.list({ subscriberId, limit: 10, }); expect(listRes.result.data.length).to.be.at.least(1); notificationId = listRes.result.data[0].id; validateInboxNotificationDto(listRes.result.data[0]); }); it('should list notifications via SDK', async () => { const listRes = await novuClient.subscribers.notifications.list({ subscriberId, limit: 10, }); expect(listRes.result.hasMore).to.be.a('boolean'); expect(listRes.result.filter).to.be.an('object'); expect(listRes.result.data.length).to.be.at.least(1); validateInboxNotificationDto(listRes.result.data[0]); }); it('should return notification counts via SDK', async () => { const countRes = await novuClient.subscribers.notifications.count(subscriberId, JSON.stringify([{}])); expect(countRes.result).to.be.an('array').with.lengthOf(1); expect(countRes.result[0].count).to.be.a('number').that.is.at.least(1); expect(countRes.result[0].filter).to.be.an('object'); }); it('should mark notification as read and unread via SDK', async () => { const readRes = await novuClient.subscribers.notifications.markAsRead({ subscriberId, notificationId, }); validateInboxNotificationDto(readRes.result); expect(readRes.result.isRead).to.equal(true); expect(readRes.result.readAt).to.be.a('string'); const unreadRes = await novuClient.subscribers.notifications.markAsUnread({ subscriberId, notificationId, }); validateInboxNotificationDto(unreadRes.result); expect(unreadRes.result.isRead).to.equal(false); }); it('should archive and unarchive notification via SDK', async () => { const archivedRes = await novuClient.subscribers.notifications.archive({ subscriberId, notificationId, }); validateInboxNotificationDto(archivedRes.result); expect(archivedRes.result.isArchived).to.equal(true); expect(archivedRes.result.archivedAt).to.be.a('string'); const unarchivedRes = await novuClient.subscribers.notifications.unarchive({ subscriberId, notificationId, }); validateInboxNotificationDto(unarchivedRes.result); expect(unarchivedRes.result.isArchived).to.equal(false); }); it('should snooze and unsnooze notification via SDK', async () => { const snoozeUntil = new Date(Date.now() + 3 * 60 * 1000); const snoozedRes = await novuClient.subscribers.notifications.snooze({ subscriberId, notificationId, snoozeSubscriberNotificationDto: { snoozeUntil }, }); validateInboxNotificationDto(snoozedRes.result); expect(snoozedRes.result.isSnoozed).to.equal(true); expect(snoozedRes.result.snoozedUntil).to.be.a('string'); const unsnoozedRes = await novuClient.subscribers.notifications.unsnooze({ subscriberId, notificationId, }); validateInboxNotificationDto(unsnoozedRes.result); expect(unsnoozedRes.result.isSnoozed).to.equal(false); }); it('should complete and revert primary action via SDK', async () => { const completedRes = await novuClient.subscribers.notifications.completeAction({ subscriberId, notificationId, actionType: 'primary', }); validateInboxNotificationDto(completedRes.result); expect(completedRes.result.primaryAction).to.be.an('object'); expect(completedRes.result.primaryAction?.isCompleted).to.equal(true); const revertedRes = await novuClient.subscribers.notifications.revertAction({ subscriberId, notificationId, actionType: 'primary', }); validateInboxNotificationDto(revertedRes.result); expect(revertedRes.result.primaryAction?.isCompleted).to.equal(false); }); it('should complete and revert secondary action via SDK', async () => { const completedRes = await novuClient.subscribers.notifications.completeAction({ subscriberId, notificationId, actionType: 'secondary', }); validateInboxNotificationDto(completedRes.result); expect(completedRes.result.secondaryAction).to.be.an('object'); expect(completedRes.result.secondaryAction?.isCompleted).to.equal(true); const revertedRes = await novuClient.subscribers.notifications.revertAction({ subscriberId, notificationId, actionType: 'secondary', }); validateInboxNotificationDto(revertedRes.result); expect(revertedRes.result.secondaryAction?.isCompleted).to.equal(false); }); it('should mark notifications as seen via SDK', async () => { await novuClient.subscribers.notifications.markAsSeen({ notificationIds: [notificationId] }, subscriberId); const listRes = await novuClient.subscribers.notifications.list({ subscriberId, limit: 10, }); const updated = listRes.result.data.find((n) => n.id === notificationId); expect(updated).to.exist; if (!updated) { throw new Error('Expected notification after markAsSeen'); } validateInboxNotificationDto(updated); expect(updated.isSeen).to.equal(true); }); it('should mark all notifications as read via SDK', async () => { await novuClient.subscribers.notifications.markAllAsRead({}, subscriberId); const listRes = await novuClient.subscribers.notifications.list({ subscriberId, limit: 10, }); for (const n of listRes.result.data) { validateInboxNotificationDto(n); expect(n.isRead).to.equal(true); } }); it('should archive all notifications via SDK', async () => { await novuClient.subscribers.notifications.archiveAll({}, subscriberId); const listRes = await novuClient.subscribers.notifications.list({ subscriberId, limit: 10, }); for (const n of listRes.result.data) { validateInboxNotificationDto(n); expect(n.isArchived).to.equal(true); } }); it('should archive all read notifications via SDK', async () => { await novuClient.subscribers.notifications.markAllAsRead({}, subscriberId); await novuClient.subscribers.notifications.archiveAllRead({}, subscriberId); const listRes = await novuClient.subscribers.notifications.list({ subscriberId, archived: true, limit: 10, }); expect(listRes.result.data.length).to.be.at.least(1); for (const n of listRes.result.data) { validateInboxNotificationDto(n); expect(n.isArchived).to.equal(true); } }); it('should delete all notifications via SDK', async () => { await novuClient.subscribers.notifications.deleteAll({}, subscriberId); const listRes = await novuClient.subscribers.notifications.list({ subscriberId, limit: 10, }); expect(listRes.result.data).to.have.lengthOf(0); }); it('should delete a single notification via SDK', async () => { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: { subscriberId }, }); await session.waitForJobCompletion(template._id, undefined); const listBefore = await novuClient.subscribers.notifications.list({ subscriberId, limit: 10, }); expect(listBefore.result.data.length).to.be.at.least(1); const idToDelete = listBefore.result.data[0].id; await novuClient.subscribers.notifications.delete({ subscriberId, notificationId: idToDelete, }); const listAfter = await novuClient.subscribers.notifications.list({ subscriberId, limit: 10, }); expect(listAfter.result.data.find((n) => n.id === idToDelete)).to.be.undefined; }); }); ================================================ FILE: apps/api/src/app/subscribers-v2/subscribers.controller.e2e.ts ================================================ import { Novu } from '@novu/api'; import { SubscriberResponseDto } from '@novu/api/models/components'; import { OrderDirection } from '@novu/api/models/operations'; import { SubscribersControllerSearchSubscribersRequest } from '@novu/api/src/models/operations'; import { SubscriberRepository } from '@novu/dal'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { randomBytes } from 'crypto'; import { initNovuClassSdk } from '../shared/helpers/e2e/sdk/e2e-sdk.helper'; let session: UserSession; describe('Subscriber Controller E2E API Testing #novu-v2', () => { const subscriberRepository = new SubscriberRepository(); let novuClient: Novu; beforeEach(async () => { session = new UserSession(); await session.initialize({ noWidgetSession: true }); novuClient = initNovuClassSdk(session); }); describe('List Subscriber', () => { describe('List Subscriber Permutations', () => { it('should not return subscribers if not matching search params', async () => { await createSubscriberAndValidate('XYZ'); await createSubscriberAndValidate('XYZ2'); const subscribers = await getAllAndValidate({ searchParams: { email: 'nonexistent@email.com' }, expectedTotalResults: 0, expectedArraySize: 0, }); expect(subscribers).to.be.empty; }); it('should return results without any filter params', async () => { const uuid = generateUUID(); await createSubscribers(uuid, 10); await getAllAndValidate({ limit: 15, expectedTotalResults: 10, expectedArraySize: 10, }); }); it('should page subscribers without overlap using cursors', async () => { const uuid = generateUUID(); await createSubscribers(uuid, 10); const firstPage = await getListSubscribers({ limit: 5, }); const secondPage = await getListSubscribers({ after: firstPage.next || undefined, limit: 5, }); const idsDeduplicated = buildIdSet(firstPage.data, secondPage.data); expect(idsDeduplicated.size).to.be.equal(10); }); }); describe('List Subscriber Search Filters', () => { it('should find subscriber by email', async () => { const uuid = generateUUID(); await createSubscriberAndValidate(uuid); const subscribers = await getAllAndValidate({ searchParams: { email: `test-${uuid}@subscriber.com` }, expectedTotalResults: 1, expectedArraySize: 1, }); expect(subscribers[0].email).to.contain(uuid); }); it('should find subscriber by phone', async () => { const uuid = generateUUID(); await createSubscriberAndValidate(uuid); const subscribers = await getAllAndValidate({ searchParams: { phone: '1234567' }, expectedTotalResults: 1, expectedArraySize: 1, }); await getAllAndValidate({ searchParams: { phone: '7145' }, expectedTotalResults: 0, expectedArraySize: 0, }); const subscribers3 = await getAllAndValidate({ searchParams: { phone: '+1234567890' }, expectedTotalResults: 1, expectedArraySize: 1, }); expect(subscribers[0].phone).to.equal('+1234567890'); expect(subscribers3[0].phone).to.equal('+1234567890'); }); it('should find subscriber by full name', async () => { const uuid = generateUUID(); await createSubscriberAndValidate(uuid); const subscribers = await getAllAndValidate({ searchParams: { name: `Test ${uuid} Subscriber` }, expectedTotalResults: 1, expectedArraySize: 1, }); expect(subscribers[0].firstName).to.equal(`Test ${uuid}`); expect(subscribers[0].lastName).to.equal('Subscriber'); }); it('should find subscriber by subscriberId', async () => { const uuid = generateUUID(); await createSubscriberAndValidate(uuid); const subscribers = await getAllAndValidate({ searchParams: { subscriberId: `test-subscriber-${uuid}` }, expectedTotalResults: 1, expectedArraySize: 1, }); expect(subscribers[0].subscriberId).to.equal(`test-subscriber-${uuid}`); }); it('should find subscriber by partial email match', async () => { const uuid = generateUUID(); await createSubscriberAndValidate(uuid); const subscribers = await getAllAndValidate({ searchParams: { email: `test-${uuid.substring(0, 5)}` }, expectedTotalResults: 1, expectedArraySize: 1, }); expect(subscribers[0].email).to.contain(uuid); }); it('should find subscriber by partial phone match', async () => { const uuid = generateUUID(); await createSubscriberAndValidate(uuid); const subscribers = await getAllAndValidate({ searchParams: { phone: '123456' }, expectedTotalResults: 1, expectedArraySize: 1, }); expect(subscribers[0].phone).to.equal('+1234567890'); }); it('should find subscriber by partial name match', async () => { const uuid = generateUUID(); await createSubscriberAndValidate(uuid); const subscribers = await getAllAndValidate({ searchParams: { name: `Test ${uuid.substring(0, 5)}` }, expectedTotalResults: 1, expectedArraySize: 1, }); expect(subscribers[0].firstName).to.contain(uuid.substring(0, 5)); expect(subscribers[0].lastName).to.equal('Subscriber'); }); }); describe('List Subscriber Cursor Pagination', () => { it('should paginate forward using after cursor', async () => { const uuid = generateUUID(); await createSubscribers(uuid, 10); const firstPage = await getListSubscribers({ limit: 5, }); const secondPage = await getListSubscribers({ after: firstPage.next || undefined, limit: 5, }); expect(firstPage.data).to.have.lengthOf(5); expect(secondPage.data).to.have.lengthOf(5); expect(firstPage.next).to.exist; expect(secondPage.previous).to.exist; const idsDeduplicated = buildIdSet(firstPage.data, secondPage.data); expect(idsDeduplicated.size).to.equal(10); }); it('should paginate backward using before cursor', async () => { const uuid = generateUUID(); await createSubscribers(uuid, 10); const firstPage = await getListSubscribers({ limit: 5, }); const secondPage = await getListSubscribers({ after: firstPage.next || undefined, limit: 5, }); const previousPage = await getListSubscribers({ before: secondPage.previous || undefined, limit: 5, }); expect(previousPage.data).to.have.lengthOf(5); expect(previousPage.next).to.exist; expect(previousPage.data).to.deep.equal(firstPage.data); }); it('should handle pagination with limit=1', async () => { const uuid = generateUUID(); await createSubscribers(uuid, 10); const firstPage = await getListSubscribers({ limit: 1, }); expect(firstPage.data).to.have.lengthOf(1); expect(firstPage.next).to.exist; expect(firstPage.previous).to.not.exist; }); }); describe('List Subscriber Sorting', () => { it('should sort subscribers by _id in ascending order', async () => { const uuid = generateUUID(); await createSubscribers(uuid, 10); const response = await getListSubscribers({ orderBy: '_id', orderDirection: OrderDirection.Asc, limit: 10, }); const ids = response.data.map((sub) => sub.id).filter((id) => id !== undefined); const sortedIds = [...ids].sort((a, b) => a.localeCompare(b)); expect(ids).to.deep.equal(sortedIds); }); it('should sort subscribers by _id in descending order', async () => { const uuid = generateUUID(); await createSubscribers(uuid, 10); const response = await getListSubscribers({ orderBy: '_id', orderDirection: OrderDirection.Desc, limit: 10, }); const ids = response.data.map((sub) => sub.id).filter((id) => id !== undefined); const sortedIds = [...ids].sort((a, b) => b.localeCompare(a)); expect(ids).to.deep.equal(sortedIds); }); it('should maintain sort order across pages', async () => { const uuid = generateUUID(); await createSubscribers(uuid, 10); const firstPage = await getListSubscribers({ orderBy: '_id', orderDirection: OrderDirection.Desc, limit: 5, }); const secondPage = await getListSubscribers({ orderBy: '_id', orderDirection: OrderDirection.Desc, after: firstPage.next || undefined, limit: 5, }); const allIds = [...firstPage.data.map((sub) => sub.id), ...secondPage.data.map((sub) => sub.id)]; const sortedIds = [...allIds].sort((a, b) => (!a || !b ? 0 : b.localeCompare(a))); expect(allIds).to.deep.equal(sortedIds); }); }); }); describe('Create Subscriber', () => { it.skip(`should not create multiple subscribers when multiple triggers are made with the same not created subscribers `, async () => { for (let i = 0; i < 2; i += 1) { const subscriberId = `not-created-twice-subscriber${i}`; await Promise.all([ novuClient.subscribers.create({ subscriberId, firstName: 'TestSubFName', lastName: 'TestSubLName' }), novuClient.subscribers.create({ subscriberId, firstName: 'TestSubFName', lastName: 'TestSubLName' }), ]); const subscribers = await subscriberRepository.find({ _environmentId: session.environment._id, subscriberId, }); expect(subscribers.length).to.equal(1); } }); }); async function createSubscriberAndValidate(nameSuffix: string = '') { const createSubscriberDto = { subscriberId: `test-subscriber-${nameSuffix}`, firstName: `Test ${nameSuffix}`, lastName: 'Subscriber', email: `test-${nameSuffix}@subscriber.com`, phone: '+1234567890', }; const res = await novuClient.subscribers.create(createSubscriberDto); const subscriber = res.result; validateCreateSubscriberResponse(subscriber, createSubscriberDto); return subscriber; } async function createSubscribers(uuid: string, numberOfSubscribers: number) { for (let i = 0; i < numberOfSubscribers; i += 1) { await createSubscriberAndValidate(`${uuid}-${i}`); } } async function getListSubscribers(params: SubscribersControllerSearchSubscribersRequest = {}) { const res = await novuClient.subscribers.search(params); return res.result; } interface IAllAndValidate { msgPrefix?: string; searchParams?: SubscribersControllerSearchSubscribersRequest; limit?: number; expectedTotalResults: number; expectedArraySize: number; } async function getAllAndValidate({ msgPrefix = '', searchParams = {}, limit = 15, expectedTotalResults, expectedArraySize, }: IAllAndValidate) { const listResponse = await getListSubscribers({ ...searchParams, limit, }); const summary = buildLogMsg( { msgPrefix, searchParams, expectedTotalResults, expectedArraySize, }, listResponse ); expect(listResponse.data).to.be.an('array', summary); expect(listResponse.data).lengthOf(expectedArraySize, `subscribers length ${summary}`); return listResponse.data; } function buildLogMsg(params: IAllAndValidate, listResponse: any): string { return `Log - msgPrefix: ${params.msgPrefix}, searchParams: ${JSON.stringify(params.searchParams || 'Not specified', null, 2)}, expectedTotalResults: ${params.expectedTotalResults ?? 'Not specified'}, expectedArraySize: ${params.expectedArraySize ?? 'Not specified'} response: ${JSON.stringify(listResponse || 'Not specified', null, 2)}`; } function buildIdSet(listResponse1: any[], listResponse2: any[]) { const extractIDs1 = extractIDs(listResponse1); const extractIDs2 = extractIDs(listResponse2); return new Set([...extractIDs1, ...extractIDs2]); } function extractIDs(subscribers: SubscriberResponseDto[]) { return subscribers.map((subscriber) => subscriber.id); } function generateUUID(): string { const randomHex = () => randomBytes(2).toString('hex'); return `${randomHex()}${randomHex()}-${randomHex()}-${randomHex()}-${randomHex()}-${randomHex()}${randomHex()}${randomHex()}`; } function validateCreateSubscriberResponse(subscriber: SubscriberResponseDto, createDto: any) { expect(subscriber).to.be.ok; expect(subscriber.id).to.be.ok; expect(subscriber.subscriberId).to.equal(createDto.subscriberId); expect(subscriber.firstName).to.equal(createDto.firstName); expect(subscriber.lastName).to.equal(createDto.lastName); expect(subscriber.email).to.equal(createDto.email); expect(subscriber.phone).to.equal(createDto.phone); } }); ================================================ FILE: apps/api/src/app/subscribers-v2/subscribers.controller.ts ================================================ import { Body, ClassSerializerInterceptor, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Query, UseInterceptors, } from '@nestjs/common'; import { ApiExcludeEndpoint, ApiOperation, ApiParam, ApiQuery, ApiTags } from '@nestjs/swagger'; import { CreateOrUpdateSubscriberCommand, CreateOrUpdateSubscriberUseCase, ExternalApiAccessible, RequirePermissions, SubscriberResponseDto, UserSession, } from '@novu/application-generic'; import { ApiRateLimitCategoryEnum, ButtonTypeEnum, DirectionEnum, MessageActionStatusEnum, PermissionsEnum, SubscriberCustomData, UserSessionData, } from '@novu/shared'; import { RequireAuthentication } from '../auth/framework/auth.decorator'; import { GetPreferencesResponseDto } from '../inbox/dtos/get-preferences-response.dto'; import { BulkUpdatePreferencesCommand } from '../inbox/usecases/bulk-update-preferences/bulk-update-preferences.command'; import { BulkUpdatePreferences } from '../inbox/usecases/bulk-update-preferences/bulk-update-preferences.usecase'; import { DeleteAllNotificationsCommand } from '../inbox/usecases/delete-all-notifications/delete-all-notifications.command'; import { DeleteAllNotifications } from '../inbox/usecases/delete-all-notifications/delete-all-notifications.usecase'; import { DeleteNotificationCommand } from '../inbox/usecases/delete-notification/delete-notification.command'; import { DeleteNotification } from '../inbox/usecases/delete-notification/delete-notification.usecase'; import { GetNotificationsCommand } from '../inbox/usecases/get-notifications/get-notifications.command'; import { GetNotifications } from '../inbox/usecases/get-notifications/get-notifications.usecase'; import { MarkNotificationAsCommand } from '../inbox/usecases/mark-notification-as/mark-notification-as.command'; import { MarkNotificationAs } from '../inbox/usecases/mark-notification-as/mark-notification-as.usecase'; import { MarkNotificationsAsSeenCommand } from '../inbox/usecases/mark-notifications-as-seen/mark-notifications-as-seen.command'; import { MarkNotificationsAsSeen } from '../inbox/usecases/mark-notifications-as-seen/mark-notifications-as-seen.usecase'; import { NotificationsCountCommand } from '../inbox/usecases/notifications-count/notifications-count.command'; import { NotificationsCount } from '../inbox/usecases/notifications-count/notifications-count.usecase'; import { SnoozeNotificationCommand } from '../inbox/usecases/snooze-notification/snooze-notification.command'; import { SnoozeNotification } from '../inbox/usecases/snooze-notification/snooze-notification.usecase'; import { UnsnoozeNotificationCommand } from '../inbox/usecases/unsnooze-notification/unsnooze-notification.command'; import { UnsnoozeNotification } from '../inbox/usecases/unsnooze-notification/unsnooze-notification.usecase'; import { UpdateAllNotificationsCommand } from '../inbox/usecases/update-all-notifications/update-all-notifications.command'; import { UpdateAllNotifications } from '../inbox/usecases/update-all-notifications/update-all-notifications.usecase'; import { UpdateNotificationActionCommand } from '../inbox/usecases/update-notification-action/update-notification-action.command'; import { UpdateNotificationAction } from '../inbox/usecases/update-notification-action/update-notification-action.usecase'; import { ThrottlerCategory } from '../rate-limiting/guards/throttler.decorator'; import { ApiCommonResponses, ApiResponse } from '../shared/framework/response.decorator'; import { SdkGroupName, SdkMethodName } from '../shared/framework/swagger/sdk.decorators'; import { GetSubscriberGlobalPreference, GetSubscriberGlobalPreferenceCommand, } from '../subscribers/usecases/get-subscriber-global-preference'; import { ListSubscriberSubscriptionsQueryDto } from '../topics-v2/dtos/list-subscriber-subscriptions-query.dto'; import { ListTopicSubscriptionsResponseDto } from '../topics-v2/dtos/list-topic-subscriptions-response.dto'; import { ListSubscriberSubscriptionsCommand } from '../topics-v2/usecases/list-subscriber-subscriptions/list-subscriber-subscriptions.command'; import { ListSubscriberSubscriptionsUseCase } from '../topics-v2/usecases/list-subscriber-subscriptions/list-subscriber-subscriptions.usecase'; import { BulkUpdateSubscriberPreferencesDto } from './dtos/bulk-update-subscriber-preferences.dto'; import { ContextKeysQueryDto } from './dtos/context-keys-query.dto'; import { CreateSubscriberRequestDto } from './dtos/create-subscriber.dto'; import { GetSubscriberNotificationsCountQueryDto } from './dtos/get-subscriber-notifications-count-query.dto'; import { GetSubscriberNotificationsCountResponseDto } from './dtos/get-subscriber-notifications-count-response.dto'; import { GetSubscriberNotificationsQueryDto } from './dtos/get-subscriber-notifications-query.dto'; import { GetSubscriberNotificationsResponseDto } from './dtos/get-subscriber-notifications-response.dto'; import { GetSubscriberPreferencesDto } from './dtos/get-subscriber-preferences.dto'; import { GetSubscriberPreferencesRequestDto } from './dtos/get-subscriber-preferences-request.dto'; import { InboxNotificationDto } from './dtos/inbox-notification.dto'; import { ListSubscribersQueryDto } from './dtos/list-subscribers-query.dto'; import { ListSubscribersResponseDto } from './dtos/list-subscribers-response.dto'; import { MarkSubscriberNotificationsAsSeenDto } from './dtos/mark-subscriber-notifications-as-seen.dto'; import { PatchSubscriberRequestDto } from './dtos/patch-subscriber.dto'; import { PatchSubscriberPreferencesDto } from './dtos/patch-subscriber-preferences.dto'; import { RemoveSubscriberResponseDto } from './dtos/remove-subscriber.dto'; import { SnoozeSubscriberNotificationDto } from './dtos/snooze-subscriber-notification.dto'; import { SubscriberGlobalPreferenceDto } from './dtos/subscriber-global-preference.dto'; import { UpdateAllSubscriberNotificationsDto } from './dtos/update-all-subscriber-notifications.dto'; import { GetSubscriberCommand } from './usecases/get-subscriber/get-subscriber.command'; import { GetSubscriber } from './usecases/get-subscriber/get-subscriber.usecase'; import { GetSubscriberPreferencesCommand } from './usecases/get-subscriber-preferences/get-subscriber-preferences.command'; import { GetSubscriberPreferences } from './usecases/get-subscriber-preferences/get-subscriber-preferences.usecase'; import { ListSubscribersCommand } from './usecases/list-subscribers/list-subscribers.command'; import { ListSubscribersUseCase } from './usecases/list-subscribers/list-subscribers.usecase'; import { mapSubscriberEntityToDto } from './usecases/list-subscribers/map-subscriber-entity-to.dto'; import { PatchSubscriberCommand } from './usecases/patch-subscriber/patch-subscriber.command'; import { PatchSubscriber } from './usecases/patch-subscriber/patch-subscriber.usecase'; import { RemoveSubscriberCommand } from './usecases/remove-subscriber/remove-subscriber.command'; import { RemoveSubscriber } from './usecases/remove-subscriber/remove-subscriber.usecase'; import { UpdateSubscriberPreferencesCommand } from './usecases/update-subscriber-preferences/update-subscriber-preferences.command'; import { UpdateSubscriberPreferences } from './usecases/update-subscriber-preferences/update-subscriber-preferences.usecase'; @ThrottlerCategory(ApiRateLimitCategoryEnum.CONFIGURATION) @Controller({ path: '/subscribers', version: '2' }) @UseInterceptors(ClassSerializerInterceptor) @ApiTags('Subscribers') @SdkGroupName('Subscribers') @RequireAuthentication() @ApiCommonResponses() export class SubscribersController { constructor( private listSubscribersUsecase: ListSubscribersUseCase, private getSubscriberUsecase: GetSubscriber, private patchSubscriberUsecase: PatchSubscriber, private removeSubscriberUsecase: RemoveSubscriber, private getSubscriberPreferencesUsecase: GetSubscriberPreferences, private updateSubscriberPreferencesUsecase: UpdateSubscriberPreferences, private bulkUpdatePreferencesUsecase: BulkUpdatePreferences, private createOrUpdateSubscriberUsecase: CreateOrUpdateSubscriberUseCase, private listSubscriberSubscriptionsUsecase: ListSubscriberSubscriptionsUseCase, private getSubscriberGlobalPreference: GetSubscriberGlobalPreference, private getNotificationsUsecase: GetNotifications, private notificationsCountUsecase: NotificationsCount, private markNotificationAsUsecase: MarkNotificationAs, private snoozeNotificationUsecase: SnoozeNotification, private unsnoozeNotificationUsecase: UnsnoozeNotification, private deleteNotificationUsecase: DeleteNotification, private updateNotificationActionUsecase: UpdateNotificationAction, private markNotificationsAsSeenUsecase: MarkNotificationsAsSeen, private updateAllNotificationsUsecase: UpdateAllNotifications, private deleteAllNotificationsUsecase: DeleteAllNotifications ) {} @Get('') @ExternalApiAccessible() @SdkMethodName('search') @ApiOperation({ summary: 'Search subscribers', description: `Search subscribers by their **email**, **phone**, **subscriberId** and **name**. The search is case sensitive and supports pagination.Checkout all available filters in the query section.`, }) @ApiResponse(ListSubscribersResponseDto) @RequirePermissions(PermissionsEnum.SUBSCRIBER_READ) async searchSubscribers( @UserSession() user: UserSessionData, @Query() query: ListSubscribersQueryDto ): Promise { return await this.listSubscribersUsecase.execute( ListSubscribersCommand.create({ user, limit: Number(query.limit || '10'), after: query.after, before: query.before, orderDirection: query.orderDirection || DirectionEnum.DESC, orderBy: query.orderBy || '_id', email: query.email, phone: query.phone, subscriberId: query.subscriberId, name: query.name, includeCursor: query.includeCursor, }) ); } @Get('/:subscriberId') @ExternalApiAccessible() @ApiOperation({ summary: 'Retrieve a subscriber', description: `Retrieve a subscriber by its unique key identifier **subscriberId**. **subscriberId** field is required.`, }) @ApiParam({ name: 'subscriberId', description: 'The identifier of the subscriber', type: String }) @ApiResponse(SubscriberResponseDto) @SdkMethodName('retrieve') @RequirePermissions(PermissionsEnum.SUBSCRIBER_READ) async getSubscriber( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string ): Promise { return await this.getSubscriberUsecase.execute( GetSubscriberCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, subscriberId, }) ); } @Post('') @ExternalApiAccessible() @ApiOperation({ summary: 'Create a subscriber', description: `Create a subscriber with the subscriber attributes. **subscriberId** is a required field, rest other fields are optional, if the subscriber already exists, it will be updated`, }) @ApiQuery({ name: 'failIfExists', required: false, type: Boolean, description: 'If true, the request will fail if a subscriber with the same subscriberId already exists', }) @ApiResponse(SubscriberResponseDto, 201) @ApiResponse(SubscriberResponseDto, 409, false, false, { description: 'Subscriber already exists (when query param failIfExists=true)', }) @SdkMethodName('create') @RequirePermissions(PermissionsEnum.SUBSCRIBER_WRITE) async createSubscriber( @UserSession() user: UserSessionData, @Body() body: CreateSubscriberRequestDto, @Query('failIfExists') failIfExists?: boolean ): Promise { const subscriberEntity = await this.createOrUpdateSubscriberUsecase.execute( CreateOrUpdateSubscriberCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, subscriberId: body.subscriberId, email: body.email, firstName: body.firstName, lastName: body.lastName, phone: body.phone, avatar: body.avatar, locale: body.locale, timezone: body.timezone, // TODO: Change shared type to data: (body.data || {}) as SubscriberCustomData, /* * TODO: In Subscriber V2 API endpoint we haven't added channels yet. * channels: body.channels || [], */ failIfExists, }) ); return mapSubscriberEntityToDto(subscriberEntity); } @Patch('/:subscriberId') @ExternalApiAccessible() @ApiOperation({ summary: 'Update a subscriber', description: `Update a subscriber by its unique key identifier **subscriberId**. **subscriberId** is a required field, rest other fields are optional`, }) @ApiParam({ name: 'subscriberId', description: 'The identifier of the subscriber', type: String }) @ApiResponse(SubscriberResponseDto) @SdkMethodName('patch') @RequirePermissions(PermissionsEnum.SUBSCRIBER_WRITE) async patchSubscriber( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string, @Body() body: PatchSubscriberRequestDto ): Promise { return await this.patchSubscriberUsecase.execute( PatchSubscriberCommand.create({ subscriberId, environmentId: user.environmentId, organizationId: user.organizationId, patchSubscriberRequestDto: body, userId: user._id, }) ); } @Delete('/:subscriberId') @ExternalApiAccessible() @ApiOperation({ summary: 'Delete a subscriber', description: `Deletes a subscriber entity from the Novu platform along with associated messages, preferences, and topic subscriptions. **subscriberId** is a required field.`, }) @ApiParam({ name: 'subscriberId', description: 'The identifier of the subscriber', type: String }) @ApiResponse(RemoveSubscriberResponseDto, 200) @SdkMethodName('delete') @RequirePermissions(PermissionsEnum.SUBSCRIBER_WRITE) async removeSubscriber( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string ): Promise { return await this.removeSubscriberUsecase.execute( RemoveSubscriberCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, subscriberId, }) ); } @Get('/:subscriberId/preferences') @ExternalApiAccessible() @ApiOperation({ summary: 'Retrieve subscriber preferences', description: `Retrieve subscriber channel preferences by its unique key identifier **subscriberId**. This API returns all five channels preferences for all workflows and global preferences.`, }) @ApiParam({ name: 'subscriberId', description: 'The identifier of the subscriber', type: String }) @ApiResponse(GetSubscriberPreferencesDto) @SdkGroupName('Subscribers.Preferences') @SdkMethodName('list') @RequirePermissions(PermissionsEnum.SUBSCRIBER_READ) async getSubscriberPreferences( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string, @Query() query: GetSubscriberPreferencesRequestDto ): Promise { return await this.getSubscriberPreferencesUsecase.execute( GetSubscriberPreferencesCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, subscriberId, criticality: query.criticality, contextKeys: query.contextKeys, }) ); } @Get('/:subscriberId/preferences/global') @ExternalApiAccessible() @ApiOperation({ summary: 'Retrieve subscriber global preference', description: `Retrieve subscriber global preference. This API returns all five global channels preferences and subscriber schedule.`, }) @ApiParam({ name: 'subscriberId', description: 'The identifier of the subscriber', type: String }) @ApiResponse(SubscriberGlobalPreferenceDto) @SdkGroupName('Subscribers.Preferences') @RequirePermissions(PermissionsEnum.SUBSCRIBER_READ) @SdkMethodName('globalPreference') @ApiExcludeEndpoint() async getGlobalPreference( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string ): Promise { const globalPreference = await this.getSubscriberGlobalPreference.execute( GetSubscriberGlobalPreferenceCommand.create({ organizationId: user.organizationId, environmentId: user.environmentId, subscriberId: subscriberId, includeInactiveChannels: false, }) ); return globalPreference.preference; } @Patch('/:subscriberId/preferences/bulk') @ExternalApiAccessible() @ApiOperation({ summary: 'Bulk update subscriber preferences', description: `Bulk update subscriber preferences by its unique key identifier **subscriberId**. This API allows updating multiple workflow preferences in a single request.`, }) @ApiParam({ name: 'subscriberId', description: 'The identifier of the subscriber', type: String }) @ApiResponse(GetPreferencesResponseDto, 200, true) @SdkGroupName('Subscribers.Preferences') @SdkMethodName('bulkUpdate') @RequirePermissions(PermissionsEnum.SUBSCRIBER_WRITE) async bulkUpdateSubscriberPreferences( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string, @Body() body: BulkUpdateSubscriberPreferencesDto ): Promise { const preferences = body.preferences.map((preference) => ({ workflowId: preference.workflowId, email: preference.channels?.email, sms: preference.channels?.sms, in_app: preference.channels?.in_app, push: preference.channels?.push, chat: preference.channels?.chat, })); return await this.bulkUpdatePreferencesUsecase.execute( BulkUpdatePreferencesCommand.create({ organizationId: user.organizationId, subscriberId, environmentId: user.environmentId, preferences, context: body.context, }) ); } @Patch('/:subscriberId/preferences') @ExternalApiAccessible() @ApiOperation({ summary: 'Update subscriber preferences', description: `Update subscriber preferences by its unique key identifier **subscriberId**. **workflowId** is optional field, if provided, this API will update that workflow preference, otherwise it will update global preferences`, }) @ApiParam({ name: 'subscriberId', description: 'The identifier of the subscriber', type: String }) @ApiResponse(GetSubscriberPreferencesDto) @SdkGroupName('Subscribers.Preferences') @SdkMethodName('update') @RequirePermissions(PermissionsEnum.SUBSCRIBER_WRITE) async updateSubscriberPreferences( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string, @Body() body: PatchSubscriberPreferencesDto ): Promise { return await this.updateSubscriberPreferencesUsecase.execute( UpdateSubscriberPreferencesCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, subscriberId, workflowIdOrInternalId: body.workflowId, channels: body.channels, schedule: body.schedule, context: body.context, }) ); } @Get('/:subscriberId/subscriptions') @ExternalApiAccessible() @ApiOperation({ summary: 'Retrieve subscriber subscriptions', description: `Retrieve subscriber's topic subscriptions by its unique key identifier **subscriberId**. Checkout all available filters in the query section.`, }) @ApiParam({ name: 'subscriberId', description: 'The identifier of the subscriber', type: String }) @ApiResponse(ListTopicSubscriptionsResponseDto) @SdkGroupName('Subscribers.Topics') @SdkMethodName('list') @RequirePermissions(PermissionsEnum.SUBSCRIBER_READ) async listSubscriberTopics( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string, @Query() query: ListSubscriberSubscriptionsQueryDto ): Promise { return await this.listSubscriberSubscriptionsUsecase.execute( ListSubscriberSubscriptionsCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, subscriberId, topicKey: query.key, contextKeys: query.contextKeys, limit: query.limit ? Number(query.limit) : 10, after: query.after, before: query.before, orderDirection: query.orderDirection === DirectionEnum.ASC ? 1 : -1, orderBy: query.orderBy || '_id', includeCursor: query.includeCursor, }) ); } @Get('/:subscriberId/notifications') @ExternalApiAccessible() @ApiOperation({ summary: 'Retrieve subscriber notifications', description: `Retrieve in-app notifications for a subscriber by its unique key identifier **subscriberId**. Supports filtering by tags, read/archived/snoozed/seen state, data attributes, severity, date range, and context keys.`, }) @ApiParam({ name: 'subscriberId', description: 'The identifier of the subscriber', type: String }) @ApiResponse(GetSubscriberNotificationsResponseDto) @SdkGroupName('Subscribers.Notifications') @SdkMethodName('list') @RequirePermissions(PermissionsEnum.SUBSCRIBER_READ) async getSubscriberNotifications( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string, @Query() query: GetSubscriberNotificationsQueryDto ): Promise { return await this.getNotificationsUsecase.execute( GetNotificationsCommand.create({ organizationId: user.organizationId, subscriberId, environmentId: user.environmentId, contextKeys: query.contextKeys, limit: query.limit, offset: query.offset, after: query.after, tags: query.tags, read: query.read, archived: query.archived, snoozed: query.snoozed, seen: query.seen, data: query.data, severity: query.severity, createdGte: query.createdGte, createdLte: query.createdLte, }) ); } @Get('/:subscriberId/notifications/count') @ExternalApiAccessible() @ApiOperation({ summary: 'Retrieve subscriber notifications count', description: `Retrieve count of notifications for a subscriber by its unique key identifier **subscriberId**. Supports multiple filters to count notifications by different criteria, including context keys.`, }) @ApiParam({ name: 'subscriberId', description: 'The identifier of the subscriber', type: String }) @ApiResponse(GetSubscriberNotificationsCountResponseDto, 200, true) @SdkGroupName('Subscribers.Notifications') @SdkMethodName('count') @RequirePermissions(PermissionsEnum.SUBSCRIBER_READ) async getSubscriberNotificationsCount( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string, @Query() query: GetSubscriberNotificationsCountQueryDto ): Promise<{ data: GetSubscriberNotificationsCountResponseDto[] }> { return await this.notificationsCountUsecase.execute( NotificationsCountCommand.create({ organizationId: user.organizationId, subscriberId, environmentId: user.environmentId, filters: query.filters, }) ); } @Patch('/:subscriberId/notifications/:notificationId/read') @ExternalApiAccessible() @ApiOperation({ summary: 'Mark notification as read', description: 'Mark a specific notification as read by its unique identifier **notificationId**.', }) @ApiParam({ name: 'subscriberId', description: 'The identifier of the subscriber', type: String }) @ApiParam({ name: 'notificationId', description: 'The identifier of the notification', type: String }) @ApiQuery({ name: 'contextKeys', required: false, type: [String], description: 'Context keys for filtering' }) @ApiResponse(InboxNotificationDto, 200, false, false) @SdkGroupName('Subscribers.Notifications') @SdkMethodName('markAsRead') @RequirePermissions(PermissionsEnum.SUBSCRIBER_WRITE) async markNotificationAsRead( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string, @Param('notificationId') notificationId: string, @Query() query: ContextKeysQueryDto ): Promise { return await this.markNotificationAsUsecase.execute( MarkNotificationAsCommand.create({ organizationId: user.organizationId, subscriberId, environmentId: user.environmentId, contextKeys: query.contextKeys, notificationId, read: true, }) ); } @Patch('/:subscriberId/notifications/:notificationId/unread') @ExternalApiAccessible() @ApiOperation({ summary: 'Mark notification as unread', description: 'Mark a specific notification as unread by its unique identifier **notificationId**.', }) @ApiParam({ name: 'subscriberId', description: 'The identifier of the subscriber', type: String }) @ApiParam({ name: 'notificationId', description: 'The identifier of the notification', type: String }) @ApiQuery({ name: 'contextKeys', required: false, type: [String], description: 'Context keys for filtering' }) @ApiResponse(InboxNotificationDto, 200, false, false) @SdkGroupName('Subscribers.Notifications') @SdkMethodName('markAsUnread') @RequirePermissions(PermissionsEnum.SUBSCRIBER_WRITE) async markNotificationAsUnread( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string, @Param('notificationId') notificationId: string, @Query() query: ContextKeysQueryDto ): Promise { return await this.markNotificationAsUsecase.execute( MarkNotificationAsCommand.create({ organizationId: user.organizationId, subscriberId, environmentId: user.environmentId, contextKeys: query.contextKeys, notificationId, read: false, }) ); } @Patch('/:subscriberId/notifications/:notificationId/archive') @ExternalApiAccessible() @ApiOperation({ summary: 'Archive notification', description: 'Archive a specific notification by its unique identifier **notificationId**.', }) @ApiParam({ name: 'subscriberId', description: 'The identifier of the subscriber', type: String }) @ApiParam({ name: 'notificationId', description: 'The identifier of the notification', type: String }) @ApiQuery({ name: 'contextKeys', required: false, type: [String], description: 'Context keys for filtering' }) @ApiResponse(InboxNotificationDto, 200, false, false) @SdkGroupName('Subscribers.Notifications') @SdkMethodName('archive') @RequirePermissions(PermissionsEnum.SUBSCRIBER_WRITE) async archiveNotification( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string, @Param('notificationId') notificationId: string, @Query() query: ContextKeysQueryDto ): Promise { return await this.markNotificationAsUsecase.execute( MarkNotificationAsCommand.create({ organizationId: user.organizationId, subscriberId, environmentId: user.environmentId, contextKeys: query.contextKeys, notificationId, archived: true, }) ); } @Patch('/:subscriberId/notifications/:notificationId/unarchive') @ExternalApiAccessible() @ApiOperation({ summary: 'Unarchive notification', description: 'Unarchive a specific notification by its unique identifier **notificationId**.', }) @ApiParam({ name: 'subscriberId', description: 'The identifier of the subscriber', type: String }) @ApiParam({ name: 'notificationId', description: 'The identifier of the notification', type: String }) @ApiQuery({ name: 'contextKeys', required: false, type: [String], description: 'Context keys for filtering' }) @ApiResponse(InboxNotificationDto, 200, false, false) @SdkGroupName('Subscribers.Notifications') @SdkMethodName('unarchive') @RequirePermissions(PermissionsEnum.SUBSCRIBER_WRITE) async unarchiveNotification( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string, @Param('notificationId') notificationId: string, @Query() query: ContextKeysQueryDto ): Promise { return await this.markNotificationAsUsecase.execute( MarkNotificationAsCommand.create({ organizationId: user.organizationId, subscriberId, environmentId: user.environmentId, contextKeys: query.contextKeys, notificationId, archived: false, }) ); } @Patch('/:subscriberId/notifications/:notificationId/snooze') @ExternalApiAccessible() @ApiOperation({ summary: 'Snooze notification', description: 'Snooze a specific notification by its unique identifier **notificationId** until a specified time.', }) @ApiParam({ name: 'subscriberId', description: 'The identifier of the subscriber', type: String }) @ApiParam({ name: 'notificationId', description: 'The identifier of the notification', type: String }) @ApiQuery({ name: 'contextKeys', required: false, type: [String], description: 'Context keys for filtering' }) @ApiResponse(InboxNotificationDto, 200, false, false) @SdkGroupName('Subscribers.Notifications') @SdkMethodName('snooze') @RequirePermissions(PermissionsEnum.SUBSCRIBER_WRITE) async snoozeNotification( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string, @Param('notificationId') notificationId: string, @Body() body: SnoozeSubscriberNotificationDto, @Query() query: ContextKeysQueryDto ): Promise { return await this.snoozeNotificationUsecase.execute( SnoozeNotificationCommand.create({ organizationId: user.organizationId, subscriberId, environmentId: user.environmentId, contextKeys: query.contextKeys, notificationId, snoozeUntil: body.snoozeUntil, }) ); } @Patch('/:subscriberId/notifications/:notificationId/unsnooze') @ExternalApiAccessible() @ApiOperation({ summary: 'Unsnooze notification', description: 'Unsnooze a specific notification by its unique identifier **notificationId**.', }) @ApiParam({ name: 'subscriberId', description: 'The identifier of the subscriber', type: String }) @ApiParam({ name: 'notificationId', description: 'The identifier of the notification', type: String }) @ApiQuery({ name: 'contextKeys', required: false, type: [String], description: 'Context keys for filtering' }) @ApiResponse(InboxNotificationDto, 200, false, false) @SdkGroupName('Subscribers.Notifications') @SdkMethodName('unsnooze') @RequirePermissions(PermissionsEnum.SUBSCRIBER_WRITE) async unsnoozeNotification( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string, @Param('notificationId') notificationId: string, @Query() query: ContextKeysQueryDto ): Promise { return await this.unsnoozeNotificationUsecase.execute( UnsnoozeNotificationCommand.create({ organizationId: user.organizationId, subscriberId, environmentId: user.environmentId, contextKeys: query.contextKeys, notificationId, }) ); } @Delete('/:subscriberId/notifications/:notificationId') @ExternalApiAccessible() @ApiOperation({ summary: 'Delete notification', description: 'Delete a specific notification by its unique identifier **notificationId**.', }) @ApiParam({ name: 'subscriberId', description: 'The identifier of the subscriber', type: String }) @ApiParam({ name: 'notificationId', description: 'The identifier of the notification', type: String }) @ApiQuery({ name: 'contextKeys', required: false, type: [String], description: 'Context keys for filtering' }) @HttpCode(HttpStatus.NO_CONTENT) @SdkGroupName('Subscribers.Notifications') @SdkMethodName('delete') @RequirePermissions(PermissionsEnum.SUBSCRIBER_WRITE) async deleteNotification( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string, @Param('notificationId') notificationId: string, @Query() query: ContextKeysQueryDto ): Promise { await this.deleteNotificationUsecase.execute( DeleteNotificationCommand.create({ organizationId: user.organizationId, subscriberId, environmentId: user.environmentId, contextKeys: query.contextKeys, notificationId, }) ); } @Patch('/:subscriberId/notifications/:notificationId/actions/:actionType/complete') @ExternalApiAccessible() @ApiOperation({ summary: 'Complete notification action', description: 'Mark a notification action (primary or secondary) as completed by its unique identifier **notificationId** and action type.', }) @ApiParam({ name: 'subscriberId', description: 'The identifier of the subscriber', type: String }) @ApiParam({ name: 'notificationId', description: 'The identifier of the notification', type: String }) @ApiParam({ name: 'actionType', description: 'The type of action (primary or secondary)', enum: ButtonTypeEnum, type: String, }) @ApiQuery({ name: 'contextKeys', required: false, type: [String], description: 'Context keys for filtering' }) @ApiResponse(InboxNotificationDto, 200, false, false) @SdkGroupName('Subscribers.Notifications') @SdkMethodName('completeAction') @RequirePermissions(PermissionsEnum.SUBSCRIBER_WRITE) async completeNotificationAction( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string, @Param('notificationId') notificationId: string, @Param('actionType') actionType: ButtonTypeEnum, @Query() query: ContextKeysQueryDto ): Promise { return await this.updateNotificationActionUsecase.execute( UpdateNotificationActionCommand.create({ organizationId: user.organizationId, subscriberId, environmentId: user.environmentId, contextKeys: query.contextKeys, notificationId, actionType, actionStatus: MessageActionStatusEnum.DONE, }) ); } @Patch('/:subscriberId/notifications/:notificationId/actions/:actionType/revert') @ExternalApiAccessible() @ApiOperation({ summary: 'Revert notification action', description: 'Revert a notification action (primary or secondary) to pending state by its unique identifier **notificationId** and action type.', }) @ApiParam({ name: 'subscriberId', description: 'The identifier of the subscriber', type: String }) @ApiParam({ name: 'notificationId', description: 'The identifier of the notification', type: String }) @ApiParam({ name: 'actionType', description: 'The type of action (primary or secondary)', enum: ButtonTypeEnum, type: String, }) @ApiQuery({ name: 'contextKeys', required: false, type: [String], description: 'Context keys for filtering' }) @ApiResponse(InboxNotificationDto, 200, false, false) @SdkGroupName('Subscribers.Notifications') @SdkMethodName('revertAction') @RequirePermissions(PermissionsEnum.SUBSCRIBER_WRITE) async revertNotificationAction( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string, @Param('notificationId') notificationId: string, @Param('actionType') actionType: ButtonTypeEnum, @Query() query: ContextKeysQueryDto ): Promise { return await this.updateNotificationActionUsecase.execute( UpdateNotificationActionCommand.create({ organizationId: user.organizationId, subscriberId, environmentId: user.environmentId, contextKeys: query.contextKeys, notificationId, actionType, actionStatus: MessageActionStatusEnum.PENDING, }) ); } @Post('/:subscriberId/notifications/seen') @ExternalApiAccessible() @ApiOperation({ summary: 'Mark notifications as seen', description: 'Mark specific notifications or notifications matching filters as seen. Supports context-based filtering.', }) @ApiParam({ name: 'subscriberId', description: 'The identifier of the subscriber', type: String }) @HttpCode(HttpStatus.NO_CONTENT) @SdkGroupName('Subscribers.Notifications') @SdkMethodName('markAsSeen') @RequirePermissions(PermissionsEnum.SUBSCRIBER_WRITE) async markNotificationsAsSeen( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string, @Body() body: MarkSubscriberNotificationsAsSeenDto ): Promise { await this.markNotificationsAsSeenUsecase.execute( MarkNotificationsAsSeenCommand.create({ organizationId: user.organizationId, subscriberId, environmentId: user.environmentId, contextKeys: body.contextKeys, notificationIds: body.notificationIds, tags: body.tags, data: body.data, }) ); } @Post('/:subscriberId/notifications/read') @ExternalApiAccessible() @ApiOperation({ summary: 'Mark all notifications as read', description: 'Mark all notifications matching the specified filters as read. Supports context-based filtering.', }) @ApiParam({ name: 'subscriberId', description: 'The identifier of the subscriber', type: String }) @HttpCode(HttpStatus.NO_CONTENT) @SdkGroupName('Subscribers.Notifications') @SdkMethodName('markAllAsRead') @RequirePermissions(PermissionsEnum.SUBSCRIBER_WRITE) async markAllNotificationsAsRead( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string, @Body() body: UpdateAllSubscriberNotificationsDto ): Promise { await this.updateAllNotificationsUsecase.execute( UpdateAllNotificationsCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, subscriberId, contextKeys: body.contextKeys, from: { tags: body.tags, data: body.data, }, to: { read: true, }, }) ); } @Post('/:subscriberId/notifications/archive') @ExternalApiAccessible() @ApiOperation({ summary: 'Archive all notifications', description: 'Archive all notifications matching the specified filters. Supports context-based filtering.', }) @ApiParam({ name: 'subscriberId', description: 'The identifier of the subscriber', type: String }) @HttpCode(HttpStatus.NO_CONTENT) @SdkGroupName('Subscribers.Notifications') @SdkMethodName('archiveAll') @RequirePermissions(PermissionsEnum.SUBSCRIBER_WRITE) async archiveAllNotifications( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string, @Body() body: UpdateAllSubscriberNotificationsDto ): Promise { await this.updateAllNotificationsUsecase.execute( UpdateAllNotificationsCommand.create({ organizationId: user.organizationId, subscriberId, environmentId: user.environmentId, contextKeys: body.contextKeys, from: { tags: body.tags, data: body.data, }, to: { archived: true, }, }) ); } @Post('/:subscriberId/notifications/read-archive') @ExternalApiAccessible() @ApiOperation({ summary: 'Archive all read notifications', description: 'Archive all read notifications matching the specified filters. Supports context-based filtering.', }) @ApiParam({ name: 'subscriberId', description: 'The identifier of the subscriber', type: String }) @HttpCode(HttpStatus.NO_CONTENT) @SdkGroupName('Subscribers.Notifications') @SdkMethodName('archiveAllRead') @RequirePermissions(PermissionsEnum.SUBSCRIBER_WRITE) async archiveAllReadNotifications( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string, @Body() body: UpdateAllSubscriberNotificationsDto ): Promise { await this.updateAllNotificationsUsecase.execute( UpdateAllNotificationsCommand.create({ organizationId: user.organizationId, subscriberId, environmentId: user.environmentId, contextKeys: body.contextKeys, from: { tags: body.tags, read: true, data: body.data, }, to: { archived: true, }, }) ); } @Post('/:subscriberId/notifications/delete') @ExternalApiAccessible() @ApiOperation({ summary: 'Delete all notifications', description: 'Delete all notifications matching the specified filters. Supports context-based filtering.', }) @ApiParam({ name: 'subscriberId', description: 'The identifier of the subscriber', type: String }) @HttpCode(HttpStatus.NO_CONTENT) @SdkGroupName('Subscribers.Notifications') @SdkMethodName('deleteAll') @RequirePermissions(PermissionsEnum.SUBSCRIBER_WRITE) async deleteAllNotifications( @UserSession() user: UserSessionData, @Param('subscriberId') subscriberId: string, @Body() body: UpdateAllSubscriberNotificationsDto ): Promise { await this.deleteAllNotificationsUsecase.execute( DeleteAllNotificationsCommand.create({ organizationId: user.organizationId, subscriberId, environmentId: user.environmentId, contextKeys: body.contextKeys, filters: { tags: body.tags, data: body.data, }, }) ); } } ================================================ FILE: apps/api/src/app/subscribers-v2/subscribers.module.ts ================================================ import { Module } from '@nestjs/common'; import { analyticsService, CacheInMemoryProviderService, CreateOrUpdateSubscriberUseCase, cacheService, featureFlagsService, GetPreferences, GetSubscriberTemplatePreference, GetWorkflowByIdsUseCase, InMemoryLRUCacheService, InvalidateCacheService, UpdateSubscriber, UpdateSubscriberChannel, UpsertPreferences, } from '@novu/application-generic'; import { CommunityOrganizationRepository, ContextRepository, EnvironmentRepository, IntegrationRepository, MessageRepository, MessageTemplateRepository, NotificationTemplateRepository, PreferencesRepository, SubscriberRepository, TenantRepository, TopicSubscribersRepository, WorkflowOverrideRepository, } from '@novu/dal'; import { InboxModule } from '../inbox/inbox.module'; import { UpdatePreferences } from '../inbox/usecases/update-preferences/update-preferences.usecase'; import { OutboundWebhooksModule } from '../outbound-webhooks/outbound-webhooks.module'; import { GetSubscriberGlobalPreference } from '../subscribers/usecases/get-subscriber-global-preference'; import { GetSubscriberPreference } from '../subscribers/usecases/get-subscriber-preference'; import { TopicsV2Module } from '../topics-v2/topics-v2.module'; import { SubscribersController } from './subscribers.controller'; import { GetSubscriber } from './usecases/get-subscriber/get-subscriber.usecase'; import { GetSubscriberPreferences } from './usecases/get-subscriber-preferences/get-subscriber-preferences.usecase'; import { ListSubscribersUseCase } from './usecases/list-subscribers/list-subscribers.usecase'; import { PatchSubscriber } from './usecases/patch-subscriber/patch-subscriber.usecase'; import { RemoveSubscriber } from './usecases/remove-subscriber/remove-subscriber.usecase'; import { UpdateSubscriberPreferences } from './usecases/update-subscriber-preferences/update-subscriber-preferences.usecase'; const USE_CASES = [ ListSubscribersUseCase, UpdateSubscriber, UpdateSubscriberChannel, IntegrationRepository, CreateOrUpdateSubscriberUseCase, UpdateSubscriber, CacheInMemoryProviderService, GetSubscriber, PatchSubscriber, RemoveSubscriber, GetSubscriberPreferences, GetSubscriberGlobalPreference, GetSubscriberPreference, GetPreferences, UpdateSubscriberPreferences, UpdatePreferences, GetSubscriberTemplatePreference, UpsertPreferences, GetWorkflowByIdsUseCase, ]; const DAL_MODELS = [ SubscriberRepository, NotificationTemplateRepository, PreferencesRepository, TopicSubscribersRepository, MessageTemplateRepository, WorkflowOverrideRepository, TenantRepository, MessageRepository, ContextRepository, ]; @Module({ imports: [TopicsV2Module, InboxModule, OutboundWebhooksModule.forRoot()], controllers: [SubscribersController], providers: [ ...USE_CASES, ...DAL_MODELS, cacheService, InvalidateCacheService, analyticsService, CommunityOrganizationRepository, featureFlagsService, EnvironmentRepository, InMemoryLRUCacheService, ], }) export class SubscribersModule {} ================================================ FILE: apps/api/src/app/subscribers-v2/usecases/get-subscriber/get-subscriber.command.ts ================================================ import { IsDefined, IsString } from 'class-validator'; import { EnvironmentCommand } from '../../../shared/commands/project.command'; export class GetSubscriberCommand extends EnvironmentCommand { @IsString() @IsDefined() subscriberId: string; } ================================================ FILE: apps/api/src/app/subscribers-v2/usecases/get-subscriber/get-subscriber.usecase.ts ================================================ import { Injectable, NotFoundException } from '@nestjs/common'; import { SubscriberResponseDto } from '@novu/application-generic'; import { SubscriberEntity, SubscriberRepository } from '@novu/dal'; import { mapSubscriberEntityToDto } from '../list-subscribers/map-subscriber-entity-to.dto'; import { GetSubscriberCommand } from './get-subscriber.command'; @Injectable() export class GetSubscriber { constructor(private subscriberRepository: SubscriberRepository) {} async execute(command: GetSubscriberCommand): Promise { const subscriber = await this.fetchSubscriber({ _environmentId: command.environmentId, subscriberId: command.subscriberId, _organizationId: command.organizationId, }); if (!subscriber) { throw new NotFoundException(`Subscriber: ${command.subscriberId} was not found`); } return mapSubscriberEntityToDto(subscriber); } private async fetchSubscriber({ subscriberId, _environmentId, _organizationId, }: { subscriberId: string; _environmentId: string; _organizationId: string; }): Promise { return await this.subscriberRepository.findOne({ _environmentId, subscriberId, _organizationId }); } } ================================================ FILE: apps/api/src/app/subscribers-v2/usecases/get-subscriber-preferences/get-subscriber-preferences.command.ts ================================================ import { WorkflowCriticalityEnum } from '@novu/shared'; import { IsEnum, IsOptional } from 'class-validator'; import { EnvironmentWithSubscriber } from '../../../shared/commands/project.command'; export class GetSubscriberPreferencesCommand extends EnvironmentWithSubscriber { @IsEnum(WorkflowCriticalityEnum) @IsOptional() criticality?: WorkflowCriticalityEnum = WorkflowCriticalityEnum.NON_CRITICAL; } ================================================ FILE: apps/api/src/app/subscribers-v2/usecases/get-subscriber-preferences/get-subscriber-preferences.usecase.ts ================================================ import { Injectable, NotFoundException } from '@nestjs/common'; import { buildSlug, InMemoryLRUCacheService, InMemoryLRUCacheStore, Instrument } from '@novu/application-generic'; import { NotificationTemplateEntity, NotificationTemplateRepository, SubscriberEntity, SubscriberRepository, } from '@novu/dal'; import { ISubscriberPreferenceResponse, ShortIsPrefixEnum, WorkflowCriticalityEnum } from '@novu/shared'; import { plainToInstance } from 'class-transformer'; import { GetSubscriberGlobalPreference, GetSubscriberGlobalPreferenceCommand, } from '../../../subscribers/usecases/get-subscriber-global-preference'; import { GetSubscriberPreference, GetSubscriberPreferenceCommand, } from '../../../subscribers/usecases/get-subscriber-preference'; import { GetSubscriberPreferencesDto } from '../../dtos/get-subscriber-preferences.dto'; import { SubscriberGlobalPreferenceDto } from '../../dtos/subscriber-global-preference.dto'; import { SubscriberWorkflowPreferenceDto } from '../../dtos/subscriber-workflow-preference.dto'; import { GetSubscriberPreferencesCommand } from './get-subscriber-preferences.command'; @Injectable() export class GetSubscriberPreferences { constructor( private getSubscriberGlobalPreference: GetSubscriberGlobalPreference, private getSubscriberPreference: GetSubscriberPreference, private subscriberRepository: SubscriberRepository, private notificationTemplateRepository: NotificationTemplateRepository, private inMemoryLRUCacheService: InMemoryLRUCacheService ) {} async execute(command: GetSubscriberPreferencesCommand): Promise { const subscriber = await this.subscriberRepository.findBySubscriberId( command.environmentId, command.subscriberId, true, '_id' ); if (!subscriber) { throw new NotFoundException(`Subscriber with id: ${command.subscriberId} not found`); } const workflowList = await this.getActiveWorkflows({ organizationId: command.organizationId, environmentId: command.environmentId, critical: command.criticality === WorkflowCriticalityEnum.CRITICAL ? true : undefined, }); const globalPreference = await this.fetchGlobalPreference(command, subscriber, workflowList); const workflowPreferences = await this.fetchWorkflowPreferences(command, subscriber, workflowList); return plainToInstance(GetSubscriberPreferencesDto, { global: globalPreference, workflows: workflowPreferences, }); } private async fetchGlobalPreference( command: GetSubscriberPreferencesCommand, subscriber: SubscriberEntity, workflowList: NotificationTemplateEntity[] ): Promise { const { preference } = await this.getSubscriberGlobalPreference.execute( GetSubscriberGlobalPreferenceCommand.create({ organizationId: command.organizationId, environmentId: command.environmentId, subscriberId: command.subscriberId, includeInactiveChannels: false, contextKeys: command.contextKeys, subscriber, workflowList, }) ); return { ...preference, }; } private async fetchWorkflowPreferences( command: GetSubscriberPreferencesCommand, subscriber: SubscriberEntity, workflowList: NotificationTemplateEntity[] ) { const subscriberWorkflowPreferences = await this.getSubscriberPreference.execute( GetSubscriberPreferenceCommand.create({ environmentId: command.environmentId, subscriberId: command.subscriberId, organizationId: command.organizationId, includeInactiveChannels: false, criticality: command.criticality ?? WorkflowCriticalityEnum.NON_CRITICAL, contextKeys: command.contextKeys, subscriber, workflowList, }) ); return subscriberWorkflowPreferences.map(this.mapToWorkflowPreference); } private mapToWorkflowPreference( subscriberWorkflowPreference: ISubscriberPreferenceResponse ): SubscriberWorkflowPreferenceDto { const { preference, template } = subscriberWorkflowPreference; return { enabled: preference.enabled, channels: preference.channels, overrides: preference.overrides, updatedAt: preference.updatedAt, workflow: { slug: buildSlug(template.name, ShortIsPrefixEnum.WORKFLOW, template._id), identifier: template.triggers[0].identifier, name: template.name, updatedAt: template.updatedAt, }, }; } @Instrument() private async getActiveWorkflows({ organizationId, environmentId, critical, }: { organizationId: string; environmentId: string; critical?: boolean; }): Promise { const cacheKey = `${organizationId}:${environmentId}`; const cacheVariant = this.buildCacheVariant(critical); return this.inMemoryLRUCacheService.get( InMemoryLRUCacheStore.ACTIVE_WORKFLOWS, cacheKey, async () => await this.notificationTemplateRepository.filterActive({ organizationId, environmentId, tags: undefined, severity: undefined, critical, }), { organizationId, environmentId, cacheVariant, } ); } private buildCacheVariant(critical?: boolean): string { const filters = { ...(critical !== undefined && { critical }), }; return Object.keys(filters).length > 0 ? JSON.stringify(filters) : 'default'; } } ================================================ FILE: apps/api/src/app/subscribers-v2/usecases/list-subscribers/list-subscribers.command.ts ================================================ import { CursorBasedPaginatedCommand } from '@novu/application-generic'; import { ISubscriber } from '@novu/shared'; import { IsOptional, IsString } from 'class-validator'; export class ListSubscribersCommand extends CursorBasedPaginatedCommand { @IsString() @IsOptional() email?: string; @IsString() @IsOptional() phone?: string; @IsString() @IsOptional() subscriberId?: string; @IsString() @IsOptional() name?: string; } ================================================ FILE: apps/api/src/app/subscribers-v2/usecases/list-subscribers/list-subscribers.usecase.ts ================================================ import { Injectable } from '@nestjs/common'; import { InstrumentUsecase } from '@novu/application-generic'; import { SubscriberRepository } from '@novu/dal'; import { DirectionEnum } from '../../../shared/dtos/base-responses'; import { ListSubscribersResponseDto } from '../../dtos/list-subscribers-response.dto'; import { ListSubscribersCommand } from './list-subscribers.command'; import { mapSubscriberEntityToDto } from './map-subscriber-entity-to.dto'; @Injectable() export class ListSubscribersUseCase { constructor(private subscriberRepository: SubscriberRepository) {} @InstrumentUsecase() async execute(command: ListSubscribersCommand): Promise { const pagination = await this.subscriberRepository.listSubscribers({ after: command.after, before: command.before, limit: command.limit, sortDirection: command.orderDirection || DirectionEnum.DESC, sortBy: command.orderBy, email: command.email, name: command.name, phone: command.phone, subscriberId: command.subscriberId, environmentId: command.user.environmentId, organizationId: command.user.organizationId, includeCursor: command.includeCursor, }); return { data: pagination.subscribers.map((subscriber) => mapSubscriberEntityToDto(subscriber)), next: pagination.next, previous: pagination.previous, totalCount: pagination.totalCount, totalCountCapped: pagination.totalCountCapped, }; } } ================================================ FILE: apps/api/src/app/subscribers-v2/usecases/list-subscribers/map-subscriber-entity-to.dto.ts ================================================ import { SubscriberResponseDto } from '@novu/application-generic'; import { SubscriberEntity } from '@novu/dal'; export function mapSubscriberEntityToDto(subscriber: SubscriberEntity): SubscriberResponseDto { return { _id: subscriber._id, firstName: subscriber.firstName, lastName: subscriber.lastName, email: subscriber.email, phone: subscriber.phone, avatar: subscriber.avatar, subscriberId: subscriber.subscriberId, createdAt: subscriber.createdAt, updatedAt: subscriber.updatedAt, _environmentId: subscriber._environmentId, _organizationId: subscriber._organizationId, deleted: subscriber.deleted, data: subscriber.data, lastOnlineAt: subscriber.lastOnlineAt ?? null, isOnline: subscriber.isOnline ?? null, topics: subscriber.topics, channels: subscriber.channels, locale: subscriber.locale, timezone: subscriber.timezone, }; } ================================================ FILE: apps/api/src/app/subscribers-v2/usecases/patch-subscriber/patch-subscriber.command.ts ================================================ import { Type } from 'class-transformer'; import { IsDefined, IsString, ValidateNested } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; import { PatchSubscriberRequestDto } from '../../dtos/patch-subscriber.dto'; export class PatchSubscriberCommand extends EnvironmentWithUserCommand { @IsString() @IsDefined() subscriberId: string; @ValidateNested() @Type(() => PatchSubscriberRequestDto) patchSubscriberRequestDto: PatchSubscriberRequestDto; } ================================================ FILE: apps/api/src/app/subscribers-v2/usecases/patch-subscriber/patch-subscriber.usecase.ts ================================================ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { FeatureFlagsService, PinoLogger, SubscriberResponseDto, UpdateSubscriber, UpdateSubscriberCommand, } from '@novu/application-generic'; import { CommunityOrganizationRepository, EnvironmentEntity, EnvironmentRepository, OrganizationEntity, SubscriberRepository, UserEntity, } from '@novu/dal'; import { FeatureFlagsKeysEnum } from '@novu/shared'; import { subscriberIdSchema } from '../../../events/utils/trigger-recipient-validation'; import { mapSubscriberEntityToDto } from '../list-subscribers/map-subscriber-entity-to.dto'; import { PatchSubscriberCommand } from './patch-subscriber.command'; @Injectable() export class PatchSubscriber { constructor( private updateSubscriberUseCase: UpdateSubscriber, private subscriberRepository: SubscriberRepository, private featureFlagService: FeatureFlagsService, private environmentRepository: EnvironmentRepository, private communityOrganizationRepository: CommunityOrganizationRepository, private logger: PinoLogger ) { this.logger.setContext(this.constructor.name); } async execute(command: PatchSubscriberCommand): Promise { const dto = command.patchSubscriberRequestDto; const [environment, organization, existingSubscriber] = await Promise.all([ this.environmentRepository.findOne({ _id: command.environmentId }, '_id', { readPreference: 'secondaryPreferred', }), this.communityOrganizationRepository.findOne({ _id: command.organizationId }, '_id', { readPreference: 'secondaryPreferred', }), this.subscriberRepository.findOne({ _environmentId: command.environmentId, subscriberId: command.subscriberId, }), ]); if (!organization) { throw new BadRequestException(`Organization ${command.organizationId} was not found`); } if (!environment) { throw new BadRequestException(`Environment ${command.environmentId} was not found`); } if (!existingSubscriber) { throw new NotFoundException(`Subscriber ${command.subscriberId} was not found`); } await this.validateItem({ itemId: command.subscriberId, environment, organization, userId: command.userId, }); const updatedSubscriber = await this.updateSubscriberUseCase.execute( UpdateSubscriberCommand.create({ environmentId: command.environmentId, organizationId: command.organizationId, subscriberId: command.subscriberId, firstName: dto.firstName, lastName: dto.lastName, email: dto.email, phone: dto.phone, avatar: dto.avatar, locale: dto.locale, timezone: dto.timezone, data: dto.data, subscriber: existingSubscriber, }) ); return mapSubscriberEntityToDto(updatedSubscriber); } private async validateItem({ itemId, userId, environment, organization, }: { itemId: string; environment?: Pick; organization?: Pick; userId: string; }) { const isDryRun = await this.featureFlagService.getFlag({ environment, organization, user: { _id: userId } as UserEntity, key: FeatureFlagsKeysEnum.IS_SUBSCRIBER_ID_VALIDATION_DRY_RUN_ENABLED, defaultValue: true, }); const result = subscriberIdSchema.safeParse(itemId); if (result.success) { return; } if (isDryRun) { this.logger.warn(`[Dry run] Invalid recipients: ${itemId}`); } else { throw new BadRequestException( `Invalid subscriberId: ${itemId}, only alphanumeric characters, -, _, and . or valid email addresses are allowed` ); } } } ================================================ FILE: apps/api/src/app/subscribers-v2/usecases/remove-subscriber/remove-subscriber.command.ts ================================================ import { IsString } from 'class-validator'; import { EnvironmentCommand } from '../../../shared/commands/project.command'; export class RemoveSubscriberCommand extends EnvironmentCommand { @IsString() subscriberId: string; } ================================================ FILE: apps/api/src/app/subscribers-v2/usecases/remove-subscriber/remove-subscriber.usecase.ts ================================================ import { Injectable, NotFoundException } from '@nestjs/common'; import { buildFeedKey, buildMessageCountKey, buildSubscriberKey, InvalidateCacheService, } from '@novu/application-generic'; import { MessageRepository, PreferencesRepository, SubscriberRepository, TopicSubscribersRepository } from '@novu/dal'; import { RemoveSubscriberCommand } from './remove-subscriber.command'; @Injectable() export class RemoveSubscriber { constructor( private invalidateCache: InvalidateCacheService, private subscriberRepository: SubscriberRepository, private topicSubscribersRepository: TopicSubscribersRepository, private preferenceRepository: PreferencesRepository, private messageRepository: MessageRepository ) {} async execute({ environmentId: _environmentId, subscriberId }: RemoveSubscriberCommand) { await Promise.all([ this.invalidateCache.invalidateByKey({ key: buildSubscriberKey({ subscriberId, _environmentId, }), }), this.invalidateCache.invalidateQuery({ key: buildMessageCountKey().invalidate({ subscriberId, _environmentId, }), }), ]); const subscriberInternalIds = await this.subscriberRepository._model.distinct('_id', { subscriberId, _environmentId, }); if (subscriberInternalIds.length === 0) { throw new NotFoundException({ message: 'Subscriber was not found', externalSubscriberId: subscriberId }); } await this.subscriberRepository.withTransaction(async () => { /* * Note about parallelism in transactions * * Running operations in parallel is not supported during a transaction. * The use of Promise.all, Promise.allSettled, Promise.race, etc. to parallelize operations * inside a transaction is undefined behaviour and should be avoided. * * Refer to https://mongoosejs.com/docs/transactions.html#note-about-parallelism-in-transactions */ await this.subscriberRepository.delete({ subscriberId, _environmentId, }); await this.topicSubscribersRepository.delete({ _environmentId, externalSubscriberId: subscriberId, }); await this.preferenceRepository.delete({ _environmentId, _subscriberId: { $in: subscriberInternalIds }, }); await this.messageRepository.delete({ _subscriberId: { $in: subscriberInternalIds }, _environmentId, }); }); return { acknowledged: true, status: 'deleted', }; } } ================================================ FILE: apps/api/src/app/subscribers-v2/usecases/update-subscriber-preferences/update-subscriber-preferences.command.ts ================================================ import { IsValidContextPayload } from '@novu/application-generic'; import { ContextPayload } from '@novu/shared'; import { Type } from 'class-transformer'; import { IsOptional, IsString } from 'class-validator'; import { EnvironmentWithSubscriber } from '../../../shared/commands/project.command'; import { ScheduleDto } from '../../../shared/dtos/schedule'; import { PatchPreferenceChannelsDto } from '../../dtos/patch-subscriber-preferences.dto'; export class UpdateSubscriberPreferencesCommand extends EnvironmentWithSubscriber { @IsOptional() @IsString() readonly workflowIdOrInternalId?: string; @IsOptional() @Type(() => PatchPreferenceChannelsDto) readonly channels?: PatchPreferenceChannelsDto; @IsOptional() @Type(() => ScheduleDto) readonly schedule?: ScheduleDto; @IsOptional() @IsValidContextPayload({ maxCount: 5 }) readonly context?: ContextPayload; } ================================================ FILE: apps/api/src/app/subscribers-v2/usecases/update-subscriber-preferences/update-subscriber-preferences.usecase.ts ================================================ import { Injectable } from '@nestjs/common'; import { FeatureFlagsService, GetWorkflowByIdsCommand, GetWorkflowByIdsUseCase } from '@novu/application-generic'; import { ContextRepository } from '@novu/dal'; import { ContextPayload, FeatureFlagsKeysEnum, PreferenceLevelEnum, WorkflowCriticalityEnum } from '@novu/shared'; import { plainToInstance } from 'class-transformer'; import { UpdatePreferencesCommand } from '../../../inbox/usecases/update-preferences/update-preferences.command'; import { UpdatePreferences } from '../../../inbox/usecases/update-preferences/update-preferences.usecase'; import { GetSubscriberPreferencesDto } from '../../dtos/get-subscriber-preferences.dto'; import { GetSubscriberPreferences } from '../get-subscriber-preferences/get-subscriber-preferences.usecase'; import { UpdateSubscriberPreferencesCommand } from './update-subscriber-preferences.command'; @Injectable() export class UpdateSubscriberPreferences { constructor( private updatePreferencesUsecase: UpdatePreferences, private getSubscriberPreferences: GetSubscriberPreferences, private getWorkflowByIdsUseCase: GetWorkflowByIdsUseCase, private contextRepository: ContextRepository, private featureFlagsService: FeatureFlagsService ) {} async execute(command: UpdateSubscriberPreferencesCommand): Promise { const contextKeys = await this.resolveContexts(command.environmentId, command.organizationId, command.context); let workflowId: string | undefined; if (command.workflowIdOrInternalId) { const workflowEntity = await this.getWorkflowByIdsUseCase.execute( GetWorkflowByIdsCommand.create({ environmentId: command.environmentId, organizationId: command.organizationId, workflowIdOrInternalId: command.workflowIdOrInternalId, }) ); workflowId = workflowEntity._id; } await this.updatePreferencesUsecase.execute( UpdatePreferencesCommand.create({ organizationId: command.organizationId, environmentId: command.environmentId, subscriberId: command.subscriberId, level: command.workflowIdOrInternalId ? PreferenceLevelEnum.TEMPLATE : PreferenceLevelEnum.GLOBAL, workflowIdOrIdentifier: workflowId, includeInactiveChannels: false, ...command.channels, schedule: command.schedule, contextKeys, }) ); const subscriberPreferences = await this.getSubscriberPreferences.execute({ environmentId: command.environmentId, organizationId: command.organizationId, subscriberId: command.subscriberId, criticality: WorkflowCriticalityEnum.NON_CRITICAL, contextKeys, }); return plainToInstance(GetSubscriberPreferencesDto, { global: subscriberPreferences.global, workflows: subscriberPreferences.workflows, }); } private async resolveContexts( environmentId: string, organizationId: string, context?: ContextPayload ): Promise { // Check if context preferences feature is enabled const isEnabled = await this.featureFlagsService.getFlag({ key: FeatureFlagsKeysEnum.IS_CONTEXT_PREFERENCES_ENABLED, defaultValue: false, organization: { _id: organizationId }, }); if (!isEnabled) { return undefined; // Ignore context when FF is off } if (!context) { return []; } const contexts = await this.contextRepository.findOrCreateContextsFromPayload( environmentId, organizationId, context ); return contexts.map((ctx) => ctx.key); } } ================================================ FILE: apps/api/src/app/subscriptions/subscriptions.module.ts ================================================ import { Module } from '@nestjs/common'; import { GetPreferences } from '@novu/application-generic'; import { ContextRepository } from '@novu/dal'; import { SharedModule } from '../shared/shared.module'; import { USE_CASES } from './usecases'; @Module({ imports: [SharedModule], providers: [...USE_CASES, GetPreferences, ContextRepository], exports: [...USE_CASES], }) export class SubscriptionsModule {} ================================================ FILE: apps/api/src/app/subscriptions/usecases/create-subscription-preferences/create-subscription-preferences.command.ts ================================================ import { NotificationTemplateEntity } from '@novu/dal'; import { Type } from 'class-transformer'; import { IsArray, IsDefined, IsOptional, IsString, ValidateNested } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; import { GroupPreferenceFilterDto } from '../../../shared/dtos/subscriptions/create-subscriptions.dto'; export class CreateSubscriptionPreferencesCommand extends EnvironmentWithUserCommand { @IsArray() @IsDefined() @ValidateNested({ each: true }) @Type(() => GroupPreferenceFilterDto) preferences: GroupPreferenceFilterDto[]; @IsDefined() @IsString() _topicSubscriptionId: string; @IsOptional() @IsString() subscriptionId?: string; @IsDefined() @IsString() _subscriberId: string; @IsDefined() @IsString() topicKey: string; @IsDefined() @IsString() externalSubscriberId: string; @IsArray() @IsDefined() @ValidateNested({ each: true }) @Type(() => NotificationTemplateEntity) workflows: NotificationTemplateEntity[]; @IsArray() @IsString({ each: true }) @IsOptional() contextKeys?: string[]; } ================================================ FILE: apps/api/src/app/subscriptions/usecases/create-subscription-preferences/create-subscription-preferences.usecase.ts ================================================ import { Injectable } from '@nestjs/common'; import { buildDefaultSubscriptionIdentifier, GetPreferences, GetPreferencesCommand, InstrumentUsecase, PinoLogger, } from '@novu/application-generic'; import { ErrorCodesEnum, NotificationTemplateEntity, PreferencesRepository, TopicSubscribersEntity } from '@novu/dal'; import { buildWorkflowPreferences, PreferencesTypeEnum, SeverityLevelEnum, WorkflowPreferences, WorkflowPreferencesPartial, } from '@novu/shared'; import { RulesLogic } from 'json-logic-js'; import { SubscriptionPreferenceDto } from '../../../shared/dtos/subscriptions/create-subscriptions-response.dto'; import { CreateSubscriptionPreferencesCommand } from './create-subscription-preferences.command'; type CreateSubscriptionPreferencesBatchCommand = Omit< CreateSubscriptionPreferencesCommand, 'subscriptionId' | '_subscriberId' | 'topicKey' | 'externalSubscriberId' | '_topicSubscriptionId' >; @Injectable() export class CreateSubscriptionPreferencesUsecase { constructor( private preferencesRepository: PreferencesRepository, private getPreferences: GetPreferences, private logger: PinoLogger ) { this.logger.setContext(this.constructor.name); } @InstrumentUsecase() async execute(command: CreateSubscriptionPreferencesCommand): Promise { if (!command.preferences.length || !command.workflows.length) { return undefined; } const preferencesResult: SubscriptionPreferenceDto[] = []; for (const workflow of command.workflows) { const workflowPreferences = await this.getWorkflowPreferences(command, workflow); if (!workflowPreferences) { continue; } let createdPreference; try { createdPreference = await this.preferencesRepository.create({ _environmentId: command.environmentId, _organizationId: command.organizationId, _subscriberId: command._subscriberId, _templateId: workflow._id, _topicSubscriptionId: command._topicSubscriptionId, type: PreferencesTypeEnum.SUBSCRIPTION_SUBSCRIBER_WORKFLOW, preferences: workflowPreferences, contextKeys: command.contextKeys, }); } catch (error) { const isDuplicateKeyError = error && typeof error === 'object' && 'code' in error && error.code === ErrorCodesEnum.DUPLICATE_KEY; if (isDuplicateKeyError) { createdPreference = await this.preferencesRepository.findOne({ _environmentId: command.environmentId, _subscriberId: command._subscriberId, _templateId: workflow._id, _topicSubscriptionId: command._topicSubscriptionId, type: PreferencesTypeEnum.SUBSCRIPTION_SUBSCRIBER_WORKFLOW, }); } if (!isDuplicateKeyError || !createdPreference) { throw error; } } if (createdPreference) { preferencesResult.push({ workflow: { id: workflow._id, identifier: workflow.triggers?.[0]?.identifier || '', name: workflow.name || '', critical: workflow.critical || false, tags: workflow.tags, data: workflow.data, severity: workflow.severity || SeverityLevelEnum.NONE, }, subscriptionId: command.subscriptionId || buildDefaultSubscriptionIdentifier(command.topicKey, command.externalSubscriberId, command.contextKeys), enabled: createdPreference.preferences?.all?.enabled ?? true, condition: createdPreference.preferences?.all?.condition as RulesLogic | undefined, }); } } return preferencesResult.length > 0 ? preferencesResult : undefined; } @InstrumentUsecase() async executeBatch( command: CreateSubscriptionPreferencesBatchCommand, subscriptions: TopicSubscribersEntity[] = [] ): Promise> { if (!command.preferences.length || !command.workflows.length || subscriptions.length === 0) { return []; } const preferencesToCreate = await this.buildPreferencesToCreate(command, subscriptions); if (preferencesToCreate.length === 0) { return []; } await this.preferencesRepository.insertMany( preferencesToCreate.map(({ subscriptionId, workflow, ...pref }) => pref), false ); const resultMap = new Map(); for (const prefData of preferencesToCreate) { const subscriptionId = prefData.subscriptionId; if (!resultMap.has(subscriptionId)) { resultMap.set(subscriptionId, []); } const workflow = prefData.workflow; const preferences = resultMap.get(subscriptionId); if (preferences) { preferences.push({ workflow: { id: workflow._id, identifier: workflow.triggers?.[0]?.identifier || '', name: workflow.name || '', critical: workflow.critical || false, tags: workflow.tags, data: workflow.data, severity: workflow.severity || SeverityLevelEnum.NONE, }, subscriptionId, enabled: prefData.preferences?.all?.enabled ?? true, condition: prefData.preferences?.all?.condition as RulesLogic | undefined, }); } } return Array.from(resultMap.entries()).map(([subscriptionId, preferences]) => ({ subscriptionId, preferences, })); } private async buildPreferencesToCreate( command: CreateSubscriptionPreferencesBatchCommand, subscriptions: TopicSubscribersEntity[] = [] ): Promise< Array<{ _environmentId: string; _organizationId: string; _subscriberId: string; _templateId: string; _topicSubscriptionId: string; type: PreferencesTypeEnum; preferences: WorkflowPreferences; contextKeys?: string[]; subscriptionId: string; workflow: NotificationTemplateEntity; }> > { const preferencesToCreate: Array<{ _environmentId: string; _organizationId: string; _subscriberId: string; _templateId: string; _topicSubscriptionId: string; type: PreferencesTypeEnum; preferences: WorkflowPreferences; contextKeys?: string[]; subscriptionId: string; workflow: NotificationTemplateEntity; }> = []; for (const subscription of subscriptions) { for (const workflow of command.workflows) { const workflowPreferences = await this.getWorkflowPreferencesForBatch( command, workflow, subscription._subscriberId.toString() ); if (workflowPreferences) { preferencesToCreate.push({ _environmentId: command.environmentId, _organizationId: command.organizationId, _subscriberId: subscription._subscriberId.toString(), _templateId: workflow._id, _topicSubscriptionId: subscription._id.toString(), type: PreferencesTypeEnum.SUBSCRIPTION_SUBSCRIBER_WORKFLOW, preferences: workflowPreferences, contextKeys: command.contextKeys, subscriptionId: subscription.identifier || buildDefaultSubscriptionIdentifier( subscription.topicKey, subscription.externalSubscriberId, subscription.contextKeys ), workflow, }); } } } return preferencesToCreate; } private async getWorkflowPreferencesForBatch( command: CreateSubscriptionPreferencesBatchCommand, workflow: NotificationTemplateEntity, _subscriberId: string ): Promise { const preferenceFilterDefinition = this.findPreferenceFilterDefinition(command, workflow); let enabled: boolean | undefined; if (preferenceFilterDefinition?.enabled !== undefined) { enabled = preferenceFilterDefinition.enabled; } else { const getPreferencesResult = await this.getPreferences.safeExecute( GetPreferencesCommand.create({ environmentId: command.environmentId, organizationId: command.organizationId, templateId: workflow._id, subscriberId: _subscriberId, excludeSubscriberPreferences: true, }) ); enabled = getPreferencesResult?.preferences.all?.enabled; } const partialPreferences: WorkflowPreferencesPartial = { all: { enabled, readOnly: false, ...(preferenceFilterDefinition?.condition !== undefined && { condition: preferenceFilterDefinition.condition }), }, }; return buildWorkflowPreferences(partialPreferences); } private async getWorkflowPreferences( command: CreateSubscriptionPreferencesCommand, workflow: { _id: string; tags?: string[]; triggers?: Array<{ identifier?: string }> } ): Promise { const preferenceFilterDefinition = this.findPreferenceFilterDefinition(command, workflow); let enabled: boolean | undefined; if (preferenceFilterDefinition?.enabled !== undefined) { enabled = preferenceFilterDefinition.enabled; } else { const getPreferencesResult = await this.getPreferences.safeExecute( GetPreferencesCommand.create({ environmentId: command.environmentId, organizationId: command.organizationId, templateId: workflow._id, subscriberId: command._subscriberId, excludeSubscriberPreferences: true, }) ); enabled = getPreferencesResult?.preferences.all?.enabled; } const partialPreferences: WorkflowPreferencesPartial = { all: { enabled, readOnly: false, ...(preferenceFilterDefinition?.condition !== undefined && { condition: preferenceFilterDefinition.condition }), }, }; return buildWorkflowPreferences(partialPreferences); } private findPreferenceFilterDefinition( command: CreateSubscriptionPreferencesCommand | CreateSubscriptionPreferencesBatchCommand, workflow: { _id: string; tags?: string[]; triggers?: Array<{ identifier?: string }> } ) { return command.preferences.find((pref) => { if (pref.filter.tags && pref.filter.tags.length > 0) { return workflow.tags && pref.filter.tags.some((tag) => workflow.tags?.includes(tag)); } if (pref.filter.workflowIds && pref.filter.workflowIds.length > 0) { return pref.filter.workflowIds.some((id) => { const workflowIdentifier = workflow.triggers?.[0]?.identifier; return id === workflow._id || id === workflowIdentifier; }); } return false; }); } } ================================================ FILE: apps/api/src/app/subscriptions/usecases/create-subscription-preferences/index.ts ================================================ export * from './create-subscription-preferences.command'; export * from './create-subscription-preferences.usecase'; ================================================ FILE: apps/api/src/app/subscriptions/usecases/create-subscriptions/create-subscriptions.command.ts ================================================ import { IsValidContextPayload } from '@novu/application-generic'; import { ContextPayload } from '@novu/shared'; import { Type } from 'class-transformer'; import { ArrayMaxSize, ArrayMinSize, IsArray, IsDefined, IsOptional, IsString, ValidateNested } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; import { GroupPreferenceFilterDto } from '../../../shared/dtos/subscriptions/create-subscriptions.dto'; export class TopicSubscriberIdentifier { @IsString() @IsOptional() identifier?: string; @IsString() @IsDefined() subscriberId: string; @IsString() @IsOptional() name?: string; } export class CreateSubscriptionsCommand extends EnvironmentWithUserCommand { @IsString() @IsDefined() topicKey: string; @IsArray() @IsDefined() @ArrayMinSize(1, { message: 'At least one subscription is required' }) @ArrayMaxSize(100, { message: 'Cannot subscribe more than 100 subscriptions at once' }) @ValidateNested({ each: true }) @Type(() => TopicSubscriberIdentifier) subscriptions: TopicSubscriberIdentifier[]; @IsString() @IsOptional() name?: string; @IsArray() @IsOptional() preferences?: Array; @IsValidContextPayload({ maxCount: 5 }) @IsOptional() context?: ContextPayload; @IsArray() @IsString({ each: true }) @IsOptional() contextKeys?: string[]; } ================================================ FILE: apps/api/src/app/subscriptions/usecases/create-subscriptions/create-subscriptions.usecase.ts ================================================ import { BadRequestException, Injectable } from '@nestjs/common'; import { buildDefaultSubscriptionIdentifier, FeatureFlagsService, InstrumentUsecase, PinoLogger, } from '@novu/application-generic'; import { BaseRepository, ContextRepository, CreateTopicSubscribersEntity, ErrorCodesEnum, NotificationTemplateEntity, NotificationTemplateRepository, PreferencesRepository, SubscriberEntity, SubscriberRepository, TopicEntity, TopicRepository, TopicSubscribersEntity, TopicSubscribersRepository, } from '@novu/dal'; import { ContextPayload, FeatureFlagsKeysEnum, PreferencesTypeEnum, SeverityLevelEnum, VALID_ID_REGEX, } from '@novu/shared'; import { RulesLogic } from 'json-logic-js'; import _ from 'lodash'; import { GroupPreferenceFilterDto } from '../../../shared/dtos/subscriptions/create-subscriptions.dto'; import { CreateSubscriptionsResponseDto, SubscriptionErrorDto, SubscriptionPreferenceDto, SubscriptionResponseDto, } from '../../../shared/dtos/subscriptions/create-subscriptions-response.dto'; import { CreateSubscriptionPreferencesUsecase } from '../create-subscription-preferences/create-subscription-preferences.usecase'; import { CreateSubscriptionsCommand } from './create-subscriptions.command'; @Injectable() export class CreateSubscriptionsUsecase { constructor( private topicRepository: TopicRepository, private topicSubscribersRepository: TopicSubscribersRepository, private subscriberRepository: SubscriberRepository, private preferencesRepository: PreferencesRepository, private notificationTemplateRepository: NotificationTemplateRepository, private createSubscriptionPreferencesUsecase: CreateSubscriptionPreferencesUsecase, private contextRepository: ContextRepository, private featureFlagsService: FeatureFlagsService, private logger: PinoLogger ) { this.logger.setContext(this.constructor.name); } @InstrumentUsecase() async execute(command: CreateSubscriptionsCommand): Promise { const useContextFiltering = await this.featureFlagsService.getFlag({ key: FeatureFlagsKeysEnum.IS_CONTEXT_PREFERENCES_ENABLED, defaultValue: false, organization: { _id: command.organizationId }, }); const contextKeys = useContextFiltering ? (command.contextKeys ?? (await this.resolveContexts(command.environmentId, command.organizationId, command.context))) : undefined; // FF OFF: always ignore context const workflows = await this.validateAndFetchWorkflows( command.preferences, command.environmentId, command.organizationId ); const topic = await this.upsertTopic(command); const errors: SubscriptionErrorDto[] = []; const subscriptionData: SubscriptionResponseDto[] = []; const externalSubscriberIds = command.subscriptions.map((subscription) => subscription.subscriberId); const foundSubscribers = await this.subscriberRepository.searchByExternalSubscriberIds({ _environmentId: command.environmentId, _organizationId: command.organizationId, externalSubscriberIds, }); const foundSubscriberIds = foundSubscribers.map((sub) => sub.subscriberId); const notFoundSubscriberIds = externalSubscriberIds.filter((id) => !foundSubscriberIds.includes(id)); for (const subscriberId of notFoundSubscriberIds) { errors.push({ subscriberId, code: 'SUBSCRIBER_NOT_FOUND', message: `Subscriber with ID '${subscriberId}' could not be found.`, }); } if (foundSubscribers.length === 0) { return { data: [], meta: { totalCount: command.subscriptions.length, successful: 0, failed: command.subscriptions.length, }, errors, }; } const subscribersToFind = foundSubscribers.map((sub) => ({ _subscriberId: sub._id.toString(), identifier: command.subscriptions.find((s) => s.subscriberId === sub.subscriberId)?.identifier || buildDefaultSubscriptionIdentifier(command.topicKey, sub.subscriberId, contextKeys), })); const contextQuery = this.topicSubscribersRepository.buildContextExactMatchQuery(contextKeys, { enabled: useContextFiltering, }); const existingSubscriptions = await this.topicSubscribersRepository.find({ _environmentId: command.environmentId, _organizationId: command.organizationId, _topicId: topic._id, identifier: { $in: subscribersToFind.map((sub) => sub.identifier) }, ...contextQuery, }); const existingSubscriberIds = existingSubscriptions.map((sub) => sub._subscriberId.toString()); let subscribersToCreate = foundSubscribers.filter((sub) => !existingSubscriberIds.includes(sub._id.toString())); if (subscribersToCreate.length > 0) { const { validSubscribers: validSubscribersToCreate, limitErrors: limitErrorsToCreate } = await this.validateSubscriptionLimit(topic, subscribersToCreate, command.environmentId, command.organizationId); errors.push(...limitErrorsToCreate); subscribersToCreate = validSubscribersToCreate; } for (const subscription of existingSubscriptions) { const subscriber = foundSubscribers.find((sub) => sub._id.toString() === subscription._subscriberId.toString()); const preferences = await this.fetchPreferencesForSubscription( command, subscription, workflows, useContextFiltering ); subscriptionData.push({ _id: subscription._id.toString(), identifier: subscription.identifier, name: subscription.name, topic: { _id: topic._id, key: topic.key, name: topic.name, }, subscriber: subscriber ? { _id: subscriber._id, subscriberId: subscriber.subscriberId, avatar: subscriber.avatar, firstName: subscriber.firstName, lastName: subscriber.lastName, email: subscriber.email, createdAt: subscriber.createdAt, updatedAt: subscriber.updatedAt, } : null, preferences, contextKeys: subscription.contextKeys, createdAt: subscription.createdAt ?? '', updatedAt: subscription.updatedAt ?? '', }); } if (subscribersToCreate.length > 0) { const subscriptionsToCreate = this.buildSubscriptionEntity( topic, subscribersToCreate, command.subscriptions, contextKeys ); const newSubscriptions = await this.topicSubscribersRepository.createSubscriptions(subscriptionsToCreate); if (newSubscriptions.failed && newSubscriptions.failed.length > 0) { errors.push( ...newSubscriptions.failed.map((failure) => ({ subscriberId: failure.subscriberId, code: 'SUBSCRIPTION_CREATE_FAILED', message: failure.message, })) ); } const BATCH_SIZE = 50; const subscriptionBatches: TopicSubscribersEntity[][] = _.chunk(newSubscriptions.created, BATCH_SIZE); const preferencesArray: Array<{ subscriptionId: string; preferences: SubscriptionPreferenceDto[] }> = []; for (const batch of subscriptionBatches) { const batchPreferencesArray = await this.createPreferencesForSubscriptionsBatch( command, batch, workflows, contextKeys ); preferencesArray.push(...batchPreferencesArray); } for (const subscription of newSubscriptions.created) { const subscriber = foundSubscribers.find((sub) => sub._id.toString() === subscription._subscriberId.toString()); const preferencesEntry = preferencesArray.find((entry) => entry.subscriptionId === subscription.identifier); const preferences = preferencesEntry?.preferences; subscriptionData.push({ _id: subscription._id.toString(), identifier: subscription.identifier, name: subscription.name, topic: { _id: topic._id, key: topic.key, name: topic.name, }, subscriber: subscriber ? { _id: subscriber._id, subscriberId: subscriber.subscriberId, avatar: subscriber.avatar, firstName: subscriber.firstName, lastName: subscriber.lastName, email: subscriber.email, createdAt: subscriber.createdAt, updatedAt: subscriber.updatedAt, } : null, preferences, contextKeys: subscription.contextKeys, createdAt: subscription.createdAt ?? '', updatedAt: subscription.updatedAt ?? '', }); } for (const subscription of newSubscriptions.updated) { const subscriber = foundSubscribers.find((sub) => sub._id.toString() === subscription._subscriberId.toString()); const preferences = await this.fetchPreferencesForSubscription( command, subscription, workflows, useContextFiltering ); subscriptionData.push({ _id: subscription._id.toString(), identifier: subscription.identifier, name: subscription.name, topic: { _id: topic._id, key: topic.key, name: topic.name, }, subscriber: subscriber ? { _id: subscriber._id, subscriberId: subscriber.subscriberId, avatar: subscriber.avatar, firstName: subscriber.firstName, lastName: subscriber.lastName, email: subscriber.email, createdAt: subscriber.createdAt, updatedAt: subscriber.updatedAt, } : null, preferences, contextKeys: subscription.contextKeys, createdAt: subscription.createdAt ?? '', updatedAt: subscription.updatedAt ?? '', }); } } return { data: subscriptionData, meta: { totalCount: command.subscriptions.length, successful: subscriptionData.length, failed: errors.length, }, errors: errors.length > 0 ? errors : undefined, }; } private async upsertTopic(command: CreateSubscriptionsCommand): Promise { let topic = await this.topicRepository.findTopicByKey( command.topicKey, command.organizationId, command.environmentId ); if (!topic) { this.validateTopicKey(command.topicKey); try { topic = await this.topicRepository.createTopic({ _environmentId: command.environmentId, _organizationId: command.organizationId, key: command.topicKey, name: command.name, }); } catch (error: unknown) { if (this.isDuplicateKeyError(error)) { topic = await this.topicRepository.findTopicByKey( command.topicKey, command.organizationId, command.environmentId ); } else { throw error; } } } else if (command.name) { topic = await this.topicRepository.findOneAndUpdate( { _id: topic._id, _environmentId: command.environmentId, _organizationId: command.organizationId, }, { $set: { name: command.name }, } ); } if (!topic) { throw new Error(`Topic with key ${command.topicKey} not found after upsert`); } return topic; } private validateTopicKey(key: string): void { if (VALID_ID_REGEX.test(key)) { return; } throw new BadRequestException( `Invalid topic key: "${key}". Topic keys must contain only alphanumeric characters (a-z, A-Z, 0-9), hyphens (-), underscores (_), colons (:), or be a valid email address.` ); } private isDuplicateKeyError(error: unknown): boolean { return ( typeof error === 'object' && error !== null && 'code' in error && (error as { code: number }).code === ErrorCodesEnum.DUPLICATE_KEY ); } private async validateSubscriptionLimit( topic: TopicEntity, subscribers: SubscriberEntity[], environmentId: string, organizationId: string ): Promise<{ validSubscribers: SubscriberEntity[]; limitErrors: SubscriptionErrorDto[]; }> { const MAX_SUBSCRIPTIONS_PER_SUBSCRIBER = 10; const BATCH_SIZE = 100; if (subscribers.length === 0) { return { validSubscribers: [], limitErrors: [] }; } const subscriberCountMap = new Map(); for (let i = 0; i < subscribers.length; i += BATCH_SIZE) { const batch = subscribers.slice(i, i + BATCH_SIZE); const subscriberIds = batch.map((sub) => sub._id.toString()); const batchCountMap = await this.topicSubscribersRepository.countSubscriptionsPerSubscriber({ environmentId, organizationId, topicId: topic._id, subscriberIds, }); for (const [subscriberId, count] of batchCountMap.entries()) { subscriberCountMap.set(subscriberId, count); } } const validSubscribers: SubscriberEntity[] = []; const limitErrors: SubscriptionErrorDto[] = []; for (const subscriber of subscribers) { const count = subscriberCountMap.get(subscriber._id.toString()) || 0; if (count >= MAX_SUBSCRIPTIONS_PER_SUBSCRIBER) { limitErrors.push({ subscriberId: subscriber.subscriberId, code: 'SUBSCRIPTION_LIMIT_EXCEEDED', message: `Subscriber ${subscriber.subscriberId} has reached the maximum allowed of ${MAX_SUBSCRIPTIONS_PER_SUBSCRIBER} subscriptions for topic "${topic.key}"`, }); } else { validSubscribers.push(subscriber); } } return { validSubscribers, limitErrors }; } private buildSubscriptionEntity( topic: TopicEntity, subscribers: SubscriberEntity[], subscriptions: Array<{ identifier?: string; subscriberId: string; name?: string }>, contextKeys?: string[] ): CreateTopicSubscribersEntity[] { return subscribers.map((subscriber) => { const subscription = subscriptions.find((sub) => sub.subscriberId === subscriber.subscriberId); return { _environmentId: subscriber._environmentId, _organizationId: subscriber._organizationId, _subscriberId: subscriber._id, _topicId: topic._id, topicKey: topic.key, externalSubscriberId: subscriber.subscriberId, identifier: subscription?.identifier || buildDefaultSubscriptionIdentifier(topic.key, subscriber.subscriberId, contextKeys), name: subscription?.name, contextKeys: contextKeys, }; }); } private async fetchPreferencesForSubscription( command: CreateSubscriptionsCommand, subscription: TopicSubscribersEntity, workflows: NotificationTemplateEntity[], useContextFiltering: boolean ): Promise { if (!command.preferences || command.preferences.length === 0 || workflows.length === 0) { return undefined; } const contextQuery = this.preferencesRepository.buildContextExactMatchQuery(subscription.contextKeys, { enabled: useContextFiltering, }); const preferencesEntities = await this.preferencesRepository.find({ _environmentId: command.environmentId, _organizationId: command.organizationId, _topicSubscriptionId: subscription._id, _subscriberId: subscription._subscriberId, _templateId: { $in: workflows.map((w) => w._id) }, type: PreferencesTypeEnum.SUBSCRIPTION_SUBSCRIBER_WORKFLOW, ...contextQuery, }); if (preferencesEntities.length === 0) { return undefined; } return preferencesEntities .map((pref) => { const workflowId = pref._templateId?.toString(); if (!workflowId) { return null; } const workflow = workflows.find((w) => w._id === workflowId); const preferences = pref.preferences; return { workflow: workflow ? { id: workflow._id, identifier: workflow.triggers?.[0]?.identifier || '', name: workflow.name || '', critical: workflow.critical || false, tags: workflow.tags, data: workflow.data, severity: workflow.severity || SeverityLevelEnum.NONE, } : undefined, subscriptionId: subscription.identifier || buildDefaultSubscriptionIdentifier( subscription.topicKey, subscription.externalSubscriberId, subscription.contextKeys ), enabled: preferences?.all?.enabled ?? true, condition: preferences?.all?.condition as RulesLogic | undefined, }; }) .filter((pref): pref is NonNullable => pref !== null); } private async createPreferencesForSubscriptionsBatch( command: CreateSubscriptionsCommand, subscriptions: TopicSubscribersEntity[] = [], workflows: NotificationTemplateEntity[], contextKeys?: string[] ): Promise> { if (!command.preferences || command.preferences.length === 0) { return []; } return await this.createSubscriptionPreferencesUsecase.executeBatch( { environmentId: command.environmentId, organizationId: command.organizationId, userId: command.userId, preferences: command.preferences, workflows, contextKeys, }, subscriptions ); } private async validateAndFetchWorkflows( preferences: GroupPreferenceFilterDto[] | undefined, environmentId: string, organizationId: string ): Promise { const workflowsById: NotificationTemplateEntity[] = []; const workflowsByIdentifier: NotificationTemplateEntity[] = []; const workflowsByTags: NotificationTemplateEntity[] = []; if (!preferences || preferences.length === 0) { return []; } for (const pref of preferences) { const missingWorkflowIds: string[] = []; const missingTags: string[] = []; const fetchWorkflowIdsByIdsResult = await this.validateAndFetchWorkflowsByIds( pref.filter.workflowIds, environmentId ); workflowsById.push(...fetchWorkflowIdsByIdsResult.workflowsById); workflowsByIdentifier.push(...fetchWorkflowIdsByIdsResult.workflowsByIdentifier); missingWorkflowIds.push(...fetchWorkflowIdsByIdsResult.missingWorkflowIds); const findByTagsResult = await this.findByTags(pref, organizationId, environmentId); workflowsByTags.push(...findByTagsResult.workflowsByTags); missingTags.push(...findByTagsResult.missingTags); if (missingWorkflowIds.length > 0) { this.logger.warn(`Workflows not found: ${missingWorkflowIds.join(', ')}.`); } if (missingTags.length > 0) { this.logger.warn(`No workflows found for tags: ${missingTags.join(', ')}.`); } } return _.uniqBy([...workflowsById, ...workflowsByIdentifier, ...workflowsByTags], '_id'); } private async findByTags( pref: GroupPreferenceFilterDto, organizationId: string, environmentId: string ): Promise<{ workflowsByTags: NotificationTemplateEntity[]; missingTags: string[] }> { const missingTags: string[] = []; let workflowsByTags: NotificationTemplateEntity[] = []; if (pref.filter.tags && pref.filter.tags.length > 0) { workflowsByTags = await this.notificationTemplateRepository.filterActive({ organizationId, environmentId, tags: pref.filter.tags, }); for (const tag of pref.filter.tags) { const hasWorkflowWithTag = workflowsByTags.some((workflow) => workflow.tags?.includes(tag)); if (!hasWorkflowWithTag) { missingTags.push(tag); } } } return { workflowsByTags, missingTags }; } private async validateAndFetchWorkflowsByIds( workflowIds: string[] | undefined, environmentId: string ): Promise<{ workflowsById: NotificationTemplateEntity[]; workflowsByIdentifier: NotificationTemplateEntity[]; missingWorkflowIds: string[]; }> { if (!workflowIds || workflowIds.length === 0) { return { workflowsById: [], workflowsByIdentifier: [], missingWorkflowIds: [], }; } const internalIds: string[] = []; const workflowIdentifiers: string[] = []; for (const workflowId of workflowIds) { if (BaseRepository.isInternalId(workflowId)) { internalIds.push(workflowId); } else { workflowIdentifiers.push(workflowId); } } let workflowsById: NotificationTemplateEntity[] = []; let workflowsByIdentifier: NotificationTemplateEntity[] = []; const missingWorkflowIds: string[] = []; if (internalIds.length > 0) { const uniqueWorkflowIds = [...new Set(internalIds)]; workflowsById = await this.notificationTemplateRepository.find({ _id: { $in: uniqueWorkflowIds }, _environmentId: environmentId, }); const foundWorkflowIds = new Set(workflowsById.map((w) => w._id.toString())); for (const workflowId of uniqueWorkflowIds) { if (!foundWorkflowIds.has(workflowId)) { missingWorkflowIds.push(workflowId); } } } if (workflowIdentifiers.length > 0) { const uniqueWorkflowIdentifiers = [...new Set(workflowIdentifiers)]; workflowsByIdentifier = await this.notificationTemplateRepository.findByTriggerIdentifierBulk( environmentId, uniqueWorkflowIdentifiers ); const foundIdentifiers = new Set(workflowsByIdentifier.map((w) => w.triggers?.[0]?.identifier).filter(Boolean)); for (const identifier of uniqueWorkflowIdentifiers) { if (!foundIdentifiers.has(identifier)) { missingWorkflowIds.push(identifier); } } } return { workflowsById, workflowsByIdentifier, missingWorkflowIds }; } private async resolveContexts( environmentId: string, organizationId: string, context?: ContextPayload ): Promise { const isEnabled = await this.featureFlagsService.getFlag({ key: FeatureFlagsKeysEnum.IS_CONTEXT_PREFERENCES_ENABLED, defaultValue: false, organization: { _id: organizationId }, }); if (!isEnabled) { return undefined; } if (!context) { return []; } const contexts = await this.contextRepository.findOrCreateContextsFromPayload( environmentId, organizationId, context ); return contexts.map((ctx) => ctx.key); } } ================================================ FILE: apps/api/src/app/subscriptions/usecases/create-subscriptions/index.ts ================================================ export * from './create-subscriptions.command'; export * from './create-subscriptions.usecase'; ================================================ FILE: apps/api/src/app/subscriptions/usecases/get-subscription/get-subscription.command.ts ================================================ import { IsArray, IsDefined, IsOptional, IsString } from 'class-validator'; import { EnvironmentCommand } from '../../../shared/commands/project.command'; export class GetSubscriptionCommand extends EnvironmentCommand { @IsString() @IsDefined() topicKey: string; @IsString() @IsDefined() identifier: string; @IsArray() @IsString({ each: true }) @IsOptional() workflowIds?: string[]; @IsArray() @IsString({ each: true }) @IsOptional() tags?: string[]; @IsArray() @IsString({ each: true }) @IsOptional() contextKeys?: string[]; } ================================================ FILE: apps/api/src/app/subscriptions/usecases/get-subscription/get-subscription.usecase.ts ================================================ import { Injectable } from '@nestjs/common'; import { FeatureFlagsService, GetPreferences, GetPreferencesCommand, InstrumentUsecase, } from '@novu/application-generic'; import { BaseRepository, NotificationTemplateRepository, PreferencesEntity, PreferencesRepository, TopicSubscribersEntity, TopicSubscribersRepository, } from '@novu/dal'; import { FeatureFlagsKeysEnum, PreferencesTypeEnum } from '@novu/shared'; import { SubscriptionDetailsResponseDto } from '../../../shared/dtos/subscription-details-response.dto'; import { mapTopicSubscriptionToDto, SELECTED_WORKFLOW_FIELDS_PROJECTION, SelectedWorkflowFields, stripContextFromIdentifier, } from '../../utils/subscriptions'; import { GetSubscriptionCommand } from './get-subscription.command'; type PartialPreferenceEntity = Pick; @Injectable() export class GetSubscription { constructor( private topicSubscribersRepository: TopicSubscribersRepository, private preferencesRepository: PreferencesRepository, private notificationTemplateRepository: NotificationTemplateRepository, private getPreferences: GetPreferences, private featureFlagsService: FeatureFlagsService ) {} @InstrumentUsecase() async execute(command: GetSubscriptionCommand): Promise { const isContextEnabled = await this.featureFlagsService.getFlag({ key: FeatureFlagsKeysEnum.IS_CONTEXT_PREFERENCES_ENABLED, defaultValue: false, organization: { _id: command.organizationId }, }); if (!isContextEnabled) { command.identifier = stripContextFromIdentifier(command.identifier); } const useContextFiltering = await this.featureFlagsService.getFlag({ key: FeatureFlagsKeysEnum.IS_CONTEXT_PREFERENCES_ENABLED, defaultValue: false, organization: { _id: command.organizationId }, }); // Admin API (topics-v2): contextKeys undefined → no context filtering (identifier is sufficient) const contextQuery = command.contextKeys === undefined ? {} : this.topicSubscribersRepository.buildContextExactMatchQuery(command.contextKeys, { enabled: useContextFiltering, }); const subscription = await this.topicSubscribersRepository.findOne({ _environmentId: command.environmentId, _organizationId: command.organizationId, topicKey: command.topicKey, identifier: command.identifier, ...contextQuery, }); if (!subscription) { return null; } const preferencesEntities = await this.preferencesRepository.find({ _environmentId: subscription._environmentId, _subscriberId: subscription._subscriberId, _topicSubscriptionId: subscription._id, type: PreferencesTypeEnum.SUBSCRIPTION_SUBSCRIBER_WORKFLOW, ...contextQuery, }); const { allPreferencesEntities, allWorkflowEntities } = await this.resolveWorkflowPreferences( command, subscription, preferencesEntities ); return mapTopicSubscriptionToDto(subscription, allPreferencesEntities, allWorkflowEntities); } private async resolveWorkflowPreferences( command: GetSubscriptionCommand, subscription: TopicSubscribersEntity, storedPreferences: Array ): Promise<{ allPreferencesEntities: Array; allWorkflowEntities: SelectedWorkflowFields[]; }> { const storedPreferenceWorkflowInternalIds = new Set( storedPreferences.map((pref) => pref._templateId?.toString()).filter((id): id is string => id !== undefined) ); const orConditions: Array> = []; const workflowIdentifiers = command.workflowIds?.filter((id) => !BaseRepository.isInternalId(id)) ?? []; const workflowInternalIds = command.workflowIds?.filter((id) => BaseRepository.isInternalId(id)) ?? []; const allIds = [...Array.from(storedPreferenceWorkflowInternalIds), ...workflowInternalIds]; if (allIds.length > 0) { orConditions.push({ _id: { $in: allIds } }); } if (workflowIdentifiers.length > 0) { orConditions.push({ 'triggers.identifier': { $in: workflowIdentifiers } }); } if (command.tags?.length) { orConditions.push({ tags: { $in: command.tags } }); } if (orConditions.length === 0) { return { allPreferencesEntities: storedPreferences, allWorkflowEntities: [], }; } const allWorkflows = await this.notificationTemplateRepository.find( { _environmentId: subscription._environmentId, _organizationId: subscription._organizationId, $or: orConditions, }, SELECTED_WORKFLOW_FIELDS_PROJECTION ); const missingWorkflows: SelectedWorkflowFields[] = allWorkflows.filter( (workflow) => !storedPreferenceWorkflowInternalIds.has(workflow._id) ); const computedPreferences = await this.computePreferencesForMissingWorkflows( command, subscription, missingWorkflows ); return { allPreferencesEntities: [...storedPreferences, ...computedPreferences], allWorkflowEntities: [...allWorkflows], }; } private async computePreferencesForMissingWorkflows( command: GetSubscriptionCommand, subscription: TopicSubscribersEntity, missingWorkflows: SelectedWorkflowFields[] ): Promise> { if (missingWorkflows.length === 0) { return []; } const computedPreferences = await Promise.all( missingWorkflows.map(async (workflow) => { const result = await this.getPreferences.safeExecute( GetPreferencesCommand.create({ environmentId: command.environmentId, organizationId: command.organizationId, subscriberId: subscription._subscriberId, templateId: workflow._id, excludeSubscriberPreferences: true, contextKeys: subscription.contextKeys, }) ); if (!result?.preferences) { return null; } return { _templateId: workflow._id, preferences: result.preferences, }; }) ); return computedPreferences.filter((pref): pref is NonNullable => pref !== null); } } ================================================ FILE: apps/api/src/app/subscriptions/usecases/index.ts ================================================ import { CreateSubscriptionPreferencesUsecase } from './create-subscription-preferences/create-subscription-preferences.usecase'; import { CreateSubscriptionsUsecase } from './create-subscriptions/create-subscriptions.usecase'; import { UpdateSubscriptionUsecase } from './update-subscription/update-subscription.usecase'; export const USE_CASES = [CreateSubscriptionPreferencesUsecase, CreateSubscriptionsUsecase, UpdateSubscriptionUsecase]; export * from './create-subscription-preferences'; export * from './create-subscriptions'; export * from './update-subscription'; ================================================ FILE: apps/api/src/app/subscriptions/usecases/update-subscription/index.ts ================================================ export * from './update-subscription.command'; export * from './update-subscription.usecase'; ================================================ FILE: apps/api/src/app/subscriptions/usecases/update-subscription/update-subscription.command.ts ================================================ import { IsArray, IsDefined, IsOptional, IsString } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; import { GroupPreferenceFilterDto } from '../../../shared/dtos/subscriptions/create-subscriptions.dto'; export class UpdateSubscriptionCommand extends EnvironmentWithUserCommand { @IsString() @IsDefined() topicKey: string; @IsString() @IsDefined() identifier: string; @IsString() @IsOptional() name?: string; @IsArray() @IsOptional() preferences?: Array; @IsArray() @IsOptional() @IsString({ each: true }) contextKeys?: string[]; } ================================================ FILE: apps/api/src/app/subscriptions/usecases/update-subscription/update-subscription.usecase.ts ================================================ import { Injectable, NotFoundException } from '@nestjs/common'; import { buildDefaultSubscriptionIdentifier, FeatureFlagsService, InstrumentUsecase, PinoLogger, } from '@novu/application-generic'; import { BaseRepository, NotificationTemplateEntity, NotificationTemplateRepository, PreferencesRepository, SubscriberEntity, SubscriberRepository, TopicEntity, TopicRepository, TopicSubscribersEntity, TopicSubscribersRepository, } from '@novu/dal'; import { FeatureFlagsKeysEnum, PreferencesTypeEnum, SeverityLevelEnum } from '@novu/shared'; import { RulesLogic } from 'json-logic-js'; import _ from 'lodash'; import { GroupPreferenceFilterDto } from '../../../shared/dtos/subscriptions/create-subscriptions.dto'; import { SubscriptionPreferenceDto, SubscriptionResponseDto, } from '../../../shared/dtos/subscriptions/create-subscriptions-response.dto'; import { stripContextFromIdentifier } from '../../utils/subscriptions'; import { CreateSubscriptionPreferencesCommand } from '../create-subscription-preferences/create-subscription-preferences.command'; import { CreateSubscriptionPreferencesUsecase } from '../create-subscription-preferences/create-subscription-preferences.usecase'; import { UpdateSubscriptionCommand } from './update-subscription.command'; @Injectable() export class UpdateSubscriptionUsecase { constructor( private topicRepository: TopicRepository, private topicSubscribersRepository: TopicSubscribersRepository, private subscriberRepository: SubscriberRepository, private preferencesRepository: PreferencesRepository, private notificationTemplateRepository: NotificationTemplateRepository, private createSubscriptionPreferencesUsecase: CreateSubscriptionPreferencesUsecase, private featureFlagsService: FeatureFlagsService, private logger: PinoLogger ) { this.logger.setContext(this.constructor.name); } @InstrumentUsecase() async execute(command: UpdateSubscriptionCommand): Promise { const isContextEnabled = await this.featureFlagsService.getFlag({ key: FeatureFlagsKeysEnum.IS_CONTEXT_PREFERENCES_ENABLED, defaultValue: false, organization: { _id: command.organizationId }, }); if (!isContextEnabled) { command.identifier = stripContextFromIdentifier(command.identifier); } const workflows = await this.validateAndFetchWorkflows( command.preferences, command.environmentId, command.organizationId ); const topic = await this.topicRepository.findTopicByKey( command.topicKey, command.organizationId, command.environmentId ); if (!topic) { throw new NotFoundException(`Topic with key ${command.topicKey} not found`); } const contextQuery = await this.buildContextQuery(command.contextKeys, command.organizationId); const subscription = await this.topicSubscribersRepository.findOne({ identifier: command.identifier, _environmentId: command.environmentId, _organizationId: command.organizationId, _topicId: topic._id, ...contextQuery, }); if (!subscription) { throw new NotFoundException( `Subscription with identifier ${command.identifier} not found for topic ${command.topicKey}` ); } const updateData: Partial = {}; if (command.preferences !== undefined) { await this.updatePreferencesForSubscription(command, subscription, workflows); } if (command.name !== undefined) { updateData.name = command.name; } if (Object.keys(updateData).length > 0) { await this.topicSubscribersRepository.update( { _id: subscription._id, _environmentId: command.environmentId, _organizationId: command.organizationId, }, updateData ); } const updatedSubscription = await this.topicSubscribersRepository.findOne({ _id: subscription._id, _environmentId: command.environmentId, _organizationId: command.organizationId, }); if (!updatedSubscription) { throw new NotFoundException(`Subscription with ID ${subscription._id} could not be retrieved after update`); } const subscriber = await this.subscriberRepository.findOne({ _id: updatedSubscription._subscriberId, _environmentId: command.environmentId, _organizationId: command.organizationId, }); const preferences = await this.fetchPreferencesForSubscription( updatedSubscription, command.environmentId, command.organizationId, workflows, command.contextKeys ); return this.mapSubscriptionToDto(updatedSubscription, subscriber, topic, preferences); } private async updatePreferencesForSubscription( command: UpdateSubscriptionCommand, subscription: TopicSubscribersEntity, workflows: NotificationTemplateEntity[] ): Promise { const contextQuery = await this.buildContextQuery(command.contextKeys, command.organizationId); await this.preferencesRepository.delete({ _environmentId: command.environmentId, _organizationId: command.organizationId, _topicSubscriptionId: subscription._id, _subscriberId: subscription._subscriberId, type: PreferencesTypeEnum.SUBSCRIPTION_SUBSCRIBER_WORKFLOW, ...contextQuery, }); if (!command.preferences || command.preferences.length === 0) { return; } await this.createSubscriptionPreferencesUsecase.execute( CreateSubscriptionPreferencesCommand.create({ environmentId: command.environmentId, organizationId: command.organizationId, userId: command.userId, preferences: command.preferences, _topicSubscriptionId: subscription._id.toString(), subscriptionId: subscription.identifier, _subscriberId: subscription._subscriberId.toString(), topicKey: subscription.topicKey, externalSubscriberId: subscription.externalSubscriberId, workflows, contextKeys: subscription.contextKeys, }) ); } private async fetchPreferencesForSubscription( subscription: TopicSubscribersEntity, environmentId: string, organizationId: string, workflows: NotificationTemplateEntity[], contextKeys?: string[] ): Promise { if (workflows.length === 0) { return undefined; } const contextQuery = await this.buildContextQuery(contextKeys, organizationId); const preferencesEntities = await this.preferencesRepository.find({ _environmentId: environmentId, _organizationId: organizationId, _topicSubscriptionId: subscription._id, _subscriberId: subscription._subscriberId, _templateId: { $in: workflows.map((w) => w._id) }, type: PreferencesTypeEnum.SUBSCRIPTION_SUBSCRIBER_WORKFLOW, ...contextQuery, }); if (preferencesEntities.length === 0) { return undefined; } return preferencesEntities .map((pref) => { const workflowId = pref._templateId?.toString(); if (!workflowId) { return null; } const workflow = workflows.find((w) => w._id === workflowId); const preferences = pref.preferences; return { workflow: workflow ? { id: workflow._id, identifier: workflow.triggers?.[0]?.identifier || '', name: workflow.name || '', critical: workflow.critical || false, tags: workflow.tags, data: workflow.data, severity: workflow.severity || SeverityLevelEnum.NONE, } : undefined, subscriptionId: subscription.identifier || buildDefaultSubscriptionIdentifier( subscription.topicKey, subscription.externalSubscriberId, subscription.contextKeys ), enabled: preferences?.all?.enabled ?? true, condition: preferences?.all?.condition as RulesLogic | undefined, }; }) .filter((pref): pref is NonNullable => pref !== null); } private async validateAndFetchWorkflows( preferences: GroupPreferenceFilterDto[] | undefined, environmentId: string, organizationId: string ): Promise { const workflowsById: NotificationTemplateEntity[] = []; const workflowsByIdentifier: NotificationTemplateEntity[] = []; const workflowsByTags: NotificationTemplateEntity[] = []; if (!preferences || preferences.length === 0) { return []; } for (const pref of preferences) { const missingWorkflowIds: string[] = []; const missingTags: string[] = []; const fetchWorkflowIdsByIdsResult = await this.validateAndFetchWorkflowsByIds( pref.filter.workflowIds, environmentId ); workflowsById.push(...fetchWorkflowIdsByIdsResult.workflowsById); workflowsByIdentifier.push(...fetchWorkflowIdsByIdsResult.workflowsByIdentifier); missingWorkflowIds.push(...fetchWorkflowIdsByIdsResult.missingWorkflowIds); const findByTagsResult = await this.findByTags(pref, organizationId, environmentId); workflowsByTags.push(...findByTagsResult.workflowsByTags); missingTags.push(...findByTagsResult.missingTags); if (missingWorkflowIds.length > 0) { this.logger.warn(`Workflows not found: ${missingWorkflowIds.join(', ')}.`); } if (missingTags.length > 0) { this.logger.warn(`No workflows found for tags: ${missingTags.join(', ')}.`); } } return _.uniqBy([...workflowsById, ...workflowsByIdentifier, ...workflowsByTags], '_id'); } private async findByTags( pref: GroupPreferenceFilterDto, organizationId: string, environmentId: string ): Promise<{ workflowsByTags: NotificationTemplateEntity[]; missingTags: string[] }> { const missingTags: string[] = []; let workflowsByTags: NotificationTemplateEntity[] = []; if (pref.filter.tags && pref.filter.tags.length > 0) { workflowsByTags = await this.notificationTemplateRepository.filterActive({ organizationId, environmentId, tags: pref.filter.tags, }); for (const tag of pref.filter.tags) { const hasWorkflowWithTag = workflowsByTags.some((workflow) => workflow.tags?.includes(tag)); if (!hasWorkflowWithTag) { missingTags.push(tag); } } } return { workflowsByTags, missingTags }; } private async validateAndFetchWorkflowsByIds( workflowIds: string[] | undefined, environmentId: string ): Promise<{ workflowsById: NotificationTemplateEntity[]; workflowsByIdentifier: NotificationTemplateEntity[]; missingWorkflowIds: string[]; }> { if (!workflowIds || workflowIds.length === 0) { return { workflowsById: [], workflowsByIdentifier: [], missingWorkflowIds: [], }; } const internalIds: string[] = []; const workflowIdentifiers: string[] = []; for (const workflowId of workflowIds) { if (BaseRepository.isInternalId(workflowId)) { internalIds.push(workflowId); } else { workflowIdentifiers.push(workflowId); } } let workflowsById: NotificationTemplateEntity[] = []; let workflowsByIdentifier: NotificationTemplateEntity[] = []; const missingWorkflowIds: string[] = []; if (internalIds.length > 0) { const uniqueWorkflowIds = [...new Set(internalIds)]; workflowsById = await this.notificationTemplateRepository.find({ _id: { $in: uniqueWorkflowIds }, _environmentId: environmentId, }); const foundWorkflowIds = new Set(workflowsById.map((w) => w._id.toString())); for (const workflowId of uniqueWorkflowIds) { if (!foundWorkflowIds.has(workflowId)) { missingWorkflowIds.push(workflowId); } } } if (workflowIdentifiers.length > 0) { const uniqueWorkflowIdentifiers = [...new Set(workflowIdentifiers)]; workflowsByIdentifier = await this.notificationTemplateRepository.findByTriggerIdentifierBulk( environmentId, uniqueWorkflowIdentifiers ); const foundIdentifiers = new Set(workflowsByIdentifier.map((w) => w.triggers?.[0]?.identifier).filter(Boolean)); for (const identifier of uniqueWorkflowIdentifiers) { if (!foundIdentifiers.has(identifier)) { missingWorkflowIds.push(identifier); } } } return { workflowsById, workflowsByIdentifier, missingWorkflowIds }; } private mapSubscriptionToDto( subscription: TopicSubscribersEntity, subscriber: SubscriberEntity | null, topic: TopicEntity, preferences?: SubscriptionPreferenceDto[] ): SubscriptionResponseDto { return { _id: subscription._id.toString(), identifier: subscription.identifier, name: subscription.name, topic: { _id: topic._id, key: topic.key, name: topic.name, }, subscriber: subscriber ? { _id: subscriber._id, subscriberId: subscriber.subscriberId, avatar: subscriber.avatar, firstName: subscriber.firstName, lastName: subscriber.lastName, email: subscriber.email, createdAt: subscriber.createdAt, updatedAt: subscriber.updatedAt, } : null, preferences, contextKeys: subscription.contextKeys, createdAt: subscription.createdAt ?? '', updatedAt: subscription.updatedAt ?? '', }; } private async buildContextQuery(contextKeys?: string[], organizationId?: string): Promise> { if (!organizationId) { return {}; } const useContextFiltering = await this.featureFlagsService.getFlag({ key: FeatureFlagsKeysEnum.IS_CONTEXT_PREFERENCES_ENABLED, defaultValue: false, organization: { _id: organizationId }, }); return this.topicSubscribersRepository.buildContextExactMatchQuery(contextKeys, { enabled: useContextFiltering, }); } } ================================================ FILE: apps/api/src/app/subscriptions/utils/subscriptions.ts ================================================ import { buildDefaultSubscriptionIdentifier } from '@novu/application-generic'; import { NotificationTemplateEntity, PreferencesEntity, TopicSubscribersEntity } from '@novu/dal'; import { SeverityLevelEnum } from '@novu/shared'; import { RulesLogic } from 'json-logic-js'; import { SubscriptionDetailsResponseDto } from '../../shared/dtos/subscription-details-response.dto'; import { SubscriptionPreferenceDto } from '../../shared/dtos/subscriptions/create-subscriptions-response.dto'; export type SelectedWorkflowFields = Pick< NotificationTemplateEntity, '_id' | 'triggers' | 'name' | 'critical' | 'tags' | 'data' | 'severity' >; type PartialPreferenceEntity = Pick; export function mapTopicSubscriptionToDto( subscription: TopicSubscribersEntity, preferencesEntities: Array, workflowEntities: SelectedWorkflowFields[] ): SubscriptionDetailsResponseDto { const preferences: SubscriptionPreferenceDto[] = preferencesEntities .map((pref) => { const workflowId = pref._templateId?.toString(); if (!workflowId) { return null; } const workflow = workflowEntities.find((w) => w._id === workflowId); const preferences = pref.preferences; return { workflow: workflow ? { id: workflow._id, identifier: workflow.triggers?.[0]?.identifier || '', name: workflow.name || '', critical: workflow.critical || false, tags: workflow.tags, data: workflow.data, severity: workflow.severity || SeverityLevelEnum.NONE, } : undefined, subscriptionId: subscription.identifier || buildDefaultSubscriptionIdentifier( subscription.topicKey, subscription.externalSubscriberId, subscription.contextKeys ), enabled: preferences?.all?.enabled ?? true, condition: preferences?.all?.condition as RulesLogic | undefined, }; }) .filter((pref): pref is NonNullable => pref !== null); return { id: subscription._id, identifier: subscription.identifier, name: subscription.name, preferences: preferences.length > 0 ? preferences : undefined, contextKeys: subscription.contextKeys, }; } /** * Strips the context part from an identifier when feature flag is off. * This handles the case where the client includes context in identifiers * but the server has stored them without context. * * @example * stripContextFromIdentifier('tk_topic:si_sub:ctx_project:a,tenant:b') // 'tk_topic:si_sub' * stripContextFromIdentifier('tk_topic:si_sub') // 'tk_topic:si_sub' */ export function stripContextFromIdentifier(identifier: string): string { const contextIndex = identifier.lastIndexOf(':ctx_'); if (contextIndex === -1) { return identifier; } return identifier.substring(0, contextIndex); } /** * MongoDB projection object for SelectedWorkflowFields. * This ensures the projection is always aligned with the type definition. */ export const SELECTED_WORKFLOW_FIELDS_PROJECTION: Record = { _id: 1, triggers: 1, name: 1, critical: 1, tags: 1, data: 1, severity: 1, } as const; ================================================ FILE: apps/api/src/app/support/dtos/create-thread.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { IsString } from 'class-validator'; export class CreateSupportThreadDto { @ApiProperty() @IsString() text: string; } ================================================ FILE: apps/api/src/app/support/dtos/plain-card.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; export class PlainCustomer { @ApiProperty() id: string; @ApiProperty() externalId?: string; @ApiProperty() email?: string; } export class PlainTenant { @ApiProperty() id?: string; @ApiProperty() externalId?: string; } export class PlainThread { @ApiProperty() id?: string; @ApiProperty() externalId?: string; } export class PlainCardRequestDto { @ApiProperty() cardKeys?: string[]; @ApiProperty() customer?: PlainCustomer | null; @ApiProperty() tenant?: PlainTenant | null; @ApiProperty() thread?: PlainThread | null; @ApiProperty() timestamp: string; } ================================================ FILE: apps/api/src/app/support/guards/plain-cards.guard.ts ================================================ import crypto from 'node:crypto'; import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; import { Observable } from 'rxjs'; @Injectable() export class PlainCardsGuard implements CanActivate { canActivate(context: ExecutionContext): boolean | Promise | Observable { const request = context.switchToHttp().getRequest(); const requestBody = JSON.stringify(request.body); const plainCardsHMACSecretKey = process.env.PLAIN_CARDS_HMAC_SECRET_KEY as string; const incomingSignature = request.headers['plain-request-signature']; if (!incomingSignature) throw new UnauthorizedException('Plain request signature is missing'); const expectedSignature = crypto.createHmac('sha-256', plainCardsHMACSecretKey).update(requestBody).digest('hex'); return incomingSignature === expectedSignature; } } ================================================ FILE: apps/api/src/app/support/support.controller.ts ================================================ import { Body, Controller, Post, UseGuards } from '@nestjs/common'; import { ApiExcludeController } from '@nestjs/swagger'; import { Novu } from '@novu/api'; import { UserSession } from '@novu/application-generic'; import { UserSessionData } from '@novu/shared'; import { RequireAuthentication } from '../auth/framework/auth.decorator'; import { CreateSupportThreadDto } from './dtos/create-thread.dto'; import { PlainCardRequestDto } from './dtos/plain-card.dto'; import { PlainCardsGuard } from './guards/plain-cards.guard'; import { CreateSupportThreadUsecase, PlainCardsUsecase } from './usecases'; import { CreateSupportThreadCommand } from './usecases/create-thread.command'; import { PlainCardsCommand } from './usecases/plain-cards.command'; @Controller('/support') @ApiExcludeController() export class SupportController { constructor( private createSupportThreadUsecase: CreateSupportThreadUsecase, private plainCardsUsecase: PlainCardsUsecase ) {} @UseGuards(PlainCardsGuard) @Post('customer-details') async fetchUserOrganizations(@Body() body: PlainCardRequestDto) { return this.plainCardsUsecase.fetchCustomerDetails(PlainCardsCommand.create({ ...body })); } @RequireAuthentication() @Post('create-thread') async createThread(@Body() body: CreateSupportThreadDto, @UserSession() user: UserSessionData) { return this.createSupportThreadUsecase.execute( CreateSupportThreadCommand.create({ text: body.text, email: user.email as string, firstName: user.firstName as string, lastName: user.lastName as string, userId: user._id as string, }) ); } @RequireAuthentication() @Post('mobile-setup') async mobileSetup(@UserSession() user: UserSessionData) { const novu = new Novu({ security: { secretKey: process.env.NOVU_INTERNAL_SECRET_KEY, }, }); await novu.trigger({ workflowId: 'mobile-setup-email', to: { subscriberId: user._id as string, firstName: user.firstName as string, lastName: user.lastName as string, email: user.email as string, }, payload: {}, }); } } ================================================ FILE: apps/api/src/app/support/support.module.ts ================================================ import { Module } from '@nestjs/common'; import { SupportService } from '@novu/application-generic'; import { OrganizationRepository, UserRepository } from '@novu/dal'; import { SharedModule } from '../shared/shared.module'; import { PlainCardsGuard } from './guards/plain-cards.guard'; import { SupportController } from './support.controller'; import { CreateSupportThreadUsecase, PlainCardsUsecase } from './usecases'; @Module({ imports: [SharedModule], controllers: [SupportController], providers: [ CreateSupportThreadUsecase, PlainCardsUsecase, SupportService, OrganizationRepository, UserRepository, PlainCardsGuard, ], }) export class SupportModule {} ================================================ FILE: apps/api/src/app/support/usecases/create-thread.command.ts ================================================ import { BaseCommand } from '@novu/application-generic'; import { IsDefined, IsOptional, IsString } from 'class-validator'; export class CreateSupportThreadCommand extends BaseCommand { @IsDefined() @IsString() text: string; @IsDefined() @IsString() email: string; @IsDefined() @IsString() firstName: string; @IsOptional() @IsString() lastName?: string; @IsDefined() @IsString() userId: string; } ================================================ FILE: apps/api/src/app/support/usecases/create-thread.usecase.ts ================================================ import { Injectable } from '@nestjs/common'; import { capitalize, SupportService } from '@novu/application-generic'; import { CreateSupportThreadCommand } from './create-thread.command'; @Injectable() export class CreateSupportThreadUsecase { constructor(private supportService: SupportService) {} async execute(command: CreateSupportThreadCommand) { const firstName = capitalize(command.firstName ?? ''); const lastName = capitalize(command.lastName ?? ''); const plainCustomer = await this.supportService.upsertCustomer({ emailAddress: command.email, fullName: `${firstName} ${lastName}`, novuUserId: command.userId, }); const thread = await this.supportService.createThread({ plainCustomerId: plainCustomer.data?.customer?.id, threadText: command.text, }); return { success: true, message: 'Thread created successfully', threadId: thread.data?.id, }; } } ================================================ FILE: apps/api/src/app/support/usecases/index.ts ================================================ export * from './create-thread.usecase'; export * from './plain-cards.usecase'; ================================================ FILE: apps/api/src/app/support/usecases/plain-cards.command.ts ================================================ import { BaseCommand } from '@novu/application-generic'; import { IsArray, IsDefined, IsOptional, IsString } from 'class-validator'; import { PlainCustomer, PlainTenant, PlainThread } from '../dtos/plain-card.dto'; export class PlainCardsCommand extends BaseCommand { @IsOptional() @IsArray() cardKeys?: string[]; @IsOptional() customer?: PlainCustomer | null; @IsOptional() tenant?: PlainTenant | null; @IsOptional() thread?: PlainThread | null; @IsDefined() @IsString() timestamp: string; } ================================================ FILE: apps/api/src/app/support/usecases/plain-cards.usecase.ts ================================================ import { Injectable } from '@nestjs/common'; import { OrganizationRepository, UserRepository } from '@novu/dal'; import { uiComponent } from '@team-plain/typescript-sdk'; import { differenceInDays } from 'date-fns'; import { PlainCardsCommand } from './plain-cards.command'; @Injectable() export class PlainCardsUsecase { constructor( private organizationRepository: OrganizationRepository, private userRepository: UserRepository ) {} async fetchCustomerDetails(command: PlainCardsCommand) { const key = `customer-details-${process.env.NOVU_REGION}`; if (!command?.customer?.externalId) { return { data: {}, cards: [ { key, components: [ uiComponent.spacer({ size: 'S' }), uiComponent.text({ text: 'This user is not yet registered in this region', }), ], }, ], }; } const organizations = await this.organizationRepository.findUserActiveOrganizations(command?.customer?.externalId); if (!organizations) { return { data: {}, cards: [ { key, components: [ uiComponent.spacer({ size: 'S' }), uiComponent.text({ text: 'This user is not yet registered in this region', }), ], }, ], }; } const sessions = await this.userRepository.findUserSessions(command?.customer?.externalId); return { data: {}, cards: [ { key, components: [ uiComponent.text({ text: "User's Organizations", size: 'L', }), uiComponent.divider({ spacingSize: 'M' }), ...this.organizationsComponent(organizations), uiComponent.divider({ spacingSize: 'M' }), uiComponent.text({ text: "User's Sessions", size: 'L', }), uiComponent.divider({ spacingSize: 'M' }), ...this.sessionsComponent(sessions), ], }, ], }; } private organizationsComponent = (organizations) => { const activeOrganizations = organizations?.map((organization) => { const orgCreatedAt = new Date(organization?.createdAt); const isTrialRemaining = differenceInDays(new Date(), orgCreatedAt) < 14; const orgTier = organization?.apiServiceLevel === 'business' && isTrialRemaining ? 'business-trial' : (organization?.apiServiceLevel ?? 'NA'); return uiComponent.container({ content: [ uiComponent.spacer({ size: 'XS' }), uiComponent.text({ text: 'Novu Org Id', size: 'S', color: 'MUTED', }), uiComponent.spacer({ size: 'XS' }), uiComponent.row({ mainContent: [ uiComponent.text({ text: organization?._id, size: 'S', }), ], asideContent: [ uiComponent.copyButton({ tooltip: 'Copy Novu Org Id', value: organization?._id, }), ], }), uiComponent.spacer({ size: 'M' }), uiComponent.text({ text: 'Clerk Org Id', size: 'S', color: 'MUTED', }), uiComponent.spacer({ size: 'XS' }), uiComponent.row({ mainContent: [ uiComponent.text({ text: organization?.externalId, size: 'S', }), ], asideContent: [ uiComponent.copyButton({ tooltip: 'Copy Clerk Org Id', value: organization?.externalId, }), ], }), uiComponent.spacer({ size: 'M' }), uiComponent.text({ text: 'Org Name', size: 'S', color: 'MUTED', }), uiComponent.spacer({ size: 'XS' }), uiComponent.row({ mainContent: [ uiComponent.text({ text: organization?.name, size: 'S', }), ], asideContent: [ uiComponent.copyButton({ tooltip: 'Copy Org Name', value: organization?.name, }), ], }), uiComponent.spacer({ size: 'M' }), uiComponent.spacer({ size: 'XS' }), uiComponent.row({ mainContent: [ uiComponent.text({ text: 'Org Tier', size: 'S', color: 'MUTED', }), ], asideContent: [ uiComponent.text({ text: orgTier, size: 'S', }), ], }), uiComponent.spacer({ size: 'M' }), uiComponent.row({ mainContent: [ uiComponent.text({ text: 'Org Created At', size: 'S', }), ], asideContent: [ uiComponent.text({ text: organization?.createdAt, size: 'S', }), ], }), ], }); }); return activeOrganizations; }; private sessionsComponent = (sessions) => { const allSessions = sessions.map((session) => { return uiComponent.container({ content: [ uiComponent.row({ mainContent: [ uiComponent.text({ text: 'Status', size: 'S', }), ], asideContent: [ uiComponent.text({ text: session?.status || 'NA', }), ], }), uiComponent.row({ mainContent: [ uiComponent.text({ text: 'City', size: 'S', }), ], asideContent: [ uiComponent.text({ text: session?.latestActivity?.city || 'NA', }), ], }), uiComponent.row({ mainContent: [ uiComponent.text({ text: 'Country', size: 'S', }), ], asideContent: [ uiComponent.text({ text: session?.latestActivity?.country || 'NA', }), ], }), uiComponent.row({ mainContent: [ uiComponent.text({ text: 'Device Type', size: 'S', }), ], asideContent: [ uiComponent.text({ text: session?.latestActivity?.deviceType || 'NA', }), ], }), uiComponent.row({ mainContent: [ uiComponent.text({ text: 'Browser Name', size: 'S', }), ], asideContent: [ uiComponent.text({ text: session?.latestActivity?.browserName || 'NA', }), ], }), uiComponent.row({ mainContent: [ uiComponent.text({ text: 'Browser Version', size: 'S', }), ], asideContent: [ uiComponent.text({ text: session?.latestActivity?.browserVersion || 'NA', }), ], }), ], }); }); return allSessions; }; } ================================================ FILE: apps/api/src/app/tenant/dtos/create-tenant-request.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { CustomDataType, ICreateTenantDto } from '@novu/shared'; export class CreateTenantRequestDto implements ICreateTenantDto { @ApiProperty() identifier: string; @ApiProperty() name: string; @ApiProperty() data?: CustomDataType; } ================================================ FILE: apps/api/src/app/tenant/dtos/create-tenant-response.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { CustomDataType } from '@novu/shared'; export class CreateTenantResponseDto { @ApiProperty() _id: string; @ApiProperty() identifier: string; @ApiProperty() name?: string; @ApiProperty() data?: CustomDataType; @ApiProperty() _environmentId: string; @ApiProperty() createdAt: string; @ApiProperty() updatedAt: string; } ================================================ FILE: apps/api/src/app/tenant/dtos/get-tenant-response.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { CustomDataType } from '@novu/shared'; export class GetTenantResponseDto { @ApiProperty() _id: string; @ApiProperty() identifier: string; @ApiProperty() name?: string; @ApiProperty() data?: CustomDataType; @ApiProperty() _environmentId: string; @ApiProperty() createdAt: string; @ApiProperty() updatedAt: string; } ================================================ FILE: apps/api/src/app/tenant/dtos/get-tenants-request.dto.ts ================================================ import { PaginationRequestDto } from '../../shared/dtos/pagination-request'; const LIMIT = { DEFAULT: 10, MAX: 100, }; export class GetTenantsRequestDto extends PaginationRequestDto(LIMIT.DEFAULT, LIMIT.MAX) {} ================================================ FILE: apps/api/src/app/tenant/dtos/index.ts ================================================ export * from './create-tenant-request.dto'; export * from './create-tenant-response.dto'; export * from './get-tenant-response.dto'; export * from './get-tenants-request.dto'; export * from './update-tenant-request.dto'; export * from './update-tenant-response.dto'; ================================================ FILE: apps/api/src/app/tenant/dtos/update-tenant-request.dto.ts ================================================ import { ApiPropertyOptional } from '@nestjs/swagger'; import { CustomDataType, IUpdateTenantDto } from '@novu/shared'; import { IsOptional, IsString } from 'class-validator'; export class UpdateTenantRequestDto implements IUpdateTenantDto { @IsOptional() @IsString() @ApiPropertyOptional({ type: String }) identifier?: string; @IsOptional() @IsString() @ApiPropertyOptional({ type: String }) name?: string; @IsOptional() @ApiPropertyOptional() data?: CustomDataType; } ================================================ FILE: apps/api/src/app/tenant/dtos/update-tenant-response.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { CustomDataType } from '@novu/shared'; import { IsString } from 'class-validator'; export class UpdateTenantResponseDto { @ApiProperty({ type: String }) @IsString() _id: string; @ApiProperty({ type: String }) @IsString() identifier: string; @ApiPropertyOptional({ type: String }) @IsString() name?: string; @ApiPropertyOptional() data?: CustomDataType; @ApiProperty({ type: String }) @IsString() _environmentId: string; @ApiProperty({ type: String }) @IsString() createdAt: string; @ApiProperty({ type: String }) @IsString() updatedAt: string; } ================================================ FILE: apps/api/src/app/tenant/e2e/create-tenant.e2e.ts ================================================ // noinspection ExceptionCaughtLocallyJS import { TenantRepository } from '@novu/dal'; import { UserSession } from '@novu/testing'; import type { AxiosResponse } from 'axios'; import axios, { AxiosError } from 'axios'; import { expect } from 'chai'; function assertValidationMessages(e: AxiosError, field: string, msg1: string) { if (!(e instanceof AxiosError)) { throw new Error(e); } const messages = e.response?.data.errors[field].messages; expect(messages).to.be.an('array').that.includes(msg1); } describe('Create Tenant - /tenants (POST) #novu-v0', () => { let session: UserSession; const tenantRepository = new TenantRepository(); beforeEach(async () => { session = new UserSession(); await session.initialize(); }); it('should create a new tenant', async () => { const response = await createTenant({ session, identifier: 'identifier_123', name: 'name_123', data: { test1: 'test value1', test2: 'test value2' }, }); expect(response.status).to.equal(201); expect(response.data).to.be.ok; const createdTenant = await tenantRepository.findOne({ _organizationId: session.organization._id, _environmentId: session.environment._id, identifier: 'identifier_123', }); expect(createdTenant?.name).to.equal('name_123'); expect(createdTenant?.identifier).to.equal('identifier_123'); expect(createdTenant?.data).to.deep.equal({ test1: 'test value1', test2: 'test value2' }); }); it('should throw error if a tenant is already exist in the environment', async () => { await createTenant({ session, identifier: 'identifier_123', name: 'name_123', }); try { await createTenant({ session, identifier: 'identifier_123', name: 'name_123', }); throw new Error(''); } catch (e) { expect(e.response.status).to.equal(409); expect(e.response.data.message).to.contains( `Tenant with identifier: identifier_123 already exists under environment ${session.environment._id}` ); } }); it('should throw error if a missing tenant identifier', async () => { try { await createTenant({ session, }); throw new Error('Should Not Succeed In the call'); } catch (e) { assertValidationMessages(e, 'identifier', 'identifier should not be empty'); assertValidationMessages(e, 'identifier', 'identifier must be a string'); } }); }); export async function createTenant({ session, identifier, name, data, }: { session; identifier?: string; name?: string; data?: any; }): Promise { const axiosInstance = axios.create(); return await axiosInstance.post( `${session.serverUrl}/v1/tenants`, { identifier, name, data, }, { headers: { authorization: `ApiKey ${session.apiKey}`, }, } ); } ================================================ FILE: apps/api/src/app/tenant/e2e/delete-tenant.e2e.ts ================================================ import { TenantRepository } from '@novu/dal'; import { UserSession } from '@novu/testing'; import axios from 'axios'; import type { AxiosResponse } from 'axios'; import { expect } from 'chai'; describe('Delete Tenant - /tenants/:identifier (DELETE) #novu-v0', () => { let session: UserSession; const tenantRepository = new TenantRepository(); beforeEach(async () => { session = new UserSession(); await session.initialize(); }); it('should delete newly created tenant', async () => { await tenantRepository.create({ _organizationId: session.organization._id, _environmentId: session.environment._id, identifier: 'identifier_123', name: 'name_123', data: { test1: 'test value1', test2: 'test value2' }, }); const existingTenant = await tenantRepository.findOne({ _environmentId: session.environment._id, identifier: 'identifier_123', }); expect(existingTenant).to.be.ok; await deleteTenant({ session, identifier: 'identifier_123', }); const deletedTenant = await tenantRepository.findOne({ _environmentId: session.environment._id, identifier: 'identifier_123', }); expect(deletedTenant).to.equal(null); }); it('should throw exception while trying to delete not existing tenant', async () => { const identifier = '4f3c4146-e471-4fe8-b23d-e3411689db00'; try { await deleteTenant({ session, identifier, }); throw new Error(''); } catch (e) { expect(e?.response?.data?.message || e?.message).to.contains( `Tenant with identifier: ${identifier} does not exist under environment ${session.environment._id}` ); } }); }); export async function deleteTenant({ session, identifier }: { session; identifier?: string }): Promise { const axiosInstance = axios.create(); return await axiosInstance.delete(`${session.serverUrl}/v1/tenants/${identifier}`, { headers: { authorization: `ApiKey ${session.apiKey}`, }, }); } ================================================ FILE: apps/api/src/app/tenant/e2e/get-tenant.e2e.ts ================================================ import { TenantRepository } from '@novu/dal'; import { UserSession } from '@novu/testing'; import axios from 'axios'; import type { AxiosResponse } from 'axios'; import { expect } from 'chai'; describe('Get Tenant - /tenants/:identifier (GET) #novu-v0', () => { let session: UserSession; const tenantRepository = new TenantRepository(); beforeEach(async () => { session = new UserSession(); await session.initialize(); }); it('should get a newly created tenant', async () => { await tenantRepository.create({ _organizationId: session.organization._id, _environmentId: session.environment._id, identifier: 'identifier_123', name: 'name_123', data: { test1: 'test value1', test2: 'test value2' }, }); const getTenantResult = await getTenant({ session, identifier: 'identifier_123' }); expect(getTenantResult.data.identifier).to.equal('identifier_123'); expect(getTenantResult.data.name).to.equal('name_123'); expect(getTenantResult.data.data).to.deep.equal({ test1: 'test value1', test2: 'test value2' }); }); it('should throw exception if tenant does not existing', async () => { const incorrectId = 'identifier_123'; try { await getTenant({ session, identifier: incorrectId }); throw new Error(''); } catch (e) { expect(e?.response?.data?.message || e?.message).to.contains( `Tenant with identifier: ${incorrectId} does not exist under environment ${session.environment._id}` ); } }); }); async function getTenant({ session, identifier }: { session; identifier: string }): Promise { const axiosInstance = axios.create(); return ( await axiosInstance.get(`${session.serverUrl}/v1/tenants/${identifier}`, { headers: { authorization: `ApiKey ${session.apiKey}`, }, }) ).data; } ================================================ FILE: apps/api/src/app/tenant/e2e/get-tenants.e2e.ts ================================================ import { TenantRepository } from '@novu/dal'; import { UserSession } from '@novu/testing'; import axios from 'axios'; import type { AxiosResponse } from 'axios'; import { expect } from 'chai'; describe('Get Tenants List- /tenants (GET) #novu-v0', () => { let session: UserSession; const tenantRepository = new TenantRepository(); beforeEach(async () => { session = new UserSession(); await session.initialize(); }); it('should get the newly created tenants', async () => { for (let i = 0; i < 5; i += 1) { await tenantRepository.create({ _organizationId: session.organization._id, _environmentId: session.environment._id, identifier: `identifier_${i}`, name: 'name_123', data: { test1: 'test value1', test2: 'test value2' }, }); await timeout(5); } const getTenantResult = await getTenants({ session }); const { data } = getTenantResult; expect(data.page).to.equal(0); expect(data.pageSize).to.equal(10); expect(data.hasMore).to.equal(false); expect(data.data.length).to.equal(5); expect(data.data[0].identifier).to.equal('identifier_4'); expect(data.data[4].identifier).to.equal('identifier_0'); }); it('should get second page of tenants', async () => { for (let i = 0; i < 9; i += 1) { await tenantRepository.create({ _environmentId: session.environment._id, _organizationId: session.organization._id, identifier: `identifier_${i}`, name: 'name_123', data: { test1: 'test value1', test2: 'test value2' }, }); await timeout(10); } const getTenantResult = await getTenants({ session, page: 1, limit: 5 }); const { data } = getTenantResult; expect(data.page).to.equal(1); expect(data.pageSize).to.equal(5); expect(data.hasMore).to.equal(false); expect(data.data.length).to.equal(4); expect(data.data[0].identifier).to.equal('identifier_3'); expect(data.data[3].identifier).to.equal('identifier_0'); }); it('should get tenants by pagination', async () => { for (let i = 0; i < 14; i += 1) { await tenantRepository.create({ _environmentId: session.environment._id, _organizationId: session.organization._id, identifier: `identifier_${i}`, name: 'name_123', data: { test1: 'test value1', test2: 'test value2' }, }); await timeout(5); } const page1 = (await getTenants({ session, page: 0, limit: 5 })).data; expect(page1.page).to.equal(0); expect(page1.pageSize).to.equal(5); expect(page1.hasMore).to.equal(true); expect(page1.data.length).to.equal(5); const page2 = (await getTenants({ session, page: 1, limit: 5 })).data; expect(page2.page).to.equal(1); expect(page2.pageSize).to.equal(5); expect(page2.hasMore).to.equal(true); expect(page2.data.length).to.equal(5); const page3 = (await getTenants({ session, page: 2, limit: 5 })).data; expect(page3.page).to.equal(2); expect(page3.pageSize).to.equal(5); expect(page3.hasMore).to.equal(false); expect(page3.data.length).to.equal(4); }); }); async function getTenants({ session, page, limit, }: { session; page?: number; limit?: number; }): Promise { const axiosInstance = axios.create(); const pageQuery = page ? `page=${page}` : ''; const limitQuery = limit ? `limit=${limit}` : ''; const queryParams = [pageQuery, limitQuery].filter((queryStr) => queryStr).join('&'); const query = queryParams ? `?${queryParams}` : ''; return await axiosInstance.get(`${session.serverUrl}/v1/tenants${query}`, { headers: { authorization: `ApiKey ${session.apiKey}`, }, }); } function timeout(ms) { return new Promise((resolve) => { setTimeout(resolve, ms); }); } ================================================ FILE: apps/api/src/app/tenant/e2e/update-tenant.e2e.ts ================================================ import { TenantRepository } from '@novu/dal'; import { UserSession } from '@novu/testing'; import axios from 'axios'; import type { AxiosResponse } from 'axios'; import { expect } from 'chai'; describe('Update Tenant - /tenants/:tenantId (PUT) #novu-v0', () => { let session: UserSession; const tenantRepository = new TenantRepository(); beforeEach(async () => { session = new UserSession(); await session.initialize(); }); it('should update tenant', async () => { await tenantRepository.create({ _organizationId: session.organization._id, _environmentId: session.environment._id, identifier: 'identifier_123', name: 'name_123', data: { test1: 'test value1', test2: 'test value2' }, }); const response = await updateTenant({ session, identifier: 'identifier_123', newIdentifier: 'newIdentifier', name: 'new_name', data: { test1: 'new value', test2: 'new value2' }, }); expect(response?.status).to.equal(200); const updatedTenant = await tenantRepository.findOne({ _environmentId: session.environment._id, identifier: 'newIdentifier', }); expect(updatedTenant?.name).to.equal('new_name'); expect(updatedTenant?.identifier).to.equal('newIdentifier'); expect(updatedTenant?.data).to.deep.equal({ test1: 'new value', test2: 'new value2' }); }); it('should not update identifier with null/undefined', async () => { await tenantRepository.create({ _organizationId: session.organization._id, _environmentId: session.environment._id, identifier: 'identifier_123', name: 'name_123', data: { test1: 'test value1', test2: 'test value2' }, }); await updateTenant({ session, identifier: 'identifier_123', newIdentifier: null, }); const tenantNotUpdatedWithNull = await tenantRepository.findOne({ _environmentId: session.environment._id, identifier: 'identifier_123', }); expect(tenantNotUpdatedWithNull?.identifier).to.equal('identifier_123'); await updateTenant({ session, identifier: 'identifier_123', newIdentifier: undefined, }); const tenantNotUpdatedWithUndefined = await tenantRepository.findOne({ _environmentId: session.environment._id, identifier: 'identifier_123', }); expect(tenantNotUpdatedWithUndefined?.identifier).to.equal('identifier_123'); }); it('should not be able to update to already existing identifier (in the same environment)', async () => { await tenantRepository.create({ _organizationId: session.organization._id, _environmentId: session.environment._id, identifier: 'identifier_123', }); await tenantRepository.create({ _organizationId: session.organization._id, _environmentId: session.environment._id, identifier: 'identifier_456', }); try { await updateTenant({ session, identifier: 'identifier_123', newIdentifier: 'identifier_456', }); expectedException(); } catch (e) { expect(e.response.status).to.equal(409); expect(e?.response?.data?.message || e?.message).to.contains( `Tenant with identifier: identifier_456 already exists under environment ${session.environment._id}` ); } }); it('should throw exception id tenant was not found under environment', async () => { await tenantRepository.create({ _organizationId: session.organization._id, _environmentId: session.environment._id, identifier: 'identifier_123', }); try { await updateTenant({ session, identifier: 'identifier_1234', }); expectedException(); } catch (e) { expect(e.response.status).to.equal(404); expect(e?.response?.data?.message || e?.message).to.contains( `Tenant with identifier: identifier_1234 does not exist under environment ${session.environment._id}` ); } }); }); const expectedException = () => { throw new Error('missing exception in the try/catch block'); }; export async function updateTenant({ session, identifier, newIdentifier, name, data, }: { session; identifier?: string; newIdentifier?: string | null | undefined; name?: string; data?: any; }): Promise { const axiosInstance = axios.create(); return await axiosInstance.patch( `${session.serverUrl}/v1/tenants/${identifier}`, { identifier: newIdentifier, name, data, }, { headers: { authorization: `ApiKey ${session.apiKey}`, }, } ); } ================================================ FILE: apps/api/src/app/tenant/tenant.controller.ts ================================================ import { Body, ClassSerializerInterceptor, Controller, Delete, Get, HttpCode, HttpStatus, MethodNotAllowedException, Param, Patch, Post, Query, UseInterceptors, } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiExcludeController } from '@nestjs/swagger/dist/decorators/api-exclude-controller.decorator'; import { CreateTenant, CreateTenantCommand, FeatureFlagsService, GetTenant, GetTenantCommand, UpdateTenant, UpdateTenantCommand, } from '@novu/application-generic'; import { EnvironmentEntity, OrganizationEntity, UserEntity } from '@novu/dal'; import { ApiRateLimitCategoryEnum, FeatureFlagsKeysEnum, UserSessionData } from '@novu/shared'; import { RequireAuthentication } from '../auth/framework/auth.decorator'; import { ExternalApiAccessible } from '../auth/framework/external-api.decorator'; import { ThrottlerCategory } from '../rate-limiting/guards'; import { PaginatedResponseDto } from '../shared/dtos/pagination-response'; import { ApiOkPaginatedResponse } from '../shared/framework/paginated-ok-response.decorator'; import { ApiCommonResponses, ApiConflictResponse, ApiNoContentResponse, ApiNotFoundResponse, ApiResponse, } from '../shared/framework/response.decorator'; import { SdkUsePagination } from '../shared/framework/swagger/sdk.decorators'; import { UserSession } from '../shared/framework/user.decorator'; import { CreateTenantRequestDto, CreateTenantResponseDto, GetTenantResponseDto, GetTenantsRequestDto, UpdateTenantRequestDto, UpdateTenantResponseDto, } from './dtos'; import { DeleteTenantCommand } from './usecases/delete-tenant/delete-tenant.command'; import { DeleteTenant } from './usecases/delete-tenant/delete-tenant.usecase'; import { GetTenantsCommand } from './usecases/get-tenants/get-tenants.command'; import { GetTenants } from './usecases/get-tenants/get-tenants.usecase'; const v2TenantsApiDescription = ' Tenants is not supported in code first version of the API.'; @ThrottlerCategory(ApiRateLimitCategoryEnum.CONFIGURATION) @ApiCommonResponses() @Controller('/tenants') @ApiTags('Tenants') @UseInterceptors(ClassSerializerInterceptor) @RequireAuthentication() @ApiExcludeController() export class TenantController { constructor( private createTenantUsecase: CreateTenant, private updateTenantUsecase: UpdateTenant, private getTenantUsecase: GetTenant, private deleteTenantUsecase: DeleteTenant, private getTenantsUsecase: GetTenants, private featureFlagService: FeatureFlagsService ) {} @Get('') @ExternalApiAccessible() @ApiOkPaginatedResponse(GetTenantResponseDto) @ApiOperation({ summary: 'Get tenants', description: `Returns a list of tenants, could paginated using the \`page\` and \`limit\` query parameter.${ v2TenantsApiDescription }`, }) @SdkUsePagination() async listTenants( @UserSession() user: UserSessionData, @Query() query: GetTenantsRequestDto ): Promise> { await this.verifyTenantsApiAvailability(user); return await this.getTenantsUsecase.execute( GetTenantsCommand.create({ organizationId: user.organizationId, environmentId: user.environmentId, page: query.page, limit: query.limit, }) ); } @Get('/:identifier') @ApiResponse(GetTenantResponseDto) @ApiOperation({ summary: 'Get tenant', description: `Get tenant by your internal id used to identify the tenant${v2TenantsApiDescription}`, }) @ApiNotFoundResponse({ description: 'The tenant with the identifier provided does not exist in the database.', }) @ExternalApiAccessible() async getTenantById( @UserSession() user: UserSessionData, @Param('identifier') identifier: string ): Promise { await this.verifyTenantsApiAvailability(user); return await this.getTenantUsecase.execute( GetTenantCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, identifier, }) ); } @Post('/') @ExternalApiAccessible() @ApiResponse(CreateTenantResponseDto) @ApiOperation({ summary: 'Create tenant', description: `Create tenant under the current environment${v2TenantsApiDescription}`, }) @ApiConflictResponse({ description: 'A tenant with the same identifier is already exist.', }) async createTenant( @UserSession() user: UserSessionData, @Body() body: CreateTenantRequestDto ): Promise { await this.verifyTenantsApiAvailability(user); return await this.createTenantUsecase.execute( CreateTenantCommand.create({ userId: user._id, environmentId: user.environmentId, organizationId: user.organizationId, identifier: body.identifier, name: body.name, data: body.data, }) ); } @Patch('/:identifier') @ExternalApiAccessible() @ApiResponse(UpdateTenantResponseDto) @ApiOperation({ summary: 'Update tenant', description: `Update tenant by your internal id used to identify the tenant${v2TenantsApiDescription}`, }) @ApiNotFoundResponse({ description: 'The tenant with the identifier provided does not exist in the database.', }) async updateTenant( @UserSession() user: UserSessionData, @Param('identifier') identifier: string, @Body() body: UpdateTenantRequestDto ): Promise { await this.verifyTenantsApiAvailability(user); return await this.updateTenantUsecase.execute( UpdateTenantCommand.create({ userId: user._id, identifier, environmentId: user.environmentId, organizationId: user.organizationId, name: body.name, data: body.data, newIdentifier: body.identifier, }) ); } @Delete('/:identifier') @ExternalApiAccessible() @ApiOperation({ summary: 'Delete tenant', description: `Deletes a tenant entity from the Novu platform.${v2TenantsApiDescription}`, }) @ApiNoContentResponse({ description: 'The tenant has been deleted correctly', }) @ApiNotFoundResponse({ description: 'The tenant with the identifier provided does not exist in the database so it can not be deleted.', }) @HttpCode(HttpStatus.NO_CONTENT) async removeTenant(@UserSession() user: UserSessionData, @Param('identifier') identifier: string): Promise { await this.verifyTenantsApiAvailability(user); return await this.deleteTenantUsecase.execute( DeleteTenantCommand.create({ userId: user._id, environmentId: user.environmentId, organizationId: user.organizationId, identifier, }) ); } private async verifyTenantsApiAvailability(user: UserSessionData) { const isV2Enabled = await this.featureFlagService.getFlag({ user: { _id: user._id } as UserEntity, environment: { _id: user.environmentId } as EnvironmentEntity, organization: { _id: user.organizationId } as OrganizationEntity, key: FeatureFlagsKeysEnum.IS_V2_ENABLED, defaultValue: false, }); if (!isV2Enabled) { return; } throw new MethodNotAllowedException(v2TenantsApiDescription.trim()); } } ================================================ FILE: apps/api/src/app/tenant/tenant.module.ts ================================================ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; import { SharedModule } from '../shared/shared.module'; import { TenantController } from './tenant.controller'; import { USE_CASES } from './usecases'; @Module({ imports: [SharedModule, AuthModule], controllers: [TenantController], providers: [...USE_CASES], exports: [...USE_CASES], }) export class TenantModule {} ================================================ FILE: apps/api/src/app/tenant/usecases/delete-tenant/delete-tenant.command.ts ================================================ import { EnvironmentWithUserCommand } from '@novu/application-generic'; import { IsNotEmpty, IsString } from 'class-validator'; export class DeleteTenantCommand extends EnvironmentWithUserCommand { @IsString() @IsNotEmpty() identifier: string; } ================================================ FILE: apps/api/src/app/tenant/usecases/delete-tenant/delete-tenant.usecase.ts ================================================ import { BadRequestException, Injectable } from '@nestjs/common'; import { GetTenant, GetTenantCommand } from '@novu/application-generic'; import { DalException, TenantRepository } from '@novu/dal'; import { DeleteTenantCommand } from './delete-tenant.command'; @Injectable() export class DeleteTenant { constructor( private tenantRepository: TenantRepository, private getTenantUsecase: GetTenant ) {} async execute(command: DeleteTenantCommand) { const tenant = await this.getTenantUsecase.execute( GetTenantCommand.create({ environmentId: command.environmentId, organizationId: command.organizationId, identifier: command.identifier, }) ); try { await this.tenantRepository.delete({ _environmentId: command.environmentId, _organizationId: command.organizationId, identifier: command.identifier, }); } catch (e) { if (e instanceof DalException) { throw new BadRequestException(e.message); } throw e; } } } ================================================ FILE: apps/api/src/app/tenant/usecases/get-tenants/get-tenants.command.ts ================================================ import { IsNumber, IsOptional } from 'class-validator'; import { EnvironmentCommand } from '../../../shared/commands/project.command'; export class GetTenantsCommand extends EnvironmentCommand { @IsNumber() @IsOptional() page: number; @IsNumber() @IsOptional() limit: number; } ================================================ FILE: apps/api/src/app/tenant/usecases/get-tenants/get-tenants.usecase.ts ================================================ import { Injectable } from '@nestjs/common'; import { TenantRepository } from '@novu/dal'; import { GetTenantsCommand } from './get-tenants.command'; @Injectable() export class GetTenants { constructor(private tenantRepository: TenantRepository) {} async execute(command: GetTenantsCommand) { const data = await this.getTenants(command); return { page: command.page, hasMore: data?.length === command.limit, pageSize: command.limit, data, }; } private async getTenants(command: GetTenantsCommand) { const data = await this.tenantRepository.find( { _environmentId: command.environmentId, _organizationId: command.organizationId, }, '', { limit: command.limit, skip: command.page * command.limit, sort: { createdAt: -1 }, } ); return data; } } ================================================ FILE: apps/api/src/app/tenant/usecases/index.ts ================================================ import { CreateTenant, GetTenant, UpdateTenant } from '@novu/application-generic'; import { DeleteTenant } from './delete-tenant/delete-tenant.usecase'; import { GetTenants } from './get-tenants/get-tenants.usecase'; export const USE_CASES = [CreateTenant, GetTenant, UpdateTenant, DeleteTenant, GetTenants]; ================================================ FILE: apps/api/src/app/testing/auth.controller.ts ================================================ import { Controller, Get } from '@nestjs/common'; import { ApiExcludeController } from '@nestjs/swagger'; import { RequirePermissions, SkipPermissionsCheck } from '@novu/application-generic'; import { PermissionsEnum } from '@novu/shared'; import { RequireAuthentication } from '../auth/framework/auth.decorator'; import { ExternalApiAccessible } from '../auth/framework/external-api.decorator'; @Controller('/test-auth') @RequireAuthentication() @ApiExcludeController() export class TestApiAuthController { @ExternalApiAccessible() @Get('/user-route') userRoute() { return true; } @Get('/user-api-inaccessible-route') userInaccessibleRoute() { return true; } @RequirePermissions(PermissionsEnum.INTEGRATION_WRITE, PermissionsEnum.WORKFLOW_WRITE) @ExternalApiAccessible() @Get('/permission-route') permissionRoute() { return true; } @SkipPermissionsCheck() @Get('/no-permission-route') noPermissionRoute() { return true; } @Get('/all-permissions-route') allPermissionsRoute() { return true; } } ================================================ FILE: apps/api/src/app/testing/dtos/idempotency.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; export enum IdempotencyBehaviorEnum { IMMEDIATE_RESPONSE = 'IMMEDIATE_RESPONSE', IMMEDIATE_EXCEPTION = 'IMMEDIATE_EXCEPTION', DELAYED_RESPONSE = 'DELAYED_RESPONSE', } export class IdempotencyTestingDto { @ApiProperty({ enum: Object.values(IdempotencyBehaviorEnum), description: 'The expected behavior of the idempotency request', enumName: 'IdempotencyBehaviorEnum', }) expectedBehavior: IdempotencyBehaviorEnum; } export class IdempotenceTestingResponse { @ApiProperty({ description: 'A unique number representing the idempotency response', example: 1, // Example value for better understanding }) number: number; } ================================================ FILE: apps/api/src/app/testing/product-feature.e2e.ts ================================================ import { CommunityOrganizationRepository } from '@novu/dal'; import { ApiServiceLevelEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; describe('Product feature Test #novu-v0-os', async () => { let session: UserSession; const path = '/v1/testing/product-feature'; let organizationRepository: CommunityOrganizationRepository; beforeEach(async () => { session = new UserSession(); await session.initialize(); organizationRepository = new CommunityOrganizationRepository(); }); it('should return a number as response when required api service level exists on organization for feature', async () => { await organizationRepository.update( { _id: session.organization._id }, { apiServiceLevel: ApiServiceLevelEnum.BUSINESS, } ); const { body } = await session.testAgent.get(path).set('authorization', `ApiKey ${session.apiKey}`).expect(200); expect(typeof body.data.number === 'number').to.be.true; }); it('should return a 402 response when required api service level does not exists on organization for feature', async () => { const { body } = await session.testAgent.get(path).set('authorization', `ApiKey ${session.apiKey}`).expect(402); expect(body.statusCode).to.equal(402); expect(body.message).to.equal('Payment Required'); }); }); ================================================ FILE: apps/api/src/app/testing/rate-limiting.controller.ts ================================================ import { Controller, Get } from '@nestjs/common'; import { ApiExcludeController } from '@nestjs/swagger'; import { ApiRateLimitCategoryEnum, ApiRateLimitCostEnum } from '@novu/shared'; import { RequireAuthentication } from '../auth/framework/auth.decorator'; import { ExternalApiAccessible } from '../auth/framework/external-api.decorator'; import { ThrottlerCategory, ThrottlerCost } from '../rate-limiting/guards'; @Controller('/rate-limiting') @RequireAuthentication() @ApiExcludeController() export class TestApiRateLimitController { @ExternalApiAccessible() @Get('/no-category-no-cost') noCategoryNoCost() { return true; } @ExternalApiAccessible() @ThrottlerCost(ApiRateLimitCostEnum.SINGLE) @Get('/no-category-single-cost') noCategorySingleCost() { return true; } @ExternalApiAccessible() @ThrottlerCategory(ApiRateLimitCategoryEnum.GLOBAL) @Get('/global-category-no-cost') globalCategoryNoCost() { return true; } @ExternalApiAccessible() @ThrottlerCategory(ApiRateLimitCategoryEnum.GLOBAL) @ThrottlerCost(ApiRateLimitCostEnum.SINGLE) @Get('/global-category-single-cost') globalCategorySingleCost() { return true; } @ExternalApiAccessible() @ThrottlerCategory(ApiRateLimitCategoryEnum.GLOBAL) @ThrottlerCost(ApiRateLimitCostEnum.BULK) @Get('/global-category-bulk-cost') global() { return true; } @ExternalApiAccessible() @ThrottlerCategory(ApiRateLimitCategoryEnum.TRIGGER) @Get('/trigger-category-no-cost') triggerCategoryNoCost() { return true; } @ExternalApiAccessible() @ThrottlerCategory(ApiRateLimitCategoryEnum.TRIGGER) @ThrottlerCost(ApiRateLimitCostEnum.SINGLE) @Get('/trigger-category-single-cost') triggerCategorySingleCost() { return true; } @ExternalApiAccessible() @ThrottlerCategory(ApiRateLimitCategoryEnum.TRIGGER) @ThrottlerCost(ApiRateLimitCostEnum.BULK) @Get('/trigger-category-bulk-cost') triggerCategoryBulkCost() { return true; } } @ApiExcludeController() @Controller('/rate-limiting-trigger-bulk') @RequireAuthentication() @ThrottlerCategory(ApiRateLimitCategoryEnum.TRIGGER) @ThrottlerCost(ApiRateLimitCostEnum.BULK) export class TestApiRateLimitBulkController { @ExternalApiAccessible() @Get('/no-category-no-cost-override') noCategoryNoCostOverride() { return true; } @ExternalApiAccessible() @ThrottlerCost(ApiRateLimitCostEnum.SINGLE) @Get('/no-category-single-cost-override') noCategorySingleCostOverride() { return true; } @ExternalApiAccessible() @Get('/global-category-no-cost-override') @ThrottlerCategory(ApiRateLimitCategoryEnum.GLOBAL) globalCategoryNoCostOverride() { return true; } } ================================================ FILE: apps/api/src/app/testing/testing.controller.ts ================================================ import { Controller, Get, NotFoundException } from '@nestjs/common'; import { ApiExcludeController } from '@nestjs/swagger'; import { ProductFeature, ResourceCategory } from '@novu/application-generic'; import { ProductFeatureKeyEnum, ResourceEnum } from '@novu/shared'; import { RequireAuthentication } from '../auth/framework/auth.decorator'; import { ExternalApiAccessible } from '../auth/framework/external-api.decorator'; @Controller('/testing') @RequireAuthentication() @ApiExcludeController() export class TestingController { @ExternalApiAccessible() @Get('/product-feature') @ProductFeature(ProductFeatureKeyEnum.TRANSLATIONS) async productFeatureGet(): Promise<{ number: number }> { if (process.env.NODE_ENV !== 'test') throw new NotFoundException(); return { number: Math.random() }; } @ExternalApiAccessible() @Get('/resource-limiting-default') async resourceLimitingDefaultGet(): Promise<{ number: number }> { if (process.env.NODE_ENV !== 'test') throw new NotFoundException(); return { number: Math.random() }; } @ExternalApiAccessible() @Get('/resource-limiting-events') @ResourceCategory(ResourceEnum.EVENTS) async resourceLimitingEventsGet(): Promise<{ number: number }> { if (process.env.NODE_ENV !== 'test') throw new NotFoundException(); return { number: Math.random() }; } } ================================================ FILE: apps/api/src/app/testing/testing.module.ts ================================================ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; import { RateLimitingModule } from '../rate-limiting/rate-limiting.module'; import { SharedModule } from '../shared/shared.module'; import { TestApiAuthController } from './auth.controller'; import { TestApiRateLimitBulkController, TestApiRateLimitController } from './rate-limiting.controller'; import { TestingController } from './testing.controller'; @Module({ imports: [SharedModule, AuthModule, RateLimitingModule], controllers: [TestingController, TestApiRateLimitController, TestApiRateLimitBulkController, TestApiAuthController], }) export class TestingModule {} ================================================ FILE: apps/api/src/app/topics-v1/dtos/add-subscribers.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { IsArray, IsDefined } from 'class-validator'; import { ExternalSubscriberId } from '../types'; export class AddSubscribersRequestDto { @ApiProperty({ description: 'List of subscriber identifiers that will be associated to the topic', }) @IsArray() @IsDefined() subscribers: string[]; } ================================================ FILE: apps/api/src/app/topics-v1/dtos/assignSubscriberToTopicDto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { ExternalSubscriberId } from '@novu/shared'; export class FailedAssignmentsDto { @ApiProperty({ description: 'List of subscriber IDs that were not found', type: [String], required: false, }) notFound?: ExternalSubscriberId[]; } export class AssignSubscriberToTopicDto { @ApiProperty({ description: 'List of successfully assigned subscriber IDs', type: [String], }) succeeded: ExternalSubscriberId[]; @ApiProperty({ description: 'Details about failed assignments', required: false, type: () => FailedAssignmentsDto, }) failed?: FailedAssignmentsDto; } ================================================ FILE: apps/api/src/app/topics-v1/dtos/create-topic.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsDefined, IsString } from 'class-validator'; export class CreateTopicResponseDto { @ApiPropertyOptional({ description: 'The unique identifier for the Topic created.', }) _id: string; @ApiProperty({ description: 'User defined custom key and provided by the user that will be an unique identifier for the Topic created.', }) key: string; } export class CreateTopicRequestDto { @ApiProperty({ description: 'User defined custom key and provided by the user that will be an unique identifier for the Topic created.', }) @IsString() @IsDefined() key: string; @ApiProperty({ description: 'User defined custom name and provided by the user that will name the Topic created.', }) @IsString() @IsDefined() name: string; } ================================================ FILE: apps/api/src/app/topics-v1/dtos/filter-topics.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsOptional, IsString } from 'class-validator'; import { TopicDto } from './topic.dto'; export class FilterTopicsRequestDto { @ApiProperty({ example: 0, required: false, type: 'integer', format: 'int64', description: 'The page number to retrieve (starts from 0)', }) @IsOptional() @Transform(({ value }) => Number(value)) // Convert string to integer public page?: number = 0; @ApiProperty({ example: 10, required: false, type: 'integer', format: 'int64', description: 'The number of items to return per page (default: 10)', }) @IsOptional() @Transform(({ value }) => Number(value)) // Convert string to integer public pageSize?: number = 10; @ApiPropertyOptional({ example: 'exampleKey', type: 'string', description: 'A filter key to apply to the results', }) @IsString() @IsOptional() public key?: string; @ApiPropertyOptional({ example: 'Example Topic', type: 'string', description: 'A filter name to apply to the results', }) @IsString() @IsOptional() public name?: string; } export class FilterTopicsResponseDto { @ApiProperty({ example: [], type: [TopicDto], description: 'The list of topics', }) data: TopicDto[]; @ApiProperty({ example: 1, type: Number, description: 'The current page number', }) page: number; @ApiProperty({ example: 10, type: Number, description: 'The number of items per page', }) pageSize: number; @ApiProperty({ example: 10, type: Number, description: 'The total number of items', }) totalCount: number; } ================================================ FILE: apps/api/src/app/topics-v1/dtos/get-topic.dto.ts ================================================ import { TopicDto } from './topic.dto'; export class GetTopicResponseDto extends TopicDto {} ================================================ FILE: apps/api/src/app/topics-v1/dtos/index.ts ================================================ export * from './add-subscribers.dto'; export * from './create-topic.dto'; export * from './filter-topics.dto'; export * from './get-topic.dto'; export * from './remove-subscribers.dto'; export * from './rename-topic.dto'; export * from './topic.dto'; export * from './topic-subscriber.dto'; ================================================ FILE: apps/api/src/app/topics-v1/dtos/remove-subscribers.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { IsArray, IsDefined } from 'class-validator'; import { ExternalSubscriberId } from '../types'; export class RemoveSubscribersRequestDto { @ApiProperty({ description: 'List of subscriber identifiers that will be removed to the topic', }) @IsArray() @IsDefined() subscribers: string[]; } ================================================ FILE: apps/api/src/app/topics-v1/dtos/rename-topic.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { IsDefined, IsString } from 'class-validator'; import { TopicDto } from './topic.dto'; export class RenameTopicResponseDto extends TopicDto {} export class RenameTopicRequestDto { @ApiProperty({ description: 'User defined custom name and provided by the user to rename the topic.', }) @IsString() @IsDefined() name: string; } ================================================ FILE: apps/api/src/app/topics-v1/dtos/topic-subscriber.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { ITopicSubscriber } from '@novu/shared'; export class TopicSubscriberDto implements ITopicSubscriber { @ApiProperty({ description: 'Unique identifier for the organization', example: 'org_123456789', }) _organizationId: string; @ApiProperty({ description: 'Unique identifier for the environment', example: 'env_123456789', }) _environmentId: string; @ApiProperty({ description: 'Unique identifier for the subscriber', example: 'sub_123456789', }) _subscriberId: string; @ApiProperty({ description: 'Unique identifier for the topic', example: 'topic_123456789', }) _topicId: string; @ApiProperty({ description: 'Key associated with the topic', example: 'my_topic_key', }) topicKey: string; @ApiProperty({ description: 'External identifier for the subscriber', example: 'external_subscriber_123', }) externalSubscriberId: string; } ================================================ FILE: apps/api/src/app/topics-v1/dtos/topic.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsOptional, IsString } from 'class-validator'; export class TopicDto { @ApiPropertyOptional() _id: string; @ApiProperty() _organizationId: string; @ApiProperty() _environmentId: string; @ApiProperty() key: string; @ApiPropertyOptional() @IsString() @IsOptional() name?: string; @ApiProperty() subscribers: string[]; @ApiPropertyOptional() @IsString() @IsOptional() createdAt?: string; @ApiPropertyOptional() @IsString() @IsOptional() updatedAt?: string; } ================================================ FILE: apps/api/src/app/topics-v1/topics-v1.controller.ts ================================================ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Query } from '@nestjs/common'; import { ApiExcludeEndpoint, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger'; import { ApiRateLimitCategoryEnum, ExternalSubscriberId, TopicKey, UserSessionData } from '@novu/shared'; import { RequireAuthentication } from '../auth/framework/auth.decorator'; import { ExternalApiAccessible } from '../auth/framework/external-api.decorator'; import { ThrottlerCategory } from '../rate-limiting/guards'; import { ApiCommonResponses, ApiNoContentResponse, ApiOkResponse, ApiResponse, } from '../shared/framework/response.decorator'; import { SdkGroupName, SdkMethodName } from '../shared/framework/swagger/sdk.decorators'; import { UserSession } from '../shared/framework/user.decorator'; import { AddSubscribersRequestDto, CreateTopicRequestDto, CreateTopicResponseDto, FilterTopicsRequestDto, FilterTopicsResponseDto, GetTopicResponseDto, RemoveSubscribersRequestDto, RenameTopicRequestDto, RenameTopicResponseDto, TopicSubscriberDto, } from './dtos'; import { AssignSubscriberToTopicDto } from './dtos/assignSubscriberToTopicDto'; import { AddSubscribersCommand, AddSubscribersUseCase, CreateTopicCommand, CreateTopicUseCase, DeleteTopicCommand, DeleteTopicUseCase, FilterTopicsCommand, FilterTopicsUseCase, GetTopicCommand, GetTopicSubscriberCommand, GetTopicSubscriberUseCase, GetTopicUseCase, RemoveSubscribersCommand, RemoveSubscribersUseCase, RenameTopicCommand, RenameTopicUseCase, } from './use-cases'; @ThrottlerCategory(ApiRateLimitCategoryEnum.CONFIGURATION) @ApiCommonResponses() @Controller('/topics') @RequireAuthentication() @ApiTags('Topics') export class TopicsV1Controller { constructor( private addSubscribersUseCase: AddSubscribersUseCase, private createTopicUseCase: CreateTopicUseCase, private deleteTopicUseCase: DeleteTopicUseCase, private filterTopicsUseCase: FilterTopicsUseCase, private getTopicSubscriberUseCase: GetTopicSubscriberUseCase, private getTopicUseCase: GetTopicUseCase, private removeSubscribersUseCase: RemoveSubscribersUseCase, private renameTopicUseCase: RenameTopicUseCase ) {} @Post('') @ExternalApiAccessible() @ApiExcludeEndpoint() @ApiResponse(CreateTopicResponseDto, 201) @ApiOperation({ summary: 'Topic creation', description: 'Create a topic' }) async createTopic( @UserSession() user: UserSessionData, @Body() body: CreateTopicRequestDto ): Promise { const topic = await this.createTopicUseCase.execute( CreateTopicCommand.create({ environmentId: user.environmentId, key: body.key, name: body.name, organizationId: user.organizationId, userId: user._id, }) ); return { _id: topic._id, key: topic.key, }; } @Post('/:topicKey/subscribers') @ExternalApiAccessible() @ApiExcludeEndpoint() @HttpCode(HttpStatus.OK) @ApiOkResponse({ type: AssignSubscriberToTopicDto }) @ApiOperation({ summary: 'Subscribers addition', description: 'Add subscribers to a topic by key' }) @ApiParam({ name: 'topicKey', description: 'The topic key', type: String, required: true }) @SdkGroupName('Topics.Subscribers') @SdkMethodName('assign') async assign( @UserSession() user: UserSessionData, @Param('topicKey') topicKey: TopicKey, @Body() body: AddSubscribersRequestDto ): Promise { const { existingExternalSubscribers, nonExistingExternalSubscribers } = await this.addSubscribersUseCase.execute( AddSubscribersCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, subscribers: body.subscribers, userId: user._id, topicKey, }) ); return { succeeded: existingExternalSubscribers, ...(nonExistingExternalSubscribers.length > 0 && { failed: { notFound: nonExistingExternalSubscribers, }, }), }; } @Get('/:topicKey/subscribers/:externalSubscriberId') @ExternalApiAccessible() @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Check topic subscriber', description: 'Check if a subscriber belongs to a certain topic' }) @ApiParam({ name: 'topicKey', description: 'The topic key', type: String, required: true }) @ApiParam({ name: 'externalSubscriberId', description: 'The external subscriber id', type: String, required: true }) @SdkGroupName('Topics.Subscribers') @ApiOkResponse({ type: TopicSubscriberDto }) async getTopicSubscriber( @UserSession() user: UserSessionData, @Param('topicKey') topicKey: TopicKey, @Param('externalSubscriberId') externalSubscriberId: ExternalSubscriberId ): Promise { return await this.getTopicSubscriberUseCase.execute( GetTopicSubscriberCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, externalSubscriberId, topicKey, }) ); } @Post('/:topicKey/subscribers/removal') @ExternalApiAccessible() @ApiExcludeEndpoint() @ApiNoContentResponse() @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Subscribers removal', description: 'Remove subscribers from a topic' }) @ApiParam({ name: 'topicKey', description: 'The topic key', type: String, required: true }) @SdkGroupName('Topics.Subscribers') @SdkMethodName('remove') async removeSubscribers( @UserSession() user: UserSessionData, @Param('topicKey') topicKey: TopicKey, @Body() body: RemoveSubscribersRequestDto ): Promise { await this.removeSubscribersUseCase.execute( RemoveSubscribersCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, topicKey, subscribers: body.subscribers, }) ); } @Get('') @ExternalApiAccessible() @ApiOkResponse({ type: FilterTopicsResponseDto, }) @ApiOperation({ summary: 'Get topic list filtered ', description: 'Returns a list of topics that can be paginated using the `page` query ' + 'parameter and filtered by the topic key with the `key` query parameter or by the topic name with the `name` query parameter', }) @ApiExcludeEndpoint() async listTopics( @UserSession() user: UserSessionData, @Query() query?: FilterTopicsRequestDto ): Promise { return await this.filterTopicsUseCase.execute( FilterTopicsCommand.create({ environmentId: user.environmentId, key: query?.key, name: query?.name, organizationId: user.organizationId, page: query?.page, pageSize: query?.pageSize, }) ); } @Delete('/:topicKey') @ApiExcludeEndpoint() @ExternalApiAccessible() @ApiNoContentResponse({ description: 'The topic has been deleted correctly', }) @ApiParam({ name: 'topicKey', description: 'The topic key', type: String, required: true }) @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Delete topic', description: 'Delete a topic by its topic key if it has no subscribers' }) async deleteTopic(@UserSession() user: UserSessionData, @Param('topicKey') topicKey: TopicKey): Promise { await this.deleteTopicUseCase.execute( DeleteTopicCommand.create({ environmentId: user.environmentId, topicKey, organizationId: user.organizationId, }) ); } @Get('/:topicKey') @ApiExcludeEndpoint() @ExternalApiAccessible() @ApiResponse(GetTopicResponseDto) @ApiOperation({ summary: 'Get topic', description: 'Get a topic by its topic key' }) @ApiParam({ name: 'topicKey', description: 'The topic key', type: String, required: true }) async getTopic( @UserSession() user: UserSessionData, @Param('topicKey') topicKey: TopicKey ): Promise { return await this.getTopicUseCase.execute( GetTopicCommand.create({ environmentId: user.environmentId, topicKey, organizationId: user.organizationId, }) ); } @Patch('/:topicKey') @ApiExcludeEndpoint() @ExternalApiAccessible() @ApiResponse(RenameTopicResponseDto) @ApiOperation({ summary: 'Rename a topic', description: 'Rename a topic by providing a new name' }) @ApiParam({ name: 'topicKey', description: 'The topic key', type: String, required: true }) @SdkMethodName('rename') async renameTopic( @UserSession() user: UserSessionData, @Param('topicKey') topicKey: TopicKey, @Body() body: RenameTopicRequestDto ): Promise { return await this.renameTopicUseCase.execute( RenameTopicCommand.create({ environmentId: user.environmentId, topicKey, name: body.name, organizationId: user.organizationId, }) ); } } ================================================ FILE: apps/api/src/app/topics-v1/topics-v1.module.ts ================================================ import { Module } from '@nestjs/common'; import { StorageHelperService } from '@novu/application-generic'; import { CommunityOrganizationRepository } from '@novu/dal'; import { AuthModule } from '../auth/auth.module'; import { SharedModule } from '../shared/shared.module'; import { SubscribersV1Module } from '../subscribers/subscribersV1.module'; import { TopicsV1Controller } from './topics-v1.controller'; import { USE_CASES } from './use-cases'; @Module({ imports: [SharedModule, AuthModule, SubscribersV1Module], providers: [...USE_CASES, StorageHelperService, CommunityOrganizationRepository], exports: [...USE_CASES], controllers: [TopicsV1Controller], }) export class TopicsV1Module {} ================================================ FILE: apps/api/src/app/topics-v1/types/index.ts ================================================ export { EnvironmentId, ExternalSubscriberId, OrganizationId, SubscriberId, TopicId, TopicKey, TopicName, UserId, } from '@novu/shared'; ================================================ FILE: apps/api/src/app/topics-v1/use-cases/add-subscribers/add-subscribers.command.ts ================================================ import { IsArray, IsDefined, IsString } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; import { ExternalSubscriberId, TopicKey } from '../../types'; export class AddSubscribersCommand extends EnvironmentWithUserCommand { @IsString() @IsDefined() topicKey: TopicKey; @IsArray() @IsDefined() subscribers: ExternalSubscriberId[]; } ================================================ FILE: apps/api/src/app/topics-v1/use-cases/add-subscribers/add-subscribers.use-case.ts ================================================ import { Injectable } from '@nestjs/common'; import { buildDefaultSubscriptionIdentifier } from '@novu/application-generic'; import { CreateTopicSubscribersEntity, TopicEntity, TopicRepository, TopicSubscribersRepository } from '@novu/dal'; import { SubscriberDto } from '@novu/shared'; import { SearchByExternalSubscriberIds, SearchByExternalSubscriberIdsCommand } from '../../../subscribers/usecases'; import { ExternalSubscriberId } from '../../types'; import { CreateTopicCommand, CreateTopicUseCase } from '../create-topic'; import { AddSubscribersCommand } from './add-subscribers.command'; interface ISubscriberGroups { existingExternalSubscribers: ExternalSubscriberId[]; nonExistingExternalSubscribers: ExternalSubscriberId[]; subscribersAvailableToAdd: SubscriberDto[]; } @Injectable() export class AddSubscribersUseCase { constructor( private createTopic: CreateTopicUseCase, private searchByExternalSubscriberIds: SearchByExternalSubscriberIds, private topicSubscribersRepository: TopicSubscribersRepository, private topicRepository: TopicRepository ) {} async execute(command: AddSubscribersCommand): Promise> { let topic = await this.topicRepository.findTopicByKey( command.topicKey, command.organizationId, command.environmentId ); if (!topic) { const createTopicCommand = CreateTopicCommand.create({ environmentId: command.environmentId, key: command.topicKey, // TODO: Maybe make more clear that is a provisional name name: `Autogenerated-${command.topicKey}`, organizationId: command.organizationId, userId: command.userId, }); topic = await this.createTopic.execute(createTopicCommand); } const { existingExternalSubscribers, nonExistingExternalSubscribers, subscribersAvailableToAdd } = await this.filterExistingSubscribers(command); if (subscribersAvailableToAdd.length > 0) { const topicSubscribers = this.mapSubscribersToTopic(topic, subscribersAvailableToAdd); await this.topicSubscribersRepository.createSubscriptions(topicSubscribers); } return { existingExternalSubscribers, nonExistingExternalSubscribers, }; } private async filterExistingSubscribers(command: AddSubscribersCommand): Promise { const searchByExternalSubscriberIdsCommand = SearchByExternalSubscriberIdsCommand.create({ environmentId: command.environmentId, organizationId: command.organizationId, externalSubscriberIds: command.subscribers, }); const foundSubscribers = await this.searchByExternalSubscriberIds.execute(searchByExternalSubscriberIdsCommand); return this.groupSubscribersIfBelonging(command.subscribers, foundSubscribers); } /** * Time complexity: 0(n) */ private groupSubscribersIfBelonging( subscribers: ExternalSubscriberId[], foundSubscribers: SubscriberDto[] ): ISubscriberGroups { const subscribersList = new Set(subscribers); const subscribersAvailableToAdd = new Set(); const existingExternalSubscribersList = new Set(); for (const foundSubscriber of foundSubscribers) { existingExternalSubscribersList.add(foundSubscriber.subscriberId); subscribersList.delete(foundSubscriber.subscriberId); subscribersAvailableToAdd.add(foundSubscriber); } return { existingExternalSubscribers: Array.from(existingExternalSubscribersList), nonExistingExternalSubscribers: Array.from(subscribersList), subscribersAvailableToAdd: Array.from(subscribersAvailableToAdd), }; } private mapSubscribersToTopic(topic: TopicEntity, subscribers: SubscriberDto[]): CreateTopicSubscribersEntity[] { return subscribers.map((subscriber) => ({ _environmentId: subscriber._environmentId, _organizationId: subscriber._organizationId, _subscriberId: subscriber._id, _topicId: topic._id, topicKey: topic.key, externalSubscriberId: subscriber.subscriberId, identifier: buildDefaultSubscriptionIdentifier(topic.key, subscriber.subscriberId, undefined), })); } } ================================================ FILE: apps/api/src/app/topics-v1/use-cases/add-subscribers/index.ts ================================================ export * from './add-subscribers.command'; export * from './add-subscribers.use-case'; ================================================ FILE: apps/api/src/app/topics-v1/use-cases/create-topic/create-topic.command.ts ================================================ import { Transform } from 'class-transformer'; import { IsDefined, IsString } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; import { TopicKey, TopicName } from '../../types'; export class CreateTopicCommand extends EnvironmentWithUserCommand { @IsString() @IsDefined() @Transform(({ value }) => value.trim()) key: TopicKey; @IsString() @IsDefined() @Transform(({ value }) => value.trim()) name: TopicName; } ================================================ FILE: apps/api/src/app/topics-v1/use-cases/create-topic/create-topic.use-case.ts ================================================ import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'; import { FeatureFlagsService, PinoLogger } from '@novu/application-generic'; import { CommunityOrganizationRepository, EnvironmentEntity, EnvironmentRepository, OrganizationEntity, TopicEntity, TopicRepository, UserEntity, } from '@novu/dal'; import { FeatureFlagsKeysEnum, VALID_ID_REGEX } from '@novu/shared'; import { TopicDto } from '../../dtos/topic.dto'; import { CreateTopicCommand } from './create-topic.command'; @Injectable() export class CreateTopicUseCase { constructor( private topicRepository: TopicRepository, private featureFlagService: FeatureFlagsService, private environmentRepository: EnvironmentRepository, private communityOrganizationRepository: CommunityOrganizationRepository, private logger: PinoLogger ) { this.logger.setContext(this.constructor.name); } async execute(command: CreateTopicCommand) { const entity = this.mapToEntity(command); const [environment, organization] = await Promise.all([ this.environmentRepository.findOne({ _id: command.environmentId }), this.communityOrganizationRepository.findOne({ _id: command.organizationId }), ]); if (!organization) { throw new BadRequestException('Organization not found'); } if (!environment) { throw new BadRequestException('Environment not found'); } const topicExists = await this.topicRepository.findTopicByKey( entity.key, entity._organizationId, entity._environmentId ); if (topicExists) { throw new ConflictException( `Topic exists with key ${entity.key} in the environment ${entity._environmentId} of the organization ${entity._organizationId}` ); } await this.validateTopicKey({ environment, organization, userId: command.userId, key: entity.key, }); const topic = await this.topicRepository.createTopic(entity); return this.mapFromEntity(topic); } private mapToEntity(domainEntity: CreateTopicCommand): Omit { return { _environmentId: domainEntity.environmentId, _organizationId: domainEntity.organizationId, key: domainEntity.key, name: domainEntity.name, }; } private mapFromEntity(topic: TopicEntity): TopicDto { return { ...topic, _id: topic._id, _organizationId: topic._organizationId, _environmentId: topic._environmentId, subscribers: [], }; } private isValidTopicKey(key: string): boolean { return key.length > 0 && key.match(VALID_ID_REGEX) !== null; } private async validateTopicKey({ key, userId, environment, organization, }: { key: string; environment?: EnvironmentEntity; organization?: OrganizationEntity; userId: string; }): Promise { const isDryRun = await this.featureFlagService.getFlag({ environment, organization, user: { _id: userId } as UserEntity, key: FeatureFlagsKeysEnum.IS_TOPIC_KEYS_VALIDATION_DRY_RUN_ENABLED, defaultValue: true, }); if (this.isValidTopicKey(key)) { return; } if (isDryRun) { this.logger.warn(`[Dry run] Invalid topic key: ${key}`); } else { throw new BadRequestException( `Invalid topic key: "${key}". Topic keys must contain only alphanumeric characters (a-z, A-Z, 0-9), hyphens (-), underscores (_), colons (:), or be a valid email address.` ); } } } ================================================ FILE: apps/api/src/app/topics-v1/use-cases/create-topic/index.ts ================================================ export * from './create-topic.command'; export * from './create-topic.use-case'; ================================================ FILE: apps/api/src/app/topics-v1/use-cases/delete-topic/delete-topic.command.ts ================================================ import { IsDefined, IsString } from 'class-validator'; import { EnvironmentCommand } from '../../../shared/commands/project.command'; import { TopicKey } from '../../types'; export class DeleteTopicCommand extends EnvironmentCommand { @IsString() @IsDefined() topicKey: TopicKey; } ================================================ FILE: apps/api/src/app/topics-v1/use-cases/delete-topic/delete-topic.use-case.ts ================================================ import { ConflictException, Injectable } from '@nestjs/common'; import { TopicRepository } from '@novu/dal'; import { GetTopicUseCase } from '../get-topic'; import { DeleteTopicCommand } from './delete-topic.command'; @Injectable() export class DeleteTopicUseCase { constructor( private getTopicUseCase: GetTopicUseCase, private topicRepository: TopicRepository ) {} async execute(command: DeleteTopicCommand): Promise { const topic = await this.getTopicUseCase.execute(command); const { subscribers } = topic; if (subscribers?.length !== 0) { throw new ConflictException( `Topic with key ${command.topicKey} in the environment ${command.environmentId} can't be deleted as it still has subscribers assigned` ); } await this.topicRepository.deleteTopic(command.topicKey, command.environmentId, command.organizationId); } } ================================================ FILE: apps/api/src/app/topics-v1/use-cases/delete-topic/index.ts ================================================ export * from './delete-topic.command'; export * from './delete-topic.use-case'; ================================================ FILE: apps/api/src/app/topics-v1/use-cases/filter-topics/filter-topics.command.ts ================================================ import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; import { EnvironmentCommand } from '../../../shared/commands/project.command'; import { TopicKey } from '../../types'; export class FilterTopicsCommand extends EnvironmentCommand { @IsString() @IsOptional() key?: TopicKey; @IsString() @IsOptional() name?: string; @IsOptional() @IsInt() @Min(0) page?: number = 0; @IsOptional() @IsInt() @Min(0) @Max(10) pageSize?: number = 10; } ================================================ FILE: apps/api/src/app/topics-v1/use-cases/filter-topics/filter-topics.use-case.ts ================================================ import { BadRequestException, Injectable } from '@nestjs/common'; import { TopicEntity, TopicRepository } from '@novu/dal'; import { TopicDto } from '../../dtos/topic.dto'; import { ExternalSubscriberId } from '../../types'; import { FilterTopicsCommand } from './filter-topics.command'; const DEFAULT_TOPIC_LIMIT = 10; @Injectable() export class FilterTopicsUseCase { constructor(private topicRepository: TopicRepository) {} async execute(command: FilterTopicsCommand) { const { pageSize = DEFAULT_TOPIC_LIMIT, page = 0 } = command; if (pageSize > DEFAULT_TOPIC_LIMIT) { throw new BadRequestException(`Page size can not be larger then ${DEFAULT_TOPIC_LIMIT}`); } const query = this.mapFromCommandToEntity(command); const totalCount = await this.topicRepository.count(query); const skipTimes = page <= 0 ? 0 : page; const pagination = { limit: pageSize, skip: skipTimes * pageSize, }; const filteredTopics = await this.topicRepository.filterTopics(query, pagination); return { page, totalCount, pageSize, data: filteredTopics.map(this.mapFromEntityToDto), }; } private mapFromCommandToEntity( command: FilterTopicsCommand ): Pick { return { _environmentId: command.environmentId, _organizationId: command.organizationId, ...(command.key && { key: command.key }), ...(command.name && { name: command.name }), } as Pick; } private mapFromEntityToDto(topic: TopicEntity & { subscribers: ExternalSubscriberId[] }): TopicDto { return { ...topic, _id: topic._id, _organizationId: topic._organizationId, _environmentId: topic._environmentId, }; } } ================================================ FILE: apps/api/src/app/topics-v1/use-cases/filter-topics/index.ts ================================================ export * from './filter-topics.command'; export * from './filter-topics.use-case'; ================================================ FILE: apps/api/src/app/topics-v1/use-cases/get-topic/get-topic.command.ts ================================================ import { IsDefined, IsString } from 'class-validator'; import { EnvironmentCommand } from '../../../shared/commands/project.command'; import { TopicKey } from '../../types'; export class GetTopicCommand extends EnvironmentCommand { @IsString() @IsDefined() topicKey: TopicKey; } ================================================ FILE: apps/api/src/app/topics-v1/use-cases/get-topic/get-topic.use-case.ts ================================================ import { Injectable, NotFoundException } from '@nestjs/common'; import { TopicEntity, TopicRepository } from '@novu/dal'; import { TopicDto } from '../../dtos'; import { ExternalSubscriberId } from '../../types'; import { GetTopicCommand } from './get-topic.command'; @Injectable() export class GetTopicUseCase { constructor(private topicRepository: TopicRepository) {} async execute(command: GetTopicCommand) { const topic = await this.topicRepository.findTopic(command.topicKey, command.environmentId); if (!topic) { throw new NotFoundException( `Topic not found for id ${command.topicKey} in the environment ${command.environmentId}` ); } return this.mapFromEntity(topic); } private mapFromEntity(topic: TopicEntity & { subscribers: ExternalSubscriberId[] }): TopicDto { return { ...topic, _id: topic._id, _organizationId: topic._organizationId, _environmentId: topic._environmentId, }; } } ================================================ FILE: apps/api/src/app/topics-v1/use-cases/get-topic/index.ts ================================================ export * from './get-topic.command'; export * from './get-topic.use-case'; ================================================ FILE: apps/api/src/app/topics-v1/use-cases/get-topic-subscriber/get-topic-subscriber.command.ts ================================================ import { IsDefined, IsString } from 'class-validator'; import { EnvironmentCommand } from '../../../shared/commands/project.command'; import { ExternalSubscriberId, TopicKey } from '../../types'; export class GetTopicSubscriberCommand extends EnvironmentCommand { @IsString() @IsDefined() externalSubscriberId: ExternalSubscriberId; @IsString() @IsDefined() topicKey: TopicKey; } ================================================ FILE: apps/api/src/app/topics-v1/use-cases/get-topic-subscriber/get-topic-subscriber.use-case.ts ================================================ import { Injectable, NotFoundException } from '@nestjs/common'; import { TopicSubscribersEntity, TopicSubscribersRepository } from '@novu/dal'; import { TopicSubscriberDto } from '../../dtos'; import { GetTopicSubscriberCommand } from './get-topic-subscriber.command'; @Injectable() export class GetTopicSubscriberUseCase { constructor(private topicSubscribersRepository: TopicSubscribersRepository) {} async execute(command: GetTopicSubscriberCommand): Promise { const topicSubscriber = await this.topicSubscribersRepository.findOneByTopicKeyAndExternalSubscriberId( command.environmentId, command.organizationId, command.topicKey, command.externalSubscriberId ); if (!topicSubscriber) { throw new NotFoundException( `Subscriber ${command.externalSubscriberId} not found for topic ${command.topicKey} in the environment ${command.environmentId}` ); } return this.mapFromEntity(topicSubscriber); } private mapFromEntity(topicSubscriber: TopicSubscribersEntity): TopicSubscriberDto { return { externalSubscriberId: topicSubscriber.externalSubscriberId, topicKey: topicSubscriber.topicKey, _topicId: topicSubscriber._topicId, _organizationId: topicSubscriber._organizationId, _environmentId: topicSubscriber._environmentId, _subscriberId: topicSubscriber._subscriberId, }; } } ================================================ FILE: apps/api/src/app/topics-v1/use-cases/get-topic-subscriber/index.ts ================================================ export * from './get-topic-subscriber.command'; export * from './get-topic-subscriber.use-case'; ================================================ FILE: apps/api/src/app/topics-v1/use-cases/index.ts ================================================ import { GetTopicSubscribersUseCase } from '@novu/application-generic'; import { AddSubscribersUseCase } from './add-subscribers'; import { CreateTopicUseCase } from './create-topic'; import { DeleteTopicUseCase } from './delete-topic/delete-topic.use-case'; import { FilterTopicsUseCase } from './filter-topics'; import { GetTopicUseCase } from './get-topic'; import { GetTopicSubscriberUseCase } from './get-topic-subscriber'; import { RemoveSubscribersUseCase } from './remove-subscribers'; import { RenameTopicUseCase } from './rename-topic'; export * from './add-subscribers'; export * from './create-topic'; export * from './delete-topic'; export * from './filter-topics'; export * from './get-topic'; export * from './get-topic-subscriber'; export * from './remove-subscribers'; export * from './rename-topic'; export const USE_CASES = [ AddSubscribersUseCase, CreateTopicUseCase, DeleteTopicUseCase, FilterTopicsUseCase, GetTopicUseCase, GetTopicSubscriberUseCase, GetTopicSubscribersUseCase, RemoveSubscribersUseCase, RenameTopicUseCase, ]; ================================================ FILE: apps/api/src/app/topics-v1/use-cases/remove-subscribers/index.ts ================================================ export * from './remove-subscribers.command'; export * from './remove-subscribers.use-case'; ================================================ FILE: apps/api/src/app/topics-v1/use-cases/remove-subscribers/remove-subscribers.command.ts ================================================ import { IsArray, IsDefined, IsString } from 'class-validator'; import { EnvironmentCommand } from '../../../shared/commands/project.command'; import { ExternalSubscriberId, TopicKey } from '../../types'; export class RemoveSubscribersCommand extends EnvironmentCommand { @IsString() @IsDefined() topicKey: TopicKey; @IsArray() @IsDefined() subscribers: ExternalSubscriberId[]; } ================================================ FILE: apps/api/src/app/topics-v1/use-cases/remove-subscribers/remove-subscribers.use-case.ts ================================================ import { ConflictException, Injectable } from '@nestjs/common'; import { TopicSubscribersEntity, TopicSubscribersRepository } from '@novu/dal'; import { EnvironmentId, OrganizationId, TopicId } from '../../types'; import { RemoveSubscribersCommand } from './remove-subscribers.command'; @Injectable() export class RemoveSubscribersUseCase { constructor(private topicSubscribersRepository: TopicSubscribersRepository) {} async execute(command: RemoveSubscribersCommand): Promise { await this.topicSubscribersRepository.removeSubscribers( command.environmentId, command.organizationId, command.topicKey, command.subscribers ); return undefined; } } ================================================ FILE: apps/api/src/app/topics-v1/use-cases/rename-topic/index.ts ================================================ export * from './rename-topic.command'; export * from './rename-topic.use-case'; ================================================ FILE: apps/api/src/app/topics-v1/use-cases/rename-topic/rename-topic.command.ts ================================================ import { IsDefined, IsString } from 'class-validator'; import { EnvironmentCommand } from '../../../shared/commands/project.command'; import { TopicKey, TopicName } from '../../types'; export class RenameTopicCommand extends EnvironmentCommand { @IsString() @IsDefined() topicKey: TopicKey; @IsString() @IsDefined() name: TopicName; } ================================================ FILE: apps/api/src/app/topics-v1/use-cases/rename-topic/rename-topic.use-case.ts ================================================ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { TopicEntity, TopicRepository } from '@novu/dal'; import { TopicDto } from '../../dtos/topic.dto'; import { ExternalSubscriberId } from '../../types'; import { GetTopicUseCase } from '../get-topic'; import { RenameTopicCommand } from './rename-topic.command'; @Injectable() export class RenameTopicUseCase { constructor( private getTopicUseCase: GetTopicUseCase, private topicRepository: TopicRepository ) {} async execute(command: RenameTopicCommand): Promise { const topic = await this.getTopicUseCase.execute(command); if (!topic) throw new NotFoundException(`Topic ${command.topicKey} not found`); const query = this.mapToQuery(command); if (!query.name) throw new BadRequestException('Name is required'); const renamedTopic = await this.topicRepository.renameTopic(topic._id, query._environmentId, query.name); return this.mapFromEntityToDto(renamedTopic); } private mapToQuery(domainEntity: RenameTopicCommand): Pick { return { _environmentId: domainEntity.environmentId, name: domainEntity.name, }; } private mapFromEntityToDto(topic: TopicEntity & { subscribers: ExternalSubscriberId[] }): TopicDto { return { ...topic, _id: topic._id, _organizationId: topic._organizationId, _environmentId: topic._environmentId, }; } } ================================================ FILE: apps/api/src/app/topics-v2/dtos/create-topic-subscriptions.dto.ts ================================================ import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger'; import { ApiContextPayload, IsValidContextPayload } from '@novu/application-generic'; import { ContextPayload } from '@novu/shared'; import { Type } from 'class-transformer'; import { ArrayMaxSize, ArrayMinSize, IsArray, IsOptional, IsString, ValidateNested } from 'class-validator'; import { GroupPreferenceFilterDto, TopicSubscriberIdentifierDto, WorkflowPreferenceRequestDto, } from '../../shared/dtos/subscriptions/create-subscriptions.dto'; @ApiExtraModels(WorkflowPreferenceRequestDto, GroupPreferenceFilterDto, TopicSubscriberIdentifierDto) export class CreateTopicSubscriptionsRequestDto { @ApiProperty({ description: 'List of subscriber IDs to subscribe to the topic (max: 100). @deprecated Use the "subscriptions" property instead.', type: [String], example: ['subscriberId1', 'subscriberId2'], deprecated: true, }) @IsArray() @IsString({ each: true }) @IsOptional() @ArrayMaxSize(100, { message: 'Cannot subscribe more than 100 subscribers at once' }) @ArrayMinSize(1, { message: 'At least one subscriber identifier is required' }) subscriberIds?: string[]; @ApiProperty({ description: 'List of subscriptions to subscribe to the topic (max: 100). Can be either a string array of subscriber IDs or an array of objects with identifier and subscriberId', type: 'array', items: { oneOf: [{ type: 'string' }, { $ref: getSchemaPath(TopicSubscriberIdentifierDto) }], }, example: [ { identifier: 'subscriber-123-subscription-a', subscriberId: 'subscriber-123' }, { identifier: 'subscriber-456-subscription-b', subscriberId: 'subscriber-456' }, ], }) @IsArray() @ValidateNested({ each: true }) @Type(() => Object) @IsOptional() @ArrayMaxSize(100, { message: 'Cannot subscribe more than 100 subscriptions at once' }) @ArrayMinSize(1, { message: 'At least one subscription is required' }) subscriptions?: Array; @ApiProperty({ description: 'The name of the topic', example: 'My Topic', }) @IsString() @IsOptional() name?: string; @ApiContextPayload() @IsOptional() @IsValidContextPayload({ maxCount: 5 }) context?: ContextPayload; @ApiProperty({ description: 'The preferences of the topic. Can be a simple workflow ID string, workflow preference object, or group filter object', type: 'array', items: { oneOf: [ { type: 'string' }, { $ref: getSchemaPath(WorkflowPreferenceRequestDto) }, { $ref: getSchemaPath(GroupPreferenceFilterDto) }, ], }, example: [{ workflowId: 'workflow-123', condition: { '===': [{ var: 'tier' }, 'premium'] } }], }) @IsArray() @ValidateNested({ each: true }) @Type(() => Object) @IsOptional() preferences?: Array; } ================================================ FILE: apps/api/src/app/topics-v2/dtos/create-update-topic.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsNotEmpty, IsOptional, IsString, Length } from 'class-validator'; export class CreateUpdateTopicRequestDto { @ApiProperty({ description: 'The unique key identifier for the topic. The key must contain only alphanumeric characters (a-z, A-Z, 0-9), hyphens (-), underscores (_), colons (:), or be a valid email address.', example: 'task:12345', }) @IsString() @IsNotEmpty() @Length(1, 100) key: string; @ApiPropertyOptional({ description: 'The display name for the topic', example: 'Task Title', }) @IsString() @IsOptional() @Length(0, 100) name: string; } ================================================ FILE: apps/api/src/app/topics-v2/dtos/cursor-pagination-query.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { DirectionEnum } from '@novu/shared'; import { Transform } from 'class-transformer'; import { IsOptional, IsString, Max } from 'class-validator'; export class CursorPaginationQueryDto { @ApiProperty({ description: 'Cursor for pagination indicating the starting point after which to fetch results.', type: String, required: false, }) @IsString() @IsOptional() after?: string; @ApiProperty({ description: 'Cursor for pagination indicating the ending point before which to fetch results.', type: String, required: false, }) @IsString() @IsOptional() before?: string; @ApiPropertyOptional({ description: 'Limit the number of items to return (max 100)', type: Number, example: 10, }) @Transform(({ value }) => Number(value)) @Max(100) @IsOptional() limit?: number; @ApiPropertyOptional({ description: 'Direction of sorting', enum: DirectionEnum, }) @IsOptional() orderDirection?: DirectionEnum; @ApiPropertyOptional({ description: 'Field to order by', type: String, }) @IsString() @IsOptional() orderBy?: K; @ApiPropertyOptional({ description: 'Include cursor item in response', type: Boolean, }) @Transform(({ value }) => value === 'true') @IsOptional() includeCursor?: boolean; } ================================================ FILE: apps/api/src/app/topics-v2/dtos/delete-topic-response.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; export class DeleteTopicResponseDto { @ApiProperty({ description: 'Indicates if the operation was acknowledged', example: true, }) acknowledged: boolean; } ================================================ FILE: apps/api/src/app/topics-v2/dtos/delete-topic-subscriptions-response.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { IsOptional, IsString } from 'class-validator'; export class TopicDto { @ApiProperty({ description: 'The unique identifier of the topic', example: '64f5e95d3d7946d80d0cb677', }) _id: string; @ApiProperty({ description: 'The key identifier of the topic', example: 'product-updates', }) key: string; @ApiProperty({ description: 'The name of the topic', example: 'Product Updates', required: false, }) name?: string; } export class SubscriberDto { @ApiProperty({ description: 'The unique identifier of the subscriber', example: '64f5e95d3d7946d80d0cb678', }) _id: string; @ApiProperty({ description: 'The external identifier of the subscriber', example: 'external-subscriber-id', }) subscriberId: string; @ApiProperty({ description: 'The avatar URL of the subscriber', example: 'https://example.com/avatar.png', required: false, }) avatar?: string; @ApiProperty({ description: 'The first name of the subscriber', example: 'John', required: false, }) firstName?: string; @ApiProperty({ description: 'The last name of the subscriber', example: 'Doe', required: false, }) lastName?: string; @ApiProperty({ description: 'The email of the subscriber', example: 'john.doe@example.com', required: false, }) email?: string; @ApiProperty({ description: 'The creation date of the subscriber', example: '2025-04-24T05:40:21Z', required: false, }) createdAt?: string; @ApiProperty({ description: 'The last update date of the subscriber', example: '2025-04-24T05:40:21Z', required: false, }) updatedAt?: string; } export class SubscriptionDto { @ApiProperty({ description: 'The unique identifier of the subscription', example: '64f5e95d3d7946d80d0cb679', }) _id: string; @ApiProperty({ description: 'The identifier of the subscription', example: 'tk=product-updates:si=subscriber-123', }) @IsOptional() @IsString() identifier?: string; @ApiProperty({ description: 'The topic information', type: () => TopicDto, }) topic: TopicDto; @ApiProperty({ description: 'The subscriber information', type: () => SubscriberDto, nullable: true, }) subscriber: SubscriberDto | null; @ApiProperty({ description: 'Context keys that scope this subscription (e.g., tenant:org-a, project:proj-123)', example: ['tenant:org-a', 'project:proj-123'], type: [String], required: false, }) contextKeys?: string[]; @ApiProperty({ description: 'The creation date of the subscription', example: '2025-04-24T05:40:21Z', }) createdAt: string; @ApiProperty({ description: 'The last update date of the subscription', example: '2025-04-24T05:40:21Z', }) updatedAt: string; } export class SubscriptionsDeleteErrorDto { @ApiProperty({ description: 'The subscriber ID that failed', example: 'invalid-subscriber-id', }) subscriberId: string; @ApiProperty({ description: 'The error code', example: 'SUBSCRIBER_NOT_FOUND', }) code: string; @ApiProperty({ description: 'The error message', example: 'Subscriber with ID invalid-subscriber-id could not be found', }) message: string; } export class MetaDto { @ApiProperty({ description: 'The total count of subscriber IDs provided', example: 3, }) totalCount: number; @ApiProperty({ description: 'The count of successfully deleted subscriptions', example: 2, }) successful: number; @ApiProperty({ description: 'The count of failed deletion attempts', example: 1, }) failed: number; } export class DeleteTopicSubscriptionsResponseDto { @ApiProperty({ description: 'The list of successfully deleted subscriptions', type: () => [SubscriptionDto], }) data: SubscriptionDto[]; @ApiProperty({ description: 'Metadata about the operation', type: MetaDto, }) meta: MetaDto; @ApiProperty({ description: 'The list of errors for failed deletion attempts', type: [SubscriptionsDeleteErrorDto], required: false, }) errors?: SubscriptionsDeleteErrorDto[]; } ================================================ FILE: apps/api/src/app/topics-v2/dtos/delete-topic-subscriptions.dto.ts ================================================ import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger'; import { ArrayMaxSize, ArrayMinSize, IsArray, IsOptional, IsString } from 'class-validator'; import { TopicSubscriberIdentifierDto } from '../../shared/dtos/subscriptions/create-subscriptions.dto'; export class DeleteTopicSubscriberIdentifierDto { @ApiProperty({ description: 'Unique identifier for this subscription. If provided, deletes only this specific subscription.', example: 'subscriber-123-subscription-a', required: false, }) @IsString() @IsOptional() identifier?: string; @ApiProperty({ description: 'The subscriber ID. If provided without identifier, deletes all subscriptions for this subscriber within the topic.', example: 'subscriber-123', required: false, }) @IsString() @IsOptional() subscriberId?: string; } @ApiExtraModels(DeleteTopicSubscriberIdentifierDto, TopicSubscriberIdentifierDto) export class DeleteTopicSubscriptionsRequestDto { @ApiProperty({ description: 'List of subscriber identifiers to unsubscribe from the topic (max: 100). @deprecated Use the "subscriptions" property instead.', example: ['subscriberId1', 'subscriberId2'], type: [String], deprecated: true, }) @IsArray() @IsString({ each: true }) @IsOptional() @ArrayMaxSize(100, { message: 'Cannot unsubscribe more than 100 subscribers at once' }) @ArrayMinSize(1, { message: 'At least one subscriber identifier is required' }) subscriberIds?: string[]; @ApiProperty({ description: 'List of subscriptions to unsubscribe from the topic (max: 100). Can be either a string array of subscriber IDs or an array of objects with identifier and/or subscriberId. If only subscriberId is provided, all subscriptions for that subscriber within the topic will be deleted.', type: 'array', items: { oneOf: [{ type: 'string' }, { $ref: getSchemaPath(DeleteTopicSubscriberIdentifierDto) }], }, example: [ { identifier: 'subscriber-123-subscription-a', subscriberId: 'subscriber-123' }, { subscriberId: 'subscriber-456' }, { identifier: 'subscriber-789-subscription-b' }, ], }) @IsArray() @IsOptional() @ArrayMaxSize(100, { message: 'Cannot unsubscribe more than 100 subscriptions at once' }) @ArrayMinSize(1, { message: 'At least one subscription is required' }) subscriptions?: Array; } ================================================ FILE: apps/api/src/app/topics-v2/dtos/list-subscriber-subscriptions-query.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsArray, IsOptional, IsString } from 'class-validator'; import { CursorPaginationQueryDto } from './cursor-pagination-query.dto'; import { TopicSubscriptionResponseDto } from './topic-subscription-response.dto'; export class ListSubscriberSubscriptionsQueryDto extends CursorPaginationQueryDto { @ApiProperty({ description: 'Filter by topic key', type: String, required: false, }) @IsOptional() @IsString() key?: string; @ApiPropertyOptional({ description: 'Filter by exact context keys, order insensitive (format: "type:id")', type: String, isArray: true, example: ['tenant:org-123', 'region:us-east-1'], }) @IsOptional() @Transform(({ value }) => { if (value === undefined) return undefined; if (value === '') return []; const array = Array.isArray(value) ? value : [value]; return array.filter((v) => v !== ''); }) @IsArray() @IsString({ each: true }) contextKeys?: string[]; } ================================================ FILE: apps/api/src/app/topics-v2/dtos/list-topic-subscriptions-query.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsArray, IsOptional, IsString } from 'class-validator'; import { CursorPaginationQueryDto } from './cursor-pagination-query.dto'; import { TopicSubscriptionResponseDto } from './topic-subscription-response.dto'; export class ListTopicSubscriptionsQueryDto extends CursorPaginationQueryDto { @ApiProperty({ description: 'Filter by subscriber ID', type: String, required: false, }) @IsOptional() @IsString() subscriberId?: string; @ApiPropertyOptional({ description: 'Filter by exact context keys, order insensitive (format: "type:id")', type: String, isArray: true, example: ['tenant:org-123', 'region:us-east-1'], }) @IsOptional() @Transform(({ value }) => { if (value === undefined) return undefined; if (value === '') return []; const array = Array.isArray(value) ? value : [value]; return array.filter((v) => v !== ''); }) @IsArray() @IsString({ each: true }) contextKeys?: string[]; } ================================================ FILE: apps/api/src/app/topics-v2/dtos/list-topic-subscriptions-response.dto.ts ================================================ import { withCursorPagination } from '../../shared/dtos/cursor-paginated-response'; import { TopicSubscriptionResponseDto } from './topic-subscription-response.dto'; export class ListTopicSubscriptionsResponseDto extends withCursorPagination(TopicSubscriptionResponseDto, { description: 'List of returned Topic Subscriptions', }) {} ================================================ FILE: apps/api/src/app/topics-v2/dtos/list-topics-query.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { IsOptional, IsString } from 'class-validator'; import { CursorPaginationQueryDto } from './cursor-pagination-query.dto'; import { TopicResponseDto } from './topic-response.dto'; export class ListTopicsQueryDto extends CursorPaginationQueryDto { @ApiProperty({ description: 'Key of the topic to filter results.', type: String, required: false, }) @IsOptional() @IsString() key?: string; @ApiProperty({ description: 'Name of the topic to filter results.', type: String, required: false, }) @IsOptional() @IsString() name?: string; } ================================================ FILE: apps/api/src/app/topics-v2/dtos/list-topics-response.dto.ts ================================================ import { withCursorPagination } from '../../shared/dtos/cursor-paginated-response'; import { TopicResponseDto } from './topic-response.dto'; export class ListTopicsResponseDto extends withCursorPagination(TopicResponseDto, { description: 'List of returned Topics', }) {} ================================================ FILE: apps/api/src/app/topics-v2/dtos/topic-response.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsOptional, IsString } from 'class-validator'; export class TopicResponseDto { @ApiProperty({ description: 'The identifier of the topic', type: String, example: '64da692e9a94fb2e6449ad06', }) @IsString() _id: string; @ApiProperty({ description: 'The unique key of the topic', type: String, example: 'product-updates', }) @IsString() key: string; @ApiPropertyOptional({ description: 'The name of the topic', type: String, example: 'Product Updates', }) @IsString() @IsOptional() name?: string; @ApiPropertyOptional({ description: 'The date the topic was created', type: String, example: '2023-08-15T00:00:00.000Z', }) @IsString() @IsOptional() createdAt?: string; @ApiPropertyOptional({ description: 'The date the topic was last updated', type: String, example: '2023-08-15T00:00:00.000Z', }) @IsString() @IsOptional() updatedAt?: string; } ================================================ FILE: apps/api/src/app/topics-v2/dtos/topic-subscription-response.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { TopicResponseDto } from './topic-response.dto'; export class SubscriberDto { @ApiProperty({ description: 'The identifier of the subscriber', example: '64da692e9a94fb2e6449ad07', }) _id: string; @ApiProperty({ description: 'The external identifier of the subscriber', example: 'user-123', }) subscriberId: string; @ApiProperty({ description: 'The avatar URL of the subscriber', example: 'https://example.com/avatar.png', nullable: true, }) avatar?: string; @ApiProperty({ description: 'The first name of the subscriber', example: 'John', nullable: true, }) firstName?: string; @ApiProperty({ description: 'The last name of the subscriber', example: 'Doe', nullable: true, }) lastName?: string; @ApiProperty({ description: 'The email of the subscriber', example: 'john@example.com', nullable: true, }) email?: string; } export class TopicSubscriptionResponseDto { @ApiProperty({ description: 'The identifier of the subscription', example: '64da692e9a94fb2e6449ad08', }) _id: string; @ApiProperty({ description: 'The identifier of the subscription', example: 'tk=product-updates:si=subscriber-123', }) identifier: string; @ApiProperty({ description: 'The date and time the subscription was created', example: '2021-01-01T00:00:00.000Z', }) createdAt: string; @ApiProperty({ description: 'Topic information', type: TopicResponseDto, }) topic: TopicResponseDto; @ApiProperty({ description: 'Subscriber information', type: SubscriberDto, }) subscriber: SubscriberDto; @ApiPropertyOptional({ description: 'Context keys that scope this subscription (e.g., tenant:org-a, project:proj-123)', example: ['tenant:org-a', 'project:proj-123'], type: [String], }) contextKeys?: string[]; } ================================================ FILE: apps/api/src/app/topics-v2/dtos/update-topic-subscription.dto.ts ================================================ import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsArray, IsOptional, IsString, ValidateNested } from 'class-validator'; import { GroupPreferenceFilterDto, WorkflowPreferenceRequestDto, } from '../../shared/dtos/subscriptions/create-subscriptions.dto'; @ApiExtraModels(WorkflowPreferenceRequestDto, GroupPreferenceFilterDto) export class UpdateTopicSubscriptionRequestDto { @ApiProperty({ description: 'The name of the subscription', example: 'My Subscription', }) @IsString() @IsOptional() name?: string; @ApiProperty({ description: 'The preferences of the topic. Can be a simple workflow ID string, workflow preference object, or group filter object', type: 'array', items: { oneOf: [ { type: 'string' }, { $ref: getSchemaPath(WorkflowPreferenceRequestDto) }, { $ref: getSchemaPath(GroupPreferenceFilterDto) }, ], }, example: [{ workflowId: 'workflow-123', condition: { '===': [{ var: 'tier' }, 'premium'] } }], }) @IsArray() @ValidateNested({ each: true }) @Type(() => Object) @IsOptional() preferences?: Array; } ================================================ FILE: apps/api/src/app/topics-v2/dtos/update-topic.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { IsNotEmpty, IsString } from 'class-validator'; export class UpdateTopicRequestDto { @ApiProperty({ description: 'The display name for the topic', example: 'Updated Topic Name', }) @IsString() @IsNotEmpty() name: string; } ================================================ FILE: apps/api/src/app/topics-v2/e2e/create-topic-subscriptions.e2e.ts ================================================ import { Novu } from '@novu/api'; import { SubscriberEntity, TopicSubscribersRepository } from '@novu/dal'; import { StepTypeEnum } from '@novu/shared'; import { SubscribersService, UserSession } from '@novu/testing'; import { expect } from 'chai'; import { initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('Create topic subscriptions - /v2/topics/:topicKey/subscriptions (POST) #novu-v2', async () => { let session: UserSession; let novuClient: Novu; let subscriber1: SubscriberEntity; let subscriber2: SubscriberEntity; let subscriber3: SubscriberEntity; let topicSubscribersRepository: TopicSubscribersRepository; before(async () => { (process.env as Record).IS_CONTEXT_PREFERENCES_ENABLED = 'true'; session = new UserSession(); await session.initialize(); novuClient = initNovuClassSdk(session); topicSubscribersRepository = new TopicSubscribersRepository(); // Create subscribers const subscribersService = new SubscribersService(session.organization._id, session.environment._id); subscriber1 = await subscribersService.createSubscriber(); subscriber2 = await subscribersService.createSubscriber(); subscriber3 = await subscribersService.createSubscriber(); }); it('should create subscriptions for subscribers to an existing topic', async () => { const topicKey = `topic-key-${Date.now()}`; // Create a topic first const createResponse = await novuClient.topics.create({ key: topicKey, name: 'Test Topic', }); const topicId = createResponse.result.id; // Add subscribers to topic const response = await novuClient.topics.subscriptions.create( { subscriberIds: [subscriber1.subscriberId, subscriber2.subscriberId], }, topicKey ); expect(response).to.exist; expect(response.result.data.length).to.equal(2); expect(response.result.meta.successful).to.equal(2); expect(response.result.meta.failed).to.equal(0); // Verify subscribers were added to the topic const subscribers = await topicSubscribersRepository.findSubscribersByTopicId( session.environment._id, session.organization._id, topicId ); expect(subscribers.length).to.equal(2); // Verify the structure of the response data response.result.data.forEach((subscription) => { expect(subscription).to.have.property('id'); expect(subscription).to.have.property('topic'); expect(subscription).to.have.property('subscriber'); expect(subscription.topic.id).to.equal(topicId); expect(subscription.topic.key).to.equal(topicKey); expect([subscriber1.subscriberId, subscriber2.subscriberId]).to.include( subscription.subscriber?.subscriberId as string ); }); }); it('should automatically create a topic when subscribing to a non-existing topic', async () => { const nonExistingTopicKey = `non-existing-topic-${Date.now()}`; // Try to get the topic - should not exist try { await novuClient.topics.get(nonExistingTopicKey); throw new Error('Topic should not exist'); } catch (error) { expect(error.statusCode).to.equal(404); } // Add subscribers to non-existing topic const response = await novuClient.topics.subscriptions.create( { subscriberIds: [subscriber3.subscriberId], }, nonExistingTopicKey ); expect(response).to.exist; expect(response.result.data.length).to.equal(1); expect(response.result.meta.successful).to.equal(1); expect(response.result.meta.failed).to.equal(0); // Verify topic was created const topic = await novuClient.topics.get(nonExistingTopicKey); expect(topic).to.exist; expect(topic.result.key).to.equal(nonExistingTopicKey); // Verify subscriber was added to the topic const subscribers = await topicSubscribersRepository.findSubscribersByTopicId( session.environment._id, session.organization._id, topic.result.id ); expect(subscribers.length).to.equal(1); expect(subscribers[0]?._subscriberId).to.equal(subscriber3._id); }); it('should handle removal of subscribers from a topic', async () => { const topicKey = `topic-key-removal-${Date.now()}`; // Create a topic const createResponse = await novuClient.topics.create({ key: topicKey, name: 'Test Topic for Removal', }); const topicId = createResponse.result.id; // Add subscribers to topic await novuClient.topics.subscriptions.create( { subscriberIds: [subscriber1.subscriberId, subscriber2.subscriberId], }, topicKey ); // Verify subscribers were added let subscribers = await topicSubscribersRepository.findSubscribersByTopicId( session.environment._id, session.organization._id, topicId ); expect(subscribers.length).to.equal(2); // Remove one subscriber const deleteResponse = await novuClient.topics.subscriptions.delete( { subscriberIds: [subscriber1.subscriberId], }, topicKey ); expect(deleteResponse).to.exist; expect(deleteResponse.result.data.length).to.equal(1); expect(deleteResponse.result.meta.successful).to.equal(1); expect(deleteResponse.result.meta.failed).to.equal(0); // Verify subscriber was removed subscribers = await topicSubscribersRepository.findSubscribersByTopicId( session.environment._id, session.organization._id, topicId ); expect(subscribers.length).to.equal(1); expect(subscribers[0]?._subscriberId).to.equal(subscriber2._id); }); it('should handle partial success when some subscribers do not exist', async () => { const topicKey = `topic-key-partial-${Date.now()}`; // Create a topic await novuClient.topics.create({ key: topicKey, name: 'Test Topic for Partial Success', }); // Add existing and non-existing subscribers const nonExistingSubscriberId = 'non-existing-subscriber-id'; const response = await novuClient.topics.subscriptions.create( { subscriberIds: [subscriber1.subscriberId, nonExistingSubscriberId], }, topicKey ); // Verify partial success response expect(response).to.exist; expect(response.result.meta.successful).to.equal(1); expect(response.result.meta.failed).to.equal(1); expect(response.result.errors?.length).to.equal(1); expect(response.result.errors?.[0]?.subscriberId).to.equal(nonExistingSubscriberId); }); it('should handle adding the same subscriber multiple times', async () => { const topicKey = `topic-key-duplicate-${Date.now()}`; // Create a topic const createResponse = await novuClient.topics.create({ key: topicKey, name: 'Test Topic for Duplicates', }); const topicId = createResponse.result.id; // Add a subscriber await novuClient.topics.subscriptions.create( { subscriberIds: [subscriber1.subscriberId], }, topicKey ); // Add the same subscriber again const response = await novuClient.topics.subscriptions.create( { subscriberIds: [subscriber1.subscriberId], }, topicKey ); // Should still be successful (idempotent operation) expect(response).to.exist; expect(response.result.data.length).to.equal(1); expect(response.result.meta.successful).to.equal(1); expect(response.result.meta.failed).to.equal(0); // Verify only one subscription exists const subscribers = await topicSubscribersRepository.findSubscribersByTopicId( session.environment._id, session.organization._id, topicId ); expect(subscribers.length).to.equal(1); expect(subscribers[0]?._subscriberId).to.equal(subscriber1._id); }); it('should create multiple subscriptions for the same subscriber with different conditions', async () => { const topicKey = `topic-key-conditions-${Date.now()}`; const workflow1 = await session.createTemplate({ name: 'Workflow 1', steps: [ { type: StepTypeEnum.IN_APP, content: 'Test content', }, ], }); const workflow2 = await session.createTemplate({ name: 'Workflow 2', steps: [ { type: StepTypeEnum.IN_APP, content: 'Test content', }, ], }); const preferencesA = [ { filter: { workflowIds: [workflow1._id] }, condition: { and: [{ '==': [{ var: 'status' }, 'active'] }, { '==': [{ var: 'priority' }, 'high'] }], }, }, ]; const responseA = await novuClient.topics.subscriptions.create( { subscriptions: [ { identifier: `${subscriber1.subscriberId}-subscription-a`, subscriberId: subscriber1.subscriberId }, ], preferences: preferencesA, }, topicKey ); expect(responseA.result.data.length, 'responseA.result.data.length').to.equal(1); expect(responseA.result.data[0].id, 'responseA.result.data[0].id').to.exist; expect(responseA.result.data[0].topic.key, 'responseA.result.data[0].topic.key').to.equal(topicKey); const preferencesB = [ { filter: { workflowIds: [workflow2._id] }, condition: { and: [{ '==': [{ var: 'status' }, 'pending'] }, { '==': [{ var: 'priority' }, 'low'] }], }, }, ]; const responseB = await novuClient.topics.subscriptions.create( { subscriptions: [ { identifier: `${subscriber1.subscriberId}-subscription-b`, subscriberId: subscriber1.subscriberId }, ], preferences: preferencesB, }, topicKey ); expect(responseB.result.data.length, 'responseB.result.data.length').to.equal(1); expect(responseB.result.data[0].id, 'responseB.result.data[0].id').to.exist; expect(responseB.result.data[0].topic.key, 'responseB.result.data[0].topic.key').to.equal(topicKey); const subscriptions = await topicSubscribersRepository.find({ _environmentId: session.environment._id, _organizationId: session.organization._id, topicKey, externalSubscriberId: subscriber1.subscriberId, }); expect(subscriptions.length, 'expect subscriptions.length to be 2').to.equal(2); await novuClient.topics.subscriptions.create( { subscriptions: [ { identifier: `${subscriber1.subscriberId}-subscription-a`, subscriberId: subscriber1.subscriberId }, ], preferences: preferencesA, }, topicKey ); const subscriptionsAfterDuplicate = await topicSubscribersRepository.find({ _environmentId: session.environment._id, _organizationId: session.organization._id, topicKey, externalSubscriberId: subscriber1.subscriberId, }); expect(subscriptionsAfterDuplicate.length, 'expect subscriptionsAfterDuplicate.length to be 2').to.equal(2); }); it('should enforce subscription limit of 10 per subscriber per topic', async () => { try { const topicKey = `topic-key-limit-${Date.now()}`; const MAX_SUBSCRIPTIONS_PER_SUBSCRIBER = 10; // Create a topic const createResponse = await novuClient.topics.create({ key: topicKey, name: 'Test Topic for Limit', }); const topicId = createResponse.result.id; // Create a single workflow const workflow = await session.createTemplate({ name: 'Test Workflow', steps: [ { type: StepTypeEnum.IN_APP, content: 'Test content', }, ], }); // Create 10 subscriptions with different conditions for the same subscriber for (let i = 0; i < MAX_SUBSCRIPTIONS_PER_SUBSCRIBER; i++) { const response = await novuClient.topics.subscriptions.create( { subscriptions: [ { identifier: `${subscriber1.subscriberId}-subscription-${i}`, subscriberId: subscriber1.subscriberId }, ], preferences: [ { filter: { workflowIds: [workflow._id] }, condition: { and: [{ '==': [{ var: 'status' }, `status-${i}`] }, { '==': [{ var: 'priority' }, `priority-${i}`] }], }, enabled: true, }, ], }, topicKey ); expect(response.result.meta.successful, `Subscription should be successful, index ${i}`).to.equal(1); expect(response.result.meta.failed, `Subscription should be successful, index ${i}`).to.equal(0); } // Verify we have exactly 10 subscriptions const subscriptions = await topicSubscribersRepository.find({ _environmentId: session.environment._id, _organizationId: session.organization._id, _topicId: topicId, _subscriberId: subscriber1._id, }); expect(subscriptions.length, `Subscriptions should be exactly of limit max`).to.equal( MAX_SUBSCRIPTIONS_PER_SUBSCRIBER ); // Try to create an 11th subscription - should fail with 400 error try { await novuClient.topics.subscriptions.create( { subscriptions: [ { identifier: `${subscriber1.subscriberId}-subscription-10`, subscriberId: subscriber1.subscriberId }, ], preferences: [ { filter: { workflowIds: [workflow._id] }, condition: { and: [{ '==': [{ var: 'status' }, 'status-11'] }, { '==': [{ var: 'priority' }, 'priority-11'] }], }, enabled: true, }, ], }, topicKey ); // Should never reach here - request should throw an error expect.fail('Request should have thrown an error when exceeding subscription limit'); } catch (error: any) { // When all subscriptions fail, the controller returns 400 and SDK throws ErrorDto expect(error.statusCode || error.data$?.statusCode || error.status, 'should be 400 error').to.equal(400); const errorContext = error.ctx || error.data$?.ctx; expect(errorContext, 'error should have ctx with response data').to.exist; const errorResponse = errorContext; expect(errorResponse.meta.successful, 'should not create extra subscriptions').to.equal(0); expect(errorResponse.meta.failed, 'should fail 1 due to limit').to.equal(1); expect(errorResponse.errors?.length, 'should have 1 error for limit').to.equal(1); expect(errorResponse.errors?.[0]?.code, 'should have limit error code').to.equal('SUBSCRIPTION_LIMIT_EXCEEDED'); expect(errorResponse.errors?.[0]?.subscriberId, 'should reference correct subscriber id').to.equal( subscriber1.subscriberId ); expect(errorResponse.errors?.[0]?.message, 'should mention limit and attempted request').to.include( `Subscriber ${subscriber1.subscriberId} has reached the maximum allowed of ${MAX_SUBSCRIPTIONS_PER_SUBSCRIBER} subscriptions for topic "${topicKey}"` ); } // Verify we still have exactly 10 subscriptions (no new one was created) const subscriptionsAfterLimit = await topicSubscribersRepository.find({ _environmentId: session.environment._id, _organizationId: session.organization._id, _topicId: topicId, _subscriberId: subscriber1._id, }); expect(subscriptionsAfterLimit.length, 'Subscriptions should still be exactly of limit max').to.equal( MAX_SUBSCRIPTIONS_PER_SUBSCRIBER ); } catch (error) { throw error; } }); describe('Context-aware subscriptions', () => { it('should create subscriptions with context payload', async () => { const topicKey = `topic-key-context-${Date.now()}`; const response = await novuClient.topics.subscriptions.create( { subscriberIds: [subscriber1.subscriberId], context: { tenant: 'org-123', project: 'proj-456' }, }, topicKey ); expect(response).to.exist; expect(response.result.data.length).to.equal(1); expect(response.result.meta.successful).to.equal(1); expect(response.result.meta.failed).to.equal(0); expect(response.result.data[0].contextKeys).to.have.members(['project:proj-456', 'tenant:org-123']); const subscriptionIdentifier = response.result.data[0].identifier; expect(subscriptionIdentifier).to.exist; // Verify we can retrieve the subscription by identifier alone (no contextKeys needed) const getResponse = await novuClient.topics.subscriptions.getSubscription( topicKey, subscriptionIdentifier as string ); expect(getResponse.result).to.exist; expect(getResponse.result.contextKeys).to.have.members(['project:proj-456', 'tenant:org-123']); expect(getResponse.result.identifier).to.include(':ctx_'); }); it('should create separate subscriptions for same subscriber with different contexts', async () => { const topicKey = `topic-key-multi-context-${Date.now()}`; const responseA = await novuClient.topics.subscriptions.create( { subscriberIds: [subscriber1.subscriberId], context: { tenant: 'org-a' }, }, topicKey ); expect(responseA.result.data.length).to.equal(1); expect(responseA.result.meta.successful).to.equal(1); expect(responseA.result.data[0].contextKeys).to.deep.equal(['tenant:org-a']); const responseB = await novuClient.topics.subscriptions.create( { subscriberIds: [subscriber1.subscriberId], context: { tenant: 'org-b' }, }, topicKey ); expect(responseB.result.data.length).to.equal(1); expect(responseB.result.meta.successful).to.equal(1); expect(responseB.result.data[0].contextKeys).to.deep.equal(['tenant:org-b']); const identifierA = responseA.result.data[0].identifier; const identifierB = responseB.result.data[0].identifier; expect(identifierA).to.not.equal(identifierB); // Verify we can retrieve both subscriptions via SDK const getResponseA = await novuClient.topics.subscriptions.getSubscription(topicKey, identifierA as string); const getResponseB = await novuClient.topics.subscriptions.getSubscription(topicKey, identifierB as string); const subscriptionA = getResponseA.result; const subscriptionB = getResponseB.result; expect(subscriptionA).to.exist; expect(subscriptionB).to.exist; expect(subscriptionA?.contextKeys).to.deep.equal(['tenant:org-a']); expect(subscriptionB?.contextKeys).to.deep.equal(['tenant:org-b']); const allSubscriptions = await topicSubscribersRepository.find({ _environmentId: session.environment._id, _organizationId: session.organization._id, topicKey, externalSubscriberId: subscriber1.subscriberId, }); expect(allSubscriptions.length).to.equal(2); }); it('should create subscription without context when context not provided', async () => { const topicKey = `topic-key-no-context-${Date.now()}`; const response = await novuClient.topics.subscriptions.create( { subscriberIds: [subscriber1.subscriberId], }, topicKey ); expect(response).to.exist; expect(response.result.data.length).to.equal(1); expect(response.result.meta.successful).to.equal(1); const contextKeys = response.result.data[0].contextKeys; expect(contextKeys === undefined || (Array.isArray(contextKeys) && contextKeys.length === 0)).to.be.true; const subscriptionIdentifier = response.result.data[0].identifier; expect(subscriptionIdentifier).to.exist; // Verify we can retrieve the subscription via SDK const getResponse = await novuClient.topics.subscriptions.getSubscription( topicKey, subscriptionIdentifier as string ); expect(getResponse.result).to.exist; expect(getResponse.result.contextKeys).to.deep.equal([]); expect(getResponse.result.identifier).to.not.include(':ctx_'); }); }); }); ================================================ FILE: apps/api/src/app/topics-v2/e2e/delete-topic-subscriptions.e2e.ts ================================================ import { Novu } from '@novu/api'; import { SubscriberEntity, TopicSubscribersRepository } from '@novu/dal'; import { SubscribersService, UserSession } from '@novu/testing'; import { expect } from 'chai'; import { initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('Delete topic subscriptions - /v2/topics/:topicKey/subscriptions (DELETE) #novu-v2', async () => { let session: UserSession; let novuClient: Novu; let subscriber1: SubscriberEntity; let subscriber2: SubscriberEntity; let subscriber3: SubscriberEntity; let topicSubscribersRepository: TopicSubscribersRepository; before(async () => { session = new UserSession(); await session.initialize(); novuClient = initNovuClassSdk(session); topicSubscribersRepository = new TopicSubscribersRepository(); // Create subscribers const subscribersService = new SubscribersService(session.organization._id, session.environment._id); subscriber1 = await subscribersService.createSubscriber(); subscriber2 = await subscribersService.createSubscriber(); subscriber3 = await subscribersService.createSubscriber(); }); it('should delete a single subscription from a topic', async () => { const topicKey = `topic-key-${Date.now()}`; // Create a topic const createResponse = await novuClient.topics.create({ key: topicKey, name: 'Test Topic for Single Deletion', }); const topicId = createResponse.result.id; // Add multiple subscribers to topic await novuClient.topics.subscriptions.create( { subscriberIds: [subscriber1.subscriberId, subscriber2.subscriberId], }, topicKey ); // Verify subscribers were added let subscribers = await topicSubscribersRepository.findSubscribersByTopicId( session.environment._id, session.organization._id, topicId ); expect(subscribers.length).to.equal(2); // Delete one subscriber const deleteResponse = await novuClient.topics.subscriptions.delete( { subscriberIds: [subscriber1.subscriberId], }, topicKey ); expect(deleteResponse).to.exist; expect(deleteResponse.result.data.length).to.equal(1); expect(deleteResponse.result.meta.successful).to.equal(1); expect(deleteResponse.result.meta.failed).to.equal(0); // Verify the subscription was removed subscribers = await topicSubscribersRepository.findSubscribersByTopicId( session.environment._id, session.organization._id, topicId ); expect(subscribers.length).to.equal(1); expect(subscribers[0]?._subscriberId).to.equal(subscriber2._id); }); it('should delete multiple subscriptions from a topic', async () => { const topicKey = `topic-key-multiple-${Date.now()}`; // Create a topic const createResponse = await novuClient.topics.create({ key: topicKey, name: 'Test Topic for Multiple Deletion', }); const topicId = createResponse.result.id; // Add multiple subscribers to topic await novuClient.topics.subscriptions.create( { subscriberIds: [subscriber1.subscriberId, subscriber2.subscriberId, subscriber3.subscriberId], }, topicKey ); // Verify subscribers were added let subscribers = await topicSubscribersRepository.findSubscribersByTopicId( session.environment._id, session.organization._id, topicId ); expect(subscribers.length).to.equal(3); // Delete multiple subscribers const deleteResponse = await novuClient.topics.subscriptions.delete( { subscriberIds: [subscriber1.subscriberId, subscriber2.subscriberId], }, topicKey ); expect(deleteResponse).to.exist; expect(deleteResponse.result.data.length).to.equal(2); expect(deleteResponse.result.meta.successful).to.equal(2); expect(deleteResponse.result.meta.failed).to.equal(0); // Verify the subscriptions were removed subscribers = await topicSubscribersRepository.findSubscribersByTopicId( session.environment._id, session.organization._id, topicId ); expect(subscribers.length).to.equal(1); expect(subscribers[0]?._subscriberId).to.equal(subscriber3._id); }); it('should handle partial success when deleting subscriptions', async () => { const topicKey = `topic-key-partial-${Date.now()}`; // Create a topic const createResponse = await novuClient.topics.create({ key: topicKey, name: 'Test Topic for Partial Success', }); const topicId = createResponse.result.id; // Add one subscriber to topic await novuClient.topics.subscriptions.create( { subscriberIds: [subscriber1.subscriberId], }, topicKey ); // Try to delete one existing and one non-existing subscriber const nonExistingSubscriberId = 'non-existing-subscriber-id'; const deleteResponse = await novuClient.topics.subscriptions.delete( { subscriberIds: [subscriber1.subscriberId, nonExistingSubscriberId], }, topicKey ); // Should return partial success expect(deleteResponse).to.exist; expect(deleteResponse.result.data.length).to.equal(1); expect(deleteResponse.result.meta.successful).to.equal(1); expect(deleteResponse.result.meta.failed).to.equal(1); expect(deleteResponse.result.errors?.length).to.equal(1); expect(deleteResponse.result.errors?.[0]?.subscriberId).to.equal(nonExistingSubscriberId); // Verify the subscription was removed const subscribers = await topicSubscribersRepository.findSubscribersByTopicId( session.environment._id, session.organization._id, topicId ); expect(subscribers.length).to.equal(0); }); it('should handle deleting from a non-existent topic', async () => { const nonExistentTopicKey = `non-existent-topic-${Date.now()}`; try { await novuClient.topics.subscriptions.delete( { subscriberIds: [subscriber1.subscriberId], }, nonExistentTopicKey ); throw new Error('Should have failed to delete subscriptions from non-existent topic'); } catch (error) { expect(error.statusCode).to.equal(404); expect(error.message).to.include(nonExistentTopicKey); } }); }); ================================================ FILE: apps/api/src/app/topics-v2/e2e/delete-topic.e2e.ts ================================================ import { Novu } from '@novu/api'; import { SubscriberEntity, TopicSubscribersRepository } from '@novu/dal'; import { SubscribersService, UserSession } from '@novu/testing'; import { expect } from 'chai'; import { initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('Delete topic by key - /v2/topics/:topicKey (DELETE) #novu-v2', async () => { let session: UserSession; let novuClient: Novu; let subscriber: SubscriberEntity; let topicSubscribersRepository: TopicSubscribersRepository; beforeEach(async () => { session = new UserSession(); await session.initialize(); novuClient = initNovuClassSdk(session); topicSubscribersRepository = new TopicSubscribersRepository(); }); it('should delete a topic with no subscribers', async () => { const topicKey = `topic-key-${Date.now()}`; await novuClient.topics.create({ key: topicKey, name: 'Test Topic', }); // Verify topic exists const getTopic = await novuClient.topics.get(topicKey); expect(getTopic).to.exist; // Delete the topic const response = await novuClient.topics.delete(topicKey); expect(response).to.exist; expect(response.result.acknowledged).to.equal(true); // Verify topic no longer exists try { await novuClient.topics.get(topicKey); throw new Error('Topic should not exist'); } catch (error) { expect(error.statusCode).to.equal(404); } }); it('should delete a topic with subscribers', async () => { // Create a subscriber const subscribersService = new SubscribersService(session.organization._id, session.environment._id); subscriber = await subscribersService.createSubscriber(); // Create a topic const topicKey = `topic-key-${Date.now()}`; const createResponse = await novuClient.topics.create({ key: topicKey, name: 'Test Topic with Subscribers', }); const topicId = createResponse.result.id; // Add subscriber to topic await novuClient.topics.subscriptions.create( { subscriberIds: [subscriber.subscriberId], }, topicKey ); // Verify subscriber is added to topic const subscribers = await topicSubscribersRepository.findSubscribersByTopicId( session.environment._id, session.organization._id, topicId ); expect(subscribers.length).to.be.greaterThan(0); await novuClient.topics.delete(topicKey); // Verify topic no longer exists try { await novuClient.topics.get(topicKey); throw new Error('Topic should not exist'); } catch (error) { expect(error.statusCode).to.equal(404); } // Verify subscriptions have been removed const subscribersAfterDelete = await topicSubscribersRepository.findSubscribersByTopicId( session.environment._id, session.organization._id, topicId ); expect(subscribersAfterDelete.length).to.equal(0); }); it('should return 404 for deleting a non-existent topic key', async () => { const nonExistentKey = 'non-existent-topic-key'; try { await novuClient.topics.delete(nonExistentKey); throw new Error('Should have failed to delete non-existent topic'); } catch (error) { expect(error.statusCode).to.equal(404); expect(error.message).to.include(nonExistentKey); } }); }); ================================================ FILE: apps/api/src/app/topics-v2/e2e/get-topic.e2e.ts ================================================ import { Novu } from '@novu/api'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('Get topic by key - /v2/topics/:topicKey (GET) #novu-v2', async () => { let session: UserSession; let novuClient: Novu; const topicKey = `topic-key-${Date.now()}`; const topicName = 'Test Topic Name'; before(async () => { session = new UserSession(); await session.initialize(); novuClient = initNovuClassSdk(session); // Create a topic to retrieve later await novuClient.topics.create({ key: topicKey, name: topicName, }); }); it('should retrieve a topic by its key', async () => { const response = await novuClient.topics.get(topicKey); expect(response).to.exist; expect(response.result).to.have.property('id'); expect(response.result.key).to.equal(topicKey); expect(response.result.name).to.equal(topicName); expect(response.result).to.have.property('createdAt'); expect(response.result).to.have.property('updatedAt'); }); it('should return 404 for a non-existent topic key', async () => { const nonExistentKey = 'non-existent-topic-key'; try { await novuClient.topics.get(nonExistentKey); throw new Error('Should have failed to get non-existent topic'); } catch (error) { expect(error.statusCode).to.equal(404); expect(error.message).to.include(nonExistentKey); } }); }); ================================================ FILE: apps/api/src/app/topics-v2/e2e/list-topic-subscriptions.e2e.ts ================================================ import { Novu } from '@novu/api'; import { SubscriberEntity } from '@novu/dal'; import { SubscribersService, UserSession } from '@novu/testing'; import { expect } from 'chai'; import { initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('List topic subscriptions - /v2/topics/:topicKey/subscriptions (GET) #novu-v2', async () => { let session: UserSession; let novuClient: Novu; let subscriber1: SubscriberEntity; let subscriber2: SubscriberEntity; let subscriber3: SubscriberEntity; const topicKey = `topic-key-${Date.now()}`; let topicId: string; before(async () => { (process.env as Record).IS_CONTEXT_PREFERENCES_ENABLED = 'true'; session = new UserSession(); await session.initialize(); novuClient = initNovuClassSdk(session); // Create subscribers const subscribersService = new SubscribersService(session.organization._id, session.environment._id); subscriber1 = await subscribersService.createSubscriber(); subscriber2 = await subscribersService.createSubscriber(); subscriber3 = await subscribersService.createSubscriber(); // Create a topic const createResponse = await novuClient.topics.create({ key: topicKey, name: 'Test Topic', }); topicId = createResponse.result.id; // Add subscribers to topic await novuClient.topics.subscriptions.create( { subscriberIds: [subscriber1.subscriberId, subscriber2.subscriberId, subscriber3.subscriberId], }, topicKey ); }); it('should list topic subscriptions with pagination', async () => { const response = await novuClient.topics.subscriptions.list({ topicKey, limit: 2, }); expect(response).to.exist; expect(response.result.data.length).to.equal(2); expect(response.result.next).to.be.a('string'); expect(response.result.previous).to.be.null; // Check response structure for each subscription response.result.data.forEach((subscription) => { expect(subscription).to.have.property('id'); expect(subscription).to.have.property('topic'); expect(subscription).to.have.property('subscriber'); expect(subscription.topic.id).to.equal(topicId); expect(subscription.topic.key).to.equal(topicKey); }); // Get next page const nextResponse = await novuClient.topics.subscriptions.list({ topicKey, limit: 2, after: response.result.next as string, }); expect(nextResponse).to.exist; // We have 3 subscribers total, with 2 per page, so the second page has 1 subscriber const expectedSubscribersInSecondPage = 1; expect(nextResponse.result.data.length).to.equal(expectedSubscribersInSecondPage); expect(nextResponse.result.next).to.be.null; expect(nextResponse.result.previous).to.be.a('string'); }); it('should filter subscriptions by subscriberId', async () => { const response = await novuClient.topics.subscriptions.list({ topicKey, subscriberId: subscriber1.subscriberId, }); expect(response).to.exist; expect(response.result.data.length).to.equal(1); expect(response.result.data[0].subscriber.subscriberId).to.equal(subscriber1.subscriberId); }); it('should return 404 for non-existent topic', async () => { const nonExistentKey = 'non-existent-topic-key'; try { await novuClient.topics.subscriptions.list({ topicKey: nonExistentKey, }); throw new Error('Should have failed to list subscriptions for non-existent topic'); } catch (error) { expect(error.statusCode).to.equal(404); expect(error.message).to.include(nonExistentKey); } }); it('should return empty array for topic with no subscriptions', async () => { // Create a topic with no subscribers const emptyTopicKey = `empty-topic-${Date.now()}`; await novuClient.topics.create({ key: emptyTopicKey, name: 'Empty Topic', }); const response = await novuClient.topics.subscriptions.list({ topicKey: emptyTopicKey, }); expect(response).to.exist; expect(response.result.data).to.be.an('array').that.is.empty; expect(response.result.next).to.be.null; expect(response.result.previous).to.be.null; }); describe('Context-aware filtering', () => { let contextTopicKey: string; let sub1WithContextA: string; let sub2WithContextB: string; let sub3NoContext: string; before(async () => { contextTopicKey = `context-topic-${Date.now()}`; const response1 = await novuClient.topics.subscriptions.create( { subscriberIds: [subscriber1.subscriberId], context: { tenant: 'org-a' }, }, contextTopicKey ); sub1WithContextA = response1.result.data[0].id; const response2 = await novuClient.topics.subscriptions.create( { subscriberIds: [subscriber2.subscriberId], context: { tenant: 'org-b' }, }, contextTopicKey ); sub2WithContextB = response2.result.data[0].id; const response3 = await novuClient.topics.subscriptions.create( { subscriberIds: [subscriber3.subscriberId], }, contextTopicKey ); sub3NoContext = response3.result.data[0].id; }); it('should filter subscriptions by exact contextKeys match', async () => { const response = await novuClient.topics.subscriptions.list({ topicKey: contextTopicKey, contextKeys: ['tenant:org-a'], }); expect(response).to.exist; expect(response.result.data.length).to.equal(1); expect(response.result.data[0].id).to.equal(sub1WithContextA); expect(response.result.data[0].subscriber.subscriberId).to.equal(subscriber1.subscriberId); expect(response.result.data[0].contextKeys).to.deep.equal(['tenant:org-a']); }); it('should return all subscriptions when contextKeys not provided', async () => { const response = await novuClient.topics.subscriptions.list({ topicKey: contextTopicKey, }); expect(response).to.exist; expect(response.result.data.length).to.equal(3); const returnedIds = response.result.data.map((sub) => sub.id); expect(returnedIds).to.include.members([sub1WithContextA, sub2WithContextB, sub3NoContext]); const sub1 = response.result.data.find((s) => s.id === sub1WithContextA); const sub2 = response.result.data.find((s) => s.id === sub2WithContextB); const sub3 = response.result.data.find((s) => s.id === sub3NoContext); expect(sub1?.contextKeys).to.deep.equal(['tenant:org-a']); expect(sub2?.contextKeys).to.deep.equal(['tenant:org-b']); const sub3ContextKeys = sub3?.contextKeys; expect(sub3ContextKeys === undefined || (Array.isArray(sub3ContextKeys) && sub3ContextKeys.length === 0)).to.be .true; }); it('should match exact contextKeys (order-insensitive)', async () => { const multiContextTopicKey = `multi-context-topic-${Date.now()}`; const createResponse = await novuClient.topics.subscriptions.create( { subscriberIds: [subscriber1.subscriberId], context: { tenant: 'org-a', project: 'proj-1' }, }, multiContextTopicKey ); const subscriptionId = createResponse.result.data[0].id; const responseOrderA = await novuClient.topics.subscriptions.list({ topicKey: multiContextTopicKey, contextKeys: ['project:proj-1', 'tenant:org-a'], }); expect(responseOrderA.result.data.length).to.equal(1); expect(responseOrderA.result.data[0].id).to.equal(subscriptionId); expect(responseOrderA.result.data[0].contextKeys).to.have.members(['project:proj-1', 'tenant:org-a']); const responseOrderB = await novuClient.topics.subscriptions.list({ topicKey: multiContextTopicKey, contextKeys: ['tenant:org-a', 'project:proj-1'], }); expect(responseOrderB.result.data.length).to.equal(1); expect(responseOrderB.result.data[0].id).to.equal(subscriptionId); expect(responseOrderB.result.data[0].contextKeys).to.have.members(['project:proj-1', 'tenant:org-a']); const responsePartial = await novuClient.topics.subscriptions.list({ topicKey: multiContextTopicKey, contextKeys: ['tenant:org-a'], }); expect(responsePartial.result.data.length).to.equal(0); }); }); }); ================================================ FILE: apps/api/src/app/topics-v2/e2e/list-topics.e2e.ts ================================================ import { Novu } from '@novu/api'; import { SubscriberEntity } from '@novu/dal'; import { ExternalSubscriberId, TopicKey } from '@novu/shared'; import { SubscribersService, UserSession } from '@novu/testing'; import { expect } from 'chai'; import { initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('List topics - /v2/topics (GET) #novu-v2', async () => { let session: UserSession; let novuClient: Novu; let firstSubscriber: SubscriberEntity; let secondSubscriber: SubscriberEntity; before(async () => { session = new UserSession(); await session.initialize(); // Create multiple topics for testing pagination await createNewTopic(session, 'topic-key-1'); await createNewTopic(session, 'topic-key-2'); await createNewTopic(session, 'topic-key-3'); await createNewTopic(session, 'topic-key-4'); await createNewTopic(session, 'topic-key-5'); // Add subscribers to one of the topics const subscribersService = new SubscribersService(session.organization._id, session.environment._id); firstSubscriber = await subscribersService.createSubscriber(); secondSubscriber = await subscribersService.createSubscriber(); const topicKey = 'topic-key-2'; const subscribers = [firstSubscriber.subscriberId, secondSubscriber.subscriberId]; await addSubscribersToTopic(session, topicKey, subscribers); novuClient = initNovuClassSdk(session); }); it('should retrieve all topics with cursor pagination', async () => { const response = await novuClient.topics.list({ limit: 3, }); expect(response).to.exist; expect(response.result.data.length).to.equal(3); expect(response.result.next).to.be.a('string'); expect(response.result.previous).to.be.null; // Get the next page using the cursor const nextResponse = await novuClient.topics.list({ limit: 3, after: response.result.next as string, }); expect(nextResponse).to.exist; expect(nextResponse.result.data.length).to.equal(2); expect(nextResponse.result.next).to.be.null; expect(nextResponse.result.previous).to.be.a('string'); // Ensure we have 5 unique topics between the two pages const allTopics = [...response.result.data, ...nextResponse.result.data]; const uniqueTopicIds = new Set(allTopics.map((topic) => topic.id)); expect(uniqueTopicIds.size).to.equal(5); }); it('should filter topics by key', async () => { const response = await novuClient.topics.list({ key: 'topic-key-2', }); expect(response).to.exist; expect(response.result.data.length).to.equal(1); expect(response.result.data[0].key).to.equal('topic-key-2'); }); it('should filter topics by name', async () => { const response = await novuClient.topics.list({ name: 'topic-key-3-name', }); expect(response).to.exist; expect(response.result.data.length).to.equal(1); expect(response.result.data[0].name).to.equal('topic-key-3-name'); }); it('should not throw when filtering by key with regex special characters', async () => { const response = await novuClient.topics.list({ key: 'topic+key*2?', }); expect(response).to.exist; expect(response.result.data.length).to.equal(0); }); it('should not throw when filtering by name with regex special characters', async () => { const response = await novuClient.topics.list({ name: 'topic+key*name?', }); expect(response).to.exist; expect(response.result.data.length).to.equal(0); }); it('should order topics by specified field', async () => { const response = await novuClient.topics.list({ orderBy: 'key', orderDirection: 'ASC', }); expect(response).to.exist; const keys = response.result.data.map((topic) => topic.key); const sortedKeys = [...keys].sort(); expect(keys).to.deep.equal(sortedKeys); }); it('should include topic fields: id, name, key, createdAt, updatedAt', async () => { const response = await novuClient.topics.list({ limit: 1, }); expect(response).to.exist; expect(response.result.data.length).to.equal(1); const topic = response.result.data[0]; expect(topic).to.have.property('id'); expect(topic).to.have.property('name'); expect(topic).to.have.property('key'); expect(topic).to.have.property('createdAt'); expect(topic).to.have.property('updatedAt'); }); }); const createNewTopic = async (session: UserSession, topicKey: string) => { const result = await initNovuClassSdk(session).topics.create({ key: topicKey, name: `${topicKey}-name`, }); return result.result; }; const addSubscribersToTopic = async (session: UserSession, topicKey: TopicKey, subscribers: ExternalSubscriberId[]) => { const result = await initNovuClassSdk(session).topics.subscriptions.create( { subscriberIds: subscribers, }, topicKey ); expect(result.result.data).to.be.ok; }; ================================================ FILE: apps/api/src/app/topics-v2/e2e/update-topic-subscription.e2e.ts ================================================ import { Novu } from '@novu/api'; import { SubscriberEntity, TopicSubscribersRepository } from '@novu/dal'; import { CreateWorkflowDto, StepTypeEnum, WorkflowCreationSourceEnum } from '@novu/shared'; import { SubscribersService, UserSession } from '@novu/testing'; import { expect } from 'chai'; import { expectSdkExceptionGeneric, initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('Update topic subscription - /v2/topics/:topicKey/subscriptions/:identifier (PATCH) #novu-v2', async () => { let session: UserSession; let novuClient: Novu; let subscriber1: SubscriberEntity; let subscriber2: SubscriberEntity; let topicSubscribersRepository: TopicSubscribersRepository; before(async () => { session = new UserSession(); await session.initialize(); novuClient = initNovuClassSdk(session); topicSubscribersRepository = new TopicSubscribersRepository(); const subscribersService = new SubscribersService(session.organization._id, session.environment._id); subscriber1 = await subscribersService.createSubscriber(); subscriber2 = await subscribersService.createSubscriber(); const workflow1Dto: CreateWorkflowDto = { name: 'Workflow 1', workflowId: 'workflow-1', __source: WorkflowCreationSourceEnum.DASHBOARD, tags: ['tag1', 'important'], active: true, steps: [ { type: StepTypeEnum.IN_APP, name: 'Test Step', controlValues: { body: 'Test content', }, }, ], }; const workflow2Dto: CreateWorkflowDto = { name: 'Workflow 2', workflowId: 'workflow-2', __source: WorkflowCreationSourceEnum.DASHBOARD, tags: ['tag2'], active: true, steps: [ { type: StepTypeEnum.IN_APP, name: 'Test Step', controlValues: { body: 'Test content', }, }, ], }; const workflow3Dto: CreateWorkflowDto = { name: 'Workflow 3', workflowId: 'workflow-3', __source: WorkflowCreationSourceEnum.DASHBOARD, tags: ['tag3'], active: true, steps: [ { type: StepTypeEnum.IN_APP, name: 'Test Step', controlValues: { body: 'Test content', }, }, ], }; await session.testAgent.post('/v2/workflows').send(workflow1Dto); await session.testAgent.post('/v2/workflows').send(workflow2Dto); await session.testAgent.post('/v2/workflows').send(workflow3Dto); }); it('should update subscription preferences', async () => { const topicKey = `topic-key-update-${Date.now()}`; await novuClient.topics.create({ key: topicKey, name: 'Test Topic', }); const subscriptionResponse = await novuClient.topics.subscriptions.create( { subscriberIds: [subscriber1.subscriberId], preferences: [ { filter: { workflowIds: ['workflow-1'], tags: ['tag1'] }, enabled: true, }, ], }, topicKey ); expect(subscriptionResponse.result.data.length, 'Should have created a subscription').to.equal(1); const subscriptionIdentifier = subscriptionResponse.result.data[0].identifier; const updateResponse = await novuClient.topics.subscriptions.update({ topicKey, identifier: subscriptionIdentifier, updateTopicSubscriptionRequestDto: { preferences: [ { filter: { workflowIds: ['workflow-2'], tags: ['tag2'] }, enabled: false, }, ], }, }); expect(updateResponse, 'Should have updated the subscription').to.exist; expect(updateResponse.result.identifier, 'Should have updated the subscription').to.equal(subscriptionIdentifier); expect(updateResponse.result.preferences, 'Should have preferences').to.exist; expect(updateResponse.result.preferences?.length, 'Should have preferences').to.be.greaterThan(0); const subscription = await topicSubscribersRepository.findOne({ identifier: subscriptionIdentifier, _environmentId: session.environment._id, _organizationId: session.organization._id, }); expect(subscription, 'Should have found the subscription').to.exist; }); it('should update subscription with multiple preferences', async () => { const topicKey = `topic-key-multiple-preferences-${Date.now()}`; await novuClient.topics.create({ key: topicKey, name: 'Test Topic', }); const subscriptionResponse = await novuClient.topics.subscriptions.create( { subscriberIds: [subscriber2.subscriberId], preferences: [ { filter: { workflowIds: ['workflow-1'], tags: ['tag1'] }, enabled: true, }, ], }, topicKey ); const subscriptionIdentifier = subscriptionResponse.result.data[0].identifier; const updateResponse = await novuClient.topics.subscriptions.update({ topicKey, identifier: subscriptionIdentifier, updateTopicSubscriptionRequestDto: { preferences: [ { filter: { workflowIds: ['workflow-2'], tags: ['tag2'] }, condition: { and: [{ '==': [{ var: 'status' }, 'active'] }] }, enabled: true, }, { filter: { tags: ['tag3'] }, enabled: false, }, ], }, }); expect(updateResponse).to.exist; expect(updateResponse.result.identifier).to.equal(subscriptionIdentifier); expect(updateResponse.result.preferences).to.exist; }); it('should return 404 when subscription does not exist', async () => { const topicKey = `topic-key-404-${Date.now()}`; await novuClient.topics.create({ key: topicKey, name: 'Test Topic', }); const nonExistentSubscriptionIdentifier = 'non-existent-identifier'; const { error } = await expectSdkExceptionGeneric(() => novuClient.topics.subscriptions.update({ topicKey, identifier: nonExistentSubscriptionIdentifier, updateTopicSubscriptionRequestDto: { preferences: [ { filter: { workflowIds: ['workflow-1'] }, enabled: true, }, ], }, }) ); expect(error, 'Should have returned an error').to.exist; expect(error?.statusCode, 'Should be 404 error').to.equal(404); }); it('should return 404 when topic does not exist', async () => { const nonExistentTopicKey = `non-existent-topic-${Date.now()}`; const nonExistentSubscriptionIdentifier = 'non-existent-identifier'; const { error } = await expectSdkExceptionGeneric(() => novuClient.topics.subscriptions.update({ topicKey: nonExistentTopicKey, identifier: nonExistentSubscriptionIdentifier, updateTopicSubscriptionRequestDto: { preferences: [ { filter: { workflowIds: ['workflow-1'] }, enabled: true, }, ], }, }) ); expect(error, 'Should have returned an error').to.exist; expect(error?.statusCode, 'Should be 404 error').to.equal(404); }); it('should handle empty update request', async () => { const topicKey = `topic-key-empty-${Date.now()}`; await novuClient.topics.create({ key: topicKey, name: 'Test Topic', }); const subscriptionResponse = await novuClient.topics.subscriptions.create( { subscriberIds: [subscriber1.subscriberId], preferences: [ { filter: { workflowIds: ['workflow-1'] }, enabled: true, }, ], }, topicKey ); const subscriptionIdentifier = subscriptionResponse.result.data[0].identifier; const updateResponse = await novuClient.topics.subscriptions.update({ topicKey, identifier: subscriptionIdentifier, updateTopicSubscriptionRequestDto: {}, }); expect(updateResponse).to.exist; expect(updateResponse.result.identifier).to.equal(subscriptionIdentifier); }); it('should update subscription with custom condition preferences', async () => { const topicKey = `topic-key-custom-${Date.now()}`; await novuClient.topics.create({ key: topicKey, name: 'Test Topic', }); const subscriptionResponse = await novuClient.topics.subscriptions.create( { subscriberIds: [subscriber1.subscriberId], }, topicKey ); const subscriptionIdentifier = subscriptionResponse.result.data[0].identifier; const customCondition = { and: [{ '==': [{ var: 'priority' }, 'high'] }, { '>': [{ var: 'amount' }, 100] }], }; const updateResponse = await novuClient.topics.subscriptions.update({ topicKey, identifier: subscriptionIdentifier, updateTopicSubscriptionRequestDto: { preferences: [ { filter: { workflowIds: ['workflow-1'], tags: ['important'] }, condition: customCondition, enabled: true, }, ], }, }); expect(updateResponse).to.exist; expect(updateResponse.result.identifier).to.equal(subscriptionIdentifier); expect(updateResponse.result.preferences).to.exist; expect(updateResponse.result.preferences?.length).to.be.greaterThan(0); }); it('should update subscription name', async () => { const topicKey = `topic-key-name-${Date.now()}`; await novuClient.topics.create({ key: topicKey, name: 'Test Topic', }); const subscriptionResponse = await novuClient.topics.subscriptions.create( { subscriberIds: [subscriber1.subscriberId], }, topicKey ); const subscriptionIdentifier = subscriptionResponse.result.data[0].identifier; const updateResponse = await novuClient.topics.subscriptions.update({ topicKey, identifier: subscriptionIdentifier, updateTopicSubscriptionRequestDto: { name: 'Updated Subscription Name', }, }); expect(updateResponse).to.exist; expect(updateResponse.result.identifier).to.equal(subscriptionIdentifier); const subscription = await topicSubscribersRepository.findOne({ identifier: subscriptionIdentifier, _environmentId: session.environment._id, _organizationId: session.organization._id, }); expect(subscription?.name).to.equal('Updated Subscription Name'); }); }); ================================================ FILE: apps/api/src/app/topics-v2/e2e/update-topic.e2e.ts ================================================ import { Novu } from '@novu/api'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('Update topic by key - /v2/topics/:topicKey (PATCH) #novu-v2', async () => { let session: UserSession; let novuClient: Novu; const topicKey = `topic-key-${Date.now()}`; const initialName = 'Initial Topic Name'; let topicId: string; before(async () => { session = new UserSession(); await session.initialize(); novuClient = initNovuClassSdk(session); // Create a topic to update later const createResponse = await novuClient.topics.create({ key: topicKey, name: initialName, }); topicId = createResponse.result.id; }); it('should update a topic by its key', async () => { const updatedName = 'Updated Topic Name'; const response = await novuClient.topics.update( { name: updatedName, }, topicKey ); expect(response.result).to.exist; expect(response.result.id).to.equal(topicId); expect(response.result.key).to.equal(topicKey); expect(response.result.name).to.equal(updatedName); expect(response.result).to.have.property('createdAt'); expect(response.result).to.have.property('updatedAt'); // Verify the update persisted by fetching the topic const getResponse = await novuClient.topics.get(topicKey); expect(getResponse.result).to.exist; expect(getResponse.result.name).to.equal(updatedName); }); it('should return 404 for updating a non-existent topic key', async () => { const nonExistentKey = 'non-existent-topic-key'; try { await novuClient.topics.update( { name: 'New Name', }, nonExistentKey ); /* If we reach here, the test failed */ expect.fail('Should have thrown an error for non-existent topic'); } catch (error) { expect(error.statusCode).to.equal(404); const message = error.response?.data?.message || error.message || error.data?.message; expect(message).to.include(nonExistentKey); } }); }); ================================================ FILE: apps/api/src/app/topics-v2/e2e/upsert-topic.e2e.ts ================================================ import { Novu } from '@novu/api'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('Upsert topic - /v2/topics (POST) #novu-v2', async () => { let session: UserSession; let novuClient: Novu; beforeEach(async () => { session = new UserSession(); await session.initialize(); novuClient = initNovuClassSdk(session); }); it('should create a new topic when it does not exist', async () => { const key = `topic-key-${Date.now()}`; const name = 'Test Topic Name'; const response = await novuClient.topics.create({ key, name, }); expect(response.result).to.exist; expect(response.result).to.have.property('id'); expect(response.result.key).to.equal(key); expect(response.result.name).to.equal(name); expect(response.result).to.have.property('createdAt'); expect(response.result).to.have.property('updatedAt'); }); it('should update an existing topic when it already exists', async () => { // First create a topic const key = `topic-key-${Date.now()}`; const originalName = 'Original Name'; const createResponse = await novuClient.topics.create({ key, name: originalName, }); expect(createResponse.result).to.exist; const originalId = createResponse.result.id; // Now update the same topic by creating with the same key const updatedName = 'Updated Name'; const updateResponse = await novuClient.topics.update( { name: updatedName, }, key ); expect(updateResponse.result).to.exist; expect(updateResponse.result.id).to.equal(originalId); expect(updateResponse.result.key).to.equal(key); expect(updateResponse.result.name).to.equal(updatedName); // Verify the update persisted by fetching the topic const getResponse = await novuClient.topics.get(key); expect(getResponse.result.name).to.equal(updatedName); }); }); ================================================ FILE: apps/api/src/app/topics-v2/topics-v2.module.ts ================================================ import { Module } from '@nestjs/common'; import { GetPreferences } from '@novu/application-generic'; import { ContextRepository } from '@novu/dal'; import { SharedModule } from '../shared/shared.module'; import { SubscriptionsModule } from '../subscriptions/subscriptions.module'; import { TopicsController } from './topics.controller'; import { USE_CASES } from './usecases'; @Module({ imports: [SharedModule, SubscriptionsModule], controllers: [TopicsController], providers: [...USE_CASES, GetPreferences, ContextRepository], exports: [...USE_CASES], }) export class TopicsV2Module {} ================================================ FILE: apps/api/src/app/topics-v2/topics.controller.ts ================================================ import { Body, ClassSerializerInterceptor, Controller, Delete, Get, HttpCode, HttpException, HttpStatus, Param, Patch, Post, Query, Res, UseInterceptors, } from '@nestjs/common'; import { ApiOperation, ApiParam, ApiQuery, ApiTags } from '@nestjs/swagger'; import { ExternalApiAccessible, RequirePermissions } from '@novu/application-generic'; import { ApiRateLimitCategoryEnum, PermissionsEnum, UserSessionData } from '@novu/shared'; import { Response } from 'express'; import { RequireAuthentication } from '../auth/framework/auth.decorator'; import { ThrottlerCategory } from '../rate-limiting/guards/throttler.decorator'; import { DirectionEnum } from '../shared/dtos/base-responses'; import { SubscriptionDetailsResponseDto } from '../shared/dtos/subscription-details-response.dto'; import { GroupPreferenceFilterDto, WorkflowPreferenceRequestDto, } from '../shared/dtos/subscriptions/create-subscriptions.dto'; import { CreateSubscriptionsResponseDto, SubscriptionResponseDto, } from '../shared/dtos/subscriptions/create-subscriptions-response.dto'; import { ApiCommonResponses, ApiResponse } from '../shared/framework/response.decorator'; import { SdkGroupName, SdkMethodName } from '../shared/framework/swagger/sdk.decorators'; import { UserSession } from '../shared/framework/user.decorator'; import { CreateSubscriptionsCommand, CreateSubscriptionsUsecase } from '../subscriptions/usecases/create-subscriptions'; import { GetSubscriptionCommand } from '../subscriptions/usecases/get-subscription/get-subscription.command'; import { GetSubscription } from '../subscriptions/usecases/get-subscription/get-subscription.usecase'; import { UpdateSubscriptionCommand, UpdateSubscriptionUsecase } from '../subscriptions/usecases/update-subscription'; import { CreateTopicSubscriptionsRequestDto } from './dtos/create-topic-subscriptions.dto'; import { CreateUpdateTopicRequestDto } from './dtos/create-update-topic.dto'; import { DeleteTopicResponseDto } from './dtos/delete-topic-response.dto'; import { DeleteTopicSubscriberIdentifierDto, DeleteTopicSubscriptionsRequestDto, } from './dtos/delete-topic-subscriptions.dto'; import { DeleteTopicSubscriptionsResponseDto } from './dtos/delete-topic-subscriptions-response.dto'; import { ListTopicSubscriptionsQueryDto } from './dtos/list-topic-subscriptions-query.dto'; import { ListTopicSubscriptionsResponseDto } from './dtos/list-topic-subscriptions-response.dto'; import { ListTopicsQueryDto } from './dtos/list-topics-query.dto'; import { ListTopicsResponseDto } from './dtos/list-topics-response.dto'; import { TopicResponseDto } from './dtos/topic-response.dto'; import { UpdateTopicRequestDto } from './dtos/update-topic.dto'; import { UpdateTopicSubscriptionRequestDto } from './dtos/update-topic-subscription.dto'; import { DeleteTopicCommand } from './usecases/delete-topic/delete-topic.command'; import { DeleteTopicUseCase } from './usecases/delete-topic/delete-topic.usecase'; import { DeleteTopicSubscriptionsCommand } from './usecases/delete-topic-subscriptions/delete-topic-subscriptions.command'; import { DeleteTopicSubscriptionsUsecase } from './usecases/delete-topic-subscriptions/delete-topic-subscriptions.usecase'; import { GetTopicCommand } from './usecases/get-topic/get-topic.command'; import { GetTopicUseCase } from './usecases/get-topic/get-topic.usecase'; import { ListTopicSubscriptionsCommand } from './usecases/list-topic-subscriptions/list-topic-subscriptions.command'; import { ListTopicSubscriptionsUseCase } from './usecases/list-topic-subscriptions/list-topic-subscriptions.usecase'; import { ListTopicsCommand } from './usecases/list-topics/list-topics.command'; import { ListTopicsUseCase } from './usecases/list-topics/list-topics.usecase'; import { UpdateTopicCommand } from './usecases/update-topic/update-topic.command'; import { UpdateTopicUseCase } from './usecases/update-topic/update-topic.usecase'; import { UpsertTopicCommand } from './usecases/upsert-topic/upsert-topic.command'; import { UpsertTopicUseCase } from './usecases/upsert-topic/upsert-topic.usecase'; @ThrottlerCategory(ApiRateLimitCategoryEnum.CONFIGURATION) @Controller({ path: '/topics', version: '2' }) @UseInterceptors(ClassSerializerInterceptor) @RequireAuthentication() @ApiTags('Topics') @SdkGroupName('Topics') @ApiCommonResponses() export class TopicsController { constructor( private listTopicsUsecase: ListTopicsUseCase, private upsertTopicUsecase: UpsertTopicUseCase, private getTopicUsecase: GetTopicUseCase, private updateTopicUsecase: UpdateTopicUseCase, private deleteTopicUsecase: DeleteTopicUseCase, private listTopicSubscriptionsUsecase: ListTopicSubscriptionsUseCase, private createSubscriptionsUsecase: CreateSubscriptionsUsecase, private deleteTopicSubscriptionsUsecase: DeleteTopicSubscriptionsUsecase, private updateSubscriptionUsecase: UpdateSubscriptionUsecase, private getSubscriptionUsecase: GetSubscription ) {} @Get('') @ExternalApiAccessible() @SdkMethodName('list') @ApiOperation({ summary: 'List all topics', description: `This api returns a paginated list of topics. Topics can be filtered by **key**, **name**, or **includeCursor** to paginate through the list. Checkout all available filters in the query section.`, }) @ApiResponse(ListTopicsResponseDto) @RequirePermissions(PermissionsEnum.TOPIC_READ) async listTopics( @UserSession() user: UserSessionData, @Query() query: ListTopicsQueryDto ): Promise { return await this.listTopicsUsecase.execute( ListTopicsCommand.create({ user, environmentId: user.environmentId, organizationId: user.organizationId, limit: Number(query.limit || '10'), after: query.after, before: query.before, orderDirection: query.orderDirection || DirectionEnum.DESC, orderBy: query.orderBy || '_id', key: query.key, name: query.name, includeCursor: query.includeCursor, }) ); } @Post('') @ExternalApiAccessible() @ApiOperation({ summary: 'Create a topic', description: `Creates a new topic if it does not exist, or updates an existing topic if it already exists. Use ?failIfExists=true to prevent updates.`, }) @ApiResponse(TopicResponseDto, 201) @ApiResponse(TopicResponseDto, 200) @ApiResponse(TopicResponseDto, 409, false, false, { description: 'Topic already exists (when query param failIfExists=true)', }) @ApiQuery({ name: 'failIfExists', required: false, type: Boolean, description: 'If true, the request will fail if a topic with the same key already exists', }) @SdkMethodName('create') @RequirePermissions(PermissionsEnum.TOPIC_WRITE) async upsertTopic( @UserSession() user: UserSessionData, @Body() body: CreateUpdateTopicRequestDto, @Res({ passthrough: true }) response: Response, @Query('failIfExists') failIfExists?: boolean ): Promise { const result = await this.upsertTopicUsecase.execute( UpsertTopicCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, key: body.key, name: body.name, failIfExists, }) ); if (result.created) { response.status(HttpStatus.CREATED); } return result.topic; } @Get('/:topicKey') @ExternalApiAccessible() @SdkMethodName('get') @ApiOperation({ summary: 'Retrieve a topic', description: `Retrieve a topic by its unique key identifier **topicKey**`, }) @ApiParam({ name: 'topicKey', description: 'The key identifier of the topic', type: String }) @ApiResponse(TopicResponseDto, 200) @RequirePermissions(PermissionsEnum.TOPIC_READ) async getTopic(@UserSession() user: UserSessionData, @Param('topicKey') topicKey: string): Promise { return await this.getTopicUsecase.execute( GetTopicCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, topicKey, }) ); } @Patch('/:topicKey') @ExternalApiAccessible() @SdkMethodName('update') @ApiOperation({ summary: 'Update a topic', description: `Update a topic name by its unique key identifier **topicKey**`, }) @ApiParam({ name: 'topicKey', description: 'The key identifier of the topic', type: String }) @ApiResponse(TopicResponseDto, 200) @RequirePermissions(PermissionsEnum.TOPIC_WRITE) async updateTopic( @UserSession() user: UserSessionData, @Param('topicKey') topicKey: string, @Body() body: UpdateTopicRequestDto ): Promise { return await this.updateTopicUsecase.execute( UpdateTopicCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, userId: user._id, topicKey, name: body.name, }) ); } @Delete('/:topicKey') @ExternalApiAccessible() @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Delete a topic', description: `Delete a topic by its unique key identifier **topicKey**. This action is irreversible and will remove all subscriptions to the topic.`, }) @ApiParam({ name: 'topicKey', description: 'The key identifier of the topic', type: String }) @ApiResponse(DeleteTopicResponseDto, 200, false, true, { description: 'Topic deleted successfully', }) @RequirePermissions(PermissionsEnum.TOPIC_WRITE) async deleteTopic( @UserSession() user: UserSessionData, @Param('topicKey') topicKey: string ): Promise { await this.deleteTopicUsecase.execute( DeleteTopicCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, userId: user._id, topicKey, force: true, }) ); return { acknowledged: true, }; } @Get('/:topicKey/subscriptions') @ExternalApiAccessible() @SdkGroupName('Topics.Subscriptions') @ApiOperation({ summary: `List topic subscriptions`, description: `List all subscriptions of subscribers for a topic. Checkout all available filters in the query section.`, }) @ApiParam({ name: 'topicKey', description: 'The key identifier of the topic', type: String }) @ApiResponse(ListTopicSubscriptionsResponseDto, 200) @RequirePermissions(PermissionsEnum.TOPIC_READ) async listTopicSubscriptions( @UserSession() user: UserSessionData, @Param('topicKey') topicKey: string, @Query() query: ListTopicSubscriptionsQueryDto ): Promise { return await this.listTopicSubscriptionsUsecase.execute( ListTopicSubscriptionsCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, topicKey, subscriberId: query.subscriberId, contextKeys: query.contextKeys, limit: query.limit ? Number(query.limit) : 10, after: query.after, before: query.before, orderDirection: query.orderDirection === DirectionEnum.ASC ? 1 : -1, orderBy: query.orderBy || '_id', includeCursor: query.includeCursor, }) ); } @Post('/:topicKey/subscriptions') @ExternalApiAccessible() @SdkGroupName('Topics.Subscriptions') @SdkMethodName('create') @ApiOperation({ summary: 'Create topic subscriptions', description: `This api will create subscription for subscriberIds for a topic. Its like subscribing to a common interest group. if topic does not exist, it will be created.`, }) @ApiParam({ name: 'topicKey', description: 'The key identifier of the topic', type: String }) @ApiResponse(CreateSubscriptionsResponseDto, 201, false, true, { description: 'Subscriptions created successfully', }) @RequirePermissions(PermissionsEnum.TOPIC_WRITE) async createTopicSubscriptions( @UserSession() user: UserSessionData, @Param('topicKey') topicKey: string, @Body() body: CreateTopicSubscriptionsRequestDto ): Promise { const result = await this.createSubscriptionsUsecase.execute( CreateSubscriptionsCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, userId: user._id, topicKey, subscriptions: this.mapSubscriptions(body.subscriptions || body.subscriberIds || []), name: body.name, preferences: body.preferences ? this.convertPreferencesToGroupFilters(body.preferences) : undefined, context: body.context, }) ); const typeSafeResult: CreateSubscriptionsResponseDto = { data: result.data.map((item) => ({ ...item, createdAt: item.createdAt || '', updatedAt: item.updatedAt || '', contextKeys: item.contextKeys, })), meta: result.meta, errors: result.errors, }; if (typeSafeResult.meta.failed > 0 && typeSafeResult.meta.successful === 0) { // All subscriptions failed but with valid request format throw new HttpException(typeSafeResult, HttpStatus.BAD_REQUEST); } return typeSafeResult; } @Delete('/:topicKey/subscriptions') @ExternalApiAccessible() @SdkGroupName('Topics.Subscriptions') @SdkMethodName('delete') @ApiOperation({ summary: 'Delete topic subscriptions', description: 'Delete subscriptions for subscriberIds for a topic.', }) @ApiParam({ name: 'topicKey', description: 'The key identifier of the topic', type: String }) @ApiResponse(DeleteTopicSubscriptionsResponseDto, 200, false, false, { description: 'Subscriptions deleted successfully', }) @RequirePermissions(PermissionsEnum.TOPIC_WRITE) async deleteTopicSubscriptions( @UserSession() user: UserSessionData, @Param('topicKey') topicKey: string, @Body() body: DeleteTopicSubscriptionsRequestDto ): Promise { const result = await this.deleteTopicSubscriptionsUsecase.execute( DeleteTopicSubscriptionsCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, userId: user._id, topicKey, subscriptions: this.mapDeleteSubscriptions(body.subscriptions || body.subscriberIds || []), }) ); // Ensure createdAt and updatedAt are always strings to match SubscriptionDto const typeSafeResult: DeleteTopicSubscriptionsResponseDto = { data: result.data.map((item) => ({ ...item, createdAt: item.createdAt || '', updatedAt: item.updatedAt || '', })), meta: result.meta, errors: result.errors, }; if (typeSafeResult.meta.failed > 0 && typeSafeResult.meta.successful === 0) { // All subscriptions failed but with valid request format throw new HttpException(typeSafeResult, HttpStatus.BAD_REQUEST); } // All subscriptions were successfully deleted return typeSafeResult; } @Get('/:topicKey/subscriptions/:identifier') @ExternalApiAccessible() @SdkGroupName('Topics.Subscriptions') @SdkMethodName('getSubscription') @ApiOperation({ summary: 'Retrieve a topic subscription', description: `Retrieve a subscription by its unique identifier for a topic.`, }) @ApiParam({ name: 'topicKey', description: 'The key identifier of the topic', type: String }) @ApiParam({ name: 'identifier', description: 'The unique identifier of the subscription', type: String, }) @ApiResponse(SubscriptionDetailsResponseDto, 200) @RequirePermissions(PermissionsEnum.TOPIC_READ) async getTopicSubscription( @UserSession() user: UserSessionData, @Param('topicKey') topicKey: string, @Param('identifier') identifier: string, @Res({ passthrough: true }) res: Response ): Promise { const result = await this.getSubscriptionUsecase.execute( GetSubscriptionCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, topicKey, identifier, }) ); if (!result) { res.status(HttpStatus.NO_CONTENT); return; } return result; } @Patch('/:topicKey/subscriptions/:identifier') @ExternalApiAccessible() @SdkGroupName('Topics.Subscriptions') @SdkMethodName('update') @ApiOperation({ summary: 'Update a topic subscription', description: `Update a subscription by its unique identifier for a topic. You can update the preferences and name associated with the subscription.`, }) @ApiParam({ name: 'topicKey', description: 'The key identifier of the topic', type: String }) @ApiParam({ name: 'identifier', description: 'The unique identifier of the subscription', type: String, }) @ApiResponse(SubscriptionResponseDto, 200) @RequirePermissions(PermissionsEnum.TOPIC_WRITE) async updateTopicSubscription( @UserSession() user: UserSessionData, @Param('topicKey') topicKey: string, @Param('identifier') identifier: string, @Body() body: UpdateTopicSubscriptionRequestDto ): Promise { return await this.updateSubscriptionUsecase.execute( UpdateSubscriptionCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, userId: user._id, topicKey, identifier, name: body.name, preferences: body.preferences ? this.convertPreferencesToGroupFilters(body.preferences) : undefined, }) ); } private mapSubscriptions( subscriptions: Array ): Array<{ identifier?: string; subscriberId: string; name?: string }> { return subscriptions.map((subscription) => { if (typeof subscription === 'string') { return { subscriberId: subscription, }; } return subscription; }); } private mapDeleteSubscriptions( subscriptions: Array ): Array<{ identifier?: string; subscriberId?: string; name?: string }> { return subscriptions.map((subscription) => { if (typeof subscription === 'string') { return { subscriberId: subscription, }; } return subscription; }); } private convertPreferencesToGroupFilters( preferences: Array ): Array { return preferences.map((preference) => { if (typeof preference === 'string') { return { filter: { workflowIds: [preference], }, }; } if (this.isGroupPreferenceFilter(preference)) { return preference; } return { filter: { workflowIds: [preference.workflowId], }, condition: preference.condition, }; }); } private isGroupPreferenceFilter( preference: WorkflowPreferenceRequestDto | GroupPreferenceFilterDto ): preference is GroupPreferenceFilterDto { return 'filter' in preference; } } ================================================ FILE: apps/api/src/app/topics-v2/usecases/delete-topic/delete-topic.command.ts ================================================ import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; export class DeleteTopicCommand extends EnvironmentWithUserCommand { @IsString() @IsNotEmpty() topicKey: string; @IsBoolean() @IsOptional() force?: boolean; } ================================================ FILE: apps/api/src/app/topics-v2/usecases/delete-topic/delete-topic.usecase.ts ================================================ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { InstrumentUsecase } from '@novu/application-generic'; import { TopicRepository, TopicSubscribersRepository } from '@novu/dal'; import { DeleteTopicCommand } from './delete-topic.command'; @Injectable() export class DeleteTopicUseCase { constructor( private topicRepository: TopicRepository, private topicSubscribersRepository: TopicSubscribersRepository ) {} @InstrumentUsecase() async execute(command: DeleteTopicCommand): Promise { const topic = await this.topicRepository.findTopicByKey( command.topicKey, command.organizationId, command.environmentId ); if (!topic) { throw new NotFoundException(`Topic with key ${command.topicKey} not found`); } const hasSubscribers = await this.topicSubscribersRepository.find( { _topicId: topic._id, _environmentId: command.environmentId, _organizationId: command.organizationId, }, '_id', { limit: 1, } ); if (hasSubscribers.length > 0 && !command.force) { throw new BadRequestException( `Topic has subscribers. Use force=true parameter to delete the topic and its subscriptions.` ); } if (hasSubscribers.length > 0) { await this.topicSubscribersRepository.delete({ _topicId: topic._id, _environmentId: command.environmentId, _organizationId: command.organizationId, }); } await this.topicRepository.delete({ _id: topic._id, _environmentId: command.environmentId, _organizationId: command.organizationId, }); } } ================================================ FILE: apps/api/src/app/topics-v2/usecases/delete-topic-subscriptions/delete-topic-subscriptions.command.ts ================================================ import { IsArray, IsDefined, IsOptional, IsString } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; export class DeleteTopicSubscriptionsCommand extends EnvironmentWithUserCommand { @IsString() @IsDefined() topicKey: string; /** * @deprecated Use subscriptions instead */ @IsArray() @IsOptional() subscriberIds?: string[]; @IsArray() @IsOptional() subscriptions?: Array<{ identifier?: string; subscriberId?: string }>; } ================================================ FILE: apps/api/src/app/topics-v2/usecases/delete-topic-subscriptions/delete-topic-subscriptions.usecase.ts ================================================ import { Injectable, NotFoundException } from '@nestjs/common'; import { InstrumentUsecase } from '@novu/application-generic'; import { PreferencesRepository, SubscriberEntity, SubscriberRepository, TopicEntity, TopicRepository, TopicSubscribersEntity, TopicSubscribersRepository, } from '@novu/dal'; import { PreferencesTypeEnum } from '@novu/shared'; import { DeleteTopicSubscriptionsResponseDto, SubscriptionDto, SubscriptionsDeleteErrorDto, } from '../../dtos/delete-topic-subscriptions-response.dto'; import { DeleteTopicSubscriptionsCommand } from './delete-topic-subscriptions.command'; interface SubscriptionLookupResult { foundSubscribers: SubscriberEntity[]; existingSubscriptions: TopicSubscribersEntity[]; errors: SubscriptionsDeleteErrorDto[]; } type ItemToDelete = { identifier?: string; subscriberId?: string }; @Injectable() export class DeleteTopicSubscriptionsUsecase { constructor( private topicRepository: TopicRepository, private topicSubscribersRepository: TopicSubscribersRepository, private subscriberRepository: SubscriberRepository, private preferencesRepository: PreferencesRepository ) {} @InstrumentUsecase() async execute(command: DeleteTopicSubscriptionsCommand): Promise { const topic = await this.topicRepository.findTopicByKey( command.topicKey, command.organizationId, command.environmentId ); if (!topic) { throw new NotFoundException(`Topic with key ${command.topicKey} not found`); } const subscriptions = command.subscriptions || []; if (subscriptions.length === 0) { return { data: [], meta: { totalCount: 0, successful: 0, failed: 0, }, }; } const itemsToDelete: ItemToDelete[] = subscriptions.filter( (sub): sub is ItemToDelete => !!(sub.identifier || sub.subscriberId) ); if (itemsToDelete.length === 0) { return { data: [], meta: { totalCount: subscriptions.length, successful: 0, failed: subscriptions.length, }, errors: subscriptions.map((sub) => ({ subscriberId: sub.subscriberId || 'unknown', identifier: sub.identifier || 'unknown', code: 'INVALID_REQUEST', message: 'Subscription identifier is required.', })), }; } return this.deleteSubscriptions(command, topic, subscriptions, itemsToDelete); } private async deleteSubscriptions( command: DeleteTopicSubscriptionsCommand, topic: TopicEntity, subscriptions: Array<{ identifier?: string; subscriberId?: string }>, itemsToDelete: ItemToDelete[] ): Promise { const lookupResult = await this.lookupSubscriptionsAndSubscribers(command, topic, subscriptions, itemsToDelete); if (lookupResult.existingSubscriptions.length === 0) { return { data: [], meta: { totalCount: subscriptions.length, successful: 0, failed: lookupResult.errors.length, }, errors: lookupResult.errors, }; } const subscriptionData = this.buildSubscriptionData(topic, lookupResult); await this.performDeletion(command, lookupResult.existingSubscriptions); return { data: subscriptionData, meta: { totalCount: subscriptions.length, successful: subscriptionData.length, failed: lookupResult.errors.length, }, errors: lookupResult.errors.length > 0 ? lookupResult.errors : undefined, }; } private async lookupSubscriptionsAndSubscribers( command: DeleteTopicSubscriptionsCommand, topic: TopicEntity, subscriptions: Array<{ identifier?: string; subscriberId?: string }>, itemsToDelete: ItemToDelete[] ): Promise { const identifiers = itemsToDelete.map((item) => item.identifier).filter((id): id is string => !!id); const subscriberIds = itemsToDelete.map((item) => item.subscriberId).filter((id): id is string => !!id); const hasIdentifiers = identifiers.length > 0; const hasSubscriberIds = subscriberIds.length > 0; if (hasIdentifiers && hasSubscriberIds) { return this.lookupByBoth(command, topic, subscriptions, identifiers, subscriberIds, itemsToDelete); } if (hasIdentifiers) { return this.lookupByIdentifiers(command, topic, identifiers, itemsToDelete); } return this.lookupBySubscriberIds(command, topic, subscriptions, subscriberIds); } private async lookupByBoth( command: DeleteTopicSubscriptionsCommand, topic: TopicEntity, subscriptions: Array<{ identifier?: string; subscriberId?: string }>, identifiers: string[], subscriberIds: string[], itemsToDelete: ItemToDelete[] ): Promise { const identifierResult = await this.lookupByIdentifiers(command, topic, identifiers, itemsToDelete); const subscriberIdResult = await this.lookupBySubscriberIds(command, topic, subscriptions, subscriberIds); const allFoundSubscribers = [...identifierResult.foundSubscribers]; const subscriberIdSet = new Set(allFoundSubscribers.map((sub) => sub._id.toString())); for (const subscriber of subscriberIdResult.foundSubscribers) { if (!subscriberIdSet.has(subscriber._id.toString())) { allFoundSubscribers.push(subscriber); } } const allExistingSubscriptions = [ ...identifierResult.existingSubscriptions, ...subscriberIdResult.existingSubscriptions, ]; const allErrors = [...identifierResult.errors, ...subscriberIdResult.errors]; return { foundSubscribers: allFoundSubscribers, existingSubscriptions: allExistingSubscriptions, errors: allErrors, }; } private async lookupByIdentifiers( command: DeleteTopicSubscriptionsCommand, topic: TopicEntity, identifiers: string[], itemsToDelete: ItemToDelete[] ): Promise { const errors: SubscriptionsDeleteErrorDto[] = []; const existingSubscriptions = await this.topicSubscribersRepository.find({ _environmentId: command.environmentId, _organizationId: command.organizationId, _topicId: topic._id, identifier: { $in: identifiers }, }); const existingIdentifiers = new Set(existingSubscriptions.map((sub) => sub.identifier).filter(Boolean)); const notFoundIdentifiers = identifiers.filter((id) => !existingIdentifiers.has(id)); for (const identifier of notFoundIdentifiers) { const item = itemsToDelete.find((item) => item.identifier === identifier); errors.push({ subscriberId: item?.subscriberId || 'unknown', code: 'SUBSCRIPTION_NOT_FOUND', message: `Subscription with identifier '${identifier}' not found.`, }); } const subscriberInternalIds = [...new Set(existingSubscriptions.map((sub) => sub._subscriberId))]; const foundSubscribers = subscriberInternalIds.length > 0 ? await this.subscriberRepository.find({ _environmentId: command.environmentId, _organizationId: command.organizationId, _id: { $in: subscriberInternalIds }, }) : []; return { foundSubscribers, existingSubscriptions, errors }; } private async lookupBySubscriberIds( command: DeleteTopicSubscriptionsCommand, topic: TopicEntity, subscriptions: Array<{ identifier?: string; subscriberId?: string }>, subscriberIds: string[] ): Promise { const errors: SubscriptionsDeleteErrorDto[] = []; const foundSubscribers = await this.subscriberRepository.searchByExternalSubscriberIds({ _environmentId: command.environmentId, _organizationId: command.organizationId, externalSubscriberIds: subscriberIds, }); const foundSubscriberIds = foundSubscribers.map((sub) => sub.subscriberId); const notFoundSubscriberIds = subscriberIds.filter((id) => !foundSubscriberIds.includes(id)); for (const subscriberId of notFoundSubscriberIds) { errors.push({ subscriberId, code: 'SUBSCRIBER_NOT_FOUND', message: `Subscriber with ID '${subscriberId}' could not be found.`, }); } if (foundSubscribers.length === 0) { return { foundSubscribers, existingSubscriptions: [], errors }; } const existingSubscriptions = await this.topicSubscribersRepository.find({ _environmentId: command.environmentId, _organizationId: command.organizationId, _topicId: topic._id, _subscriberId: { $in: foundSubscribers.map((sub) => sub._id) }, }); this.validateSubscriptions(subscriptions, foundSubscribers, existingSubscriptions, errors); return { foundSubscribers, existingSubscriptions, errors }; } private validateSubscriptions( subscriptions: Array<{ identifier?: string; subscriberId?: string }>, foundSubscribers: SubscriberEntity[], existingSubscriptions: TopicSubscribersEntity[], errors: SubscriptionsDeleteErrorDto[] ): void { const existingIdentifiers = new Set(existingSubscriptions.map((sub) => sub.identifier).filter(Boolean)); const existingSubscriberIdsSet = new Set(existingSubscriptions.map((sub) => sub._subscriberId.toString())); for (const subscription of subscriptions) { const subscriber = foundSubscribers.find((sub) => sub.subscriberId === subscription.subscriberId); if (!subscriber) continue; if (subscription.identifier) { if (!existingIdentifiers.has(subscription.identifier)) { errors.push({ subscriberId: subscriber.subscriberId, code: 'SUBSCRIPTION_NOT_FOUND', message: `Subscription with identifier '${subscription.identifier}' for subscriber '${subscriber.subscriberId}' not found.`, }); } } else { if (!existingSubscriberIdsSet.has(subscriber._id.toString())) { errors.push({ subscriberId: subscriber.subscriberId, code: 'SUBSCRIPTION_NOT_FOUND', message: `Subscription for subscriber '${subscriber.subscriberId}' not found.`, }); } } } } private buildSubscriptionData(topic: TopicEntity, lookupResult: SubscriptionLookupResult): SubscriptionDto[] { return lookupResult.existingSubscriptions.map((subscription) => { const subscriber = lookupResult.foundSubscribers.find( (sub) => sub._id.toString() === subscription._subscriberId.toString() ); return { _id: subscription._id, identifier: subscription.identifier, topic: { _id: topic._id, key: topic.key, name: topic.name, }, subscriber: subscriber ? { _id: subscriber._id, subscriberId: subscriber.subscriberId, avatar: subscriber.avatar, firstName: subscriber.firstName, lastName: subscriber.lastName, email: subscriber.email, createdAt: subscriber.createdAt, updatedAt: subscriber.updatedAt, } : null, contextKeys: subscription.contextKeys, createdAt: subscription.createdAt ?? new Date().toISOString(), updatedAt: subscription.updatedAt ?? new Date().toISOString(), }; }); } private async performDeletion( command: DeleteTopicSubscriptionsCommand, existingSubscriptions: TopicSubscribersEntity[] ): Promise { await this.topicSubscribersRepository.withTransaction(async () => { const subscriptionIds = existingSubscriptions.map((sub) => sub._id); await this.preferencesRepository.delete({ _environmentId: command.environmentId, _topicSubscriptionId: { $in: subscriptionIds }, type: PreferencesTypeEnum.SUBSCRIPTION_SUBSCRIBER_WORKFLOW, }); await this.topicSubscribersRepository.delete({ _organizationId: command.organizationId, _id: { $in: subscriptionIds }, }); }); } } ================================================ FILE: apps/api/src/app/topics-v2/usecases/delete-topic-subscriptions/index.ts ================================================ export * from './delete-topic-subscriptions.command'; export * from './delete-topic-subscriptions.usecase'; ================================================ FILE: apps/api/src/app/topics-v2/usecases/get-topic/get-topic.command.ts ================================================ import { IsNotEmpty, IsString } from 'class-validator'; import { EnvironmentCommand } from '../../../shared/commands/project.command'; export class GetTopicCommand extends EnvironmentCommand { @IsString() @IsNotEmpty() topicKey: string; } ================================================ FILE: apps/api/src/app/topics-v2/usecases/get-topic/get-topic.usecase.ts ================================================ import { Injectable, NotFoundException } from '@nestjs/common'; import { InstrumentUsecase } from '@novu/application-generic'; import { TopicRepository } from '@novu/dal'; import { TopicResponseDto } from '../../dtos/topic-response.dto'; import { mapTopicEntityToDto } from '../list-topics/map-topic-entity-to.dto'; import { GetTopicCommand } from './get-topic.command'; @Injectable() export class GetTopicUseCase { constructor(private topicRepository: TopicRepository) {} @InstrumentUsecase() async execute(command: GetTopicCommand): Promise { const topic = await this.topicRepository.findTopicByKey( command.topicKey, command.organizationId, command.environmentId ); if (!topic) { throw new NotFoundException(`Topic with key ${command.topicKey} not found`); } return mapTopicEntityToDto(topic); } } ================================================ FILE: apps/api/src/app/topics-v2/usecases/index.ts ================================================ import { GetSubscription } from '../../subscriptions/usecases/get-subscription/get-subscription.usecase'; import { DeleteTopicUseCase } from './delete-topic/delete-topic.usecase'; import { DeleteTopicSubscriptionsUsecase } from './delete-topic-subscriptions/delete-topic-subscriptions.usecase'; import { GetTopicUseCase } from './get-topic/get-topic.usecase'; import { ListSubscriberSubscriptionsUseCase } from './list-subscriber-subscriptions/list-subscriber-subscriptions.usecase'; import { ListTopicSubscriptionsUseCase } from './list-topic-subscriptions/list-topic-subscriptions.usecase'; import { ListTopicsUseCase } from './list-topics/list-topics.usecase'; import { UpdateTopicUseCase } from './update-topic/update-topic.usecase'; import { UpsertTopicUseCase } from './upsert-topic/upsert-topic.usecase'; export const USE_CASES = [ DeleteTopicSubscriptionsUsecase, DeleteTopicUseCase, GetTopicUseCase, ListSubscriberSubscriptionsUseCase, ListTopicSubscriptionsUseCase, ListTopicsUseCase, UpdateTopicUseCase, UpsertTopicUseCase, GetSubscription, ]; ================================================ FILE: apps/api/src/app/topics-v2/usecases/list-subscriber-subscriptions/index.ts ================================================ export * from './list-subscriber-subscriptions.command'; export * from './list-subscriber-subscriptions.usecase'; ================================================ FILE: apps/api/src/app/topics-v2/usecases/list-subscriber-subscriptions/list-subscriber-subscriptions.command.ts ================================================ import { IsArray, IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { EnvironmentCommand } from '../../../shared/commands/project.command'; export class ListSubscriberSubscriptionsCommand extends EnvironmentCommand { @IsString() @IsNotEmpty() subscriberId: string; @IsString() @IsOptional() topicKey?: string; @IsOptional() @IsArray() @IsString({ each: true }) contextKeys?: string[]; @IsOptional() limit?: number; @IsOptional() after?: string; @IsOptional() before?: string; @IsOptional() orderBy?: string; @IsOptional() orderDirection?: number; @IsOptional() includeCursor?: boolean; } ================================================ FILE: apps/api/src/app/topics-v2/usecases/list-subscriber-subscriptions/list-subscriber-subscriptions.usecase.ts ================================================ import { Injectable, NotFoundException } from '@nestjs/common'; import { InstrumentUsecase } from '@novu/application-generic'; import { SubscriberRepository, TopicSubscribersEntity, TopicSubscribersRepository } from '@novu/dal'; import { DirectionEnum, EnvironmentId } from '@novu/shared'; import { ListTopicSubscriptionsResponseDto } from '../../dtos/list-topic-subscriptions-response.dto'; import { TopicSubscriptionResponseDto } from '../../dtos/topic-subscription-response.dto'; import { mapTopicSubscriptionsToDto } from '../list-topics/map-topic-entity-to.dto'; import { ListSubscriberSubscriptionsCommand } from './list-subscriber-subscriptions.command'; @Injectable() export class ListSubscriberSubscriptionsUseCase { constructor( private topicSubscribersRepository: TopicSubscribersRepository, private subscriberRepository: SubscriberRepository ) {} @InstrumentUsecase() async execute(command: ListSubscriberSubscriptionsCommand): Promise { // Find the subscriber to validate it exists const subscriber = await this.subscriberRepository.findBySubscriberId(command.environmentId, command.subscriberId); if (!subscriber) { throw new NotFoundException('Subscriber not found'); } if (command.before && command.after) { throw new Error('Cannot specify both "before" and "after" cursors at the same time.'); } // Use the repository method for pagination const subscriptionsPagination = await this.topicSubscribersRepository.findTopicSubscriptionsWithPagination({ environmentId: command.environmentId, organizationId: command.organizationId, topicKey: command.topicKey, subscriberId: command.subscriberId, contextKeys: command.contextKeys, limit: command.limit || 10, before: command.before, after: command.after, orderDirection: command.orderDirection === 1 ? DirectionEnum.ASC : DirectionEnum.DESC, includeCursor: command.includeCursor, }); // Build detailed response with topic and subscriber info const subscriptionsWithDetails = await this.populateSubscriptionsData( subscriptionsPagination.data, command.environmentId ); return { data: subscriptionsWithDetails, next: subscriptionsPagination.next, previous: subscriptionsPagination.previous, totalCount: subscriptionsPagination.totalCount, totalCountCapped: subscriptionsPagination.totalCountCapped, }; } private async populateSubscriptionsData( subscriptions: TopicSubscribersEntity[], environmentId: EnvironmentId ): Promise { if (subscriptions.length === 0) { return []; } // Get the subscriber from the first subscription since it's always the same subscriber const subscriberId = subscriptions[0]._subscriberId; const subscriber = await this.subscriberRepository.findOne({ _environmentId: environmentId, _id: subscriberId, }); if (!subscriber) { return []; } // Need unique topic IDs const topicKeys = subscriptions.map((subscription) => subscription.topicKey); if (topicKeys.length === 0) { return []; } // Find all topic information using the topic keys const topics = await this.topicSubscribersRepository.findTopicsByTopicKeys(environmentId, topicKeys); // Create a map for quick lookup const topicsMap = new Map(topics.map((result) => [result._id, result.topic])); // Map subscriptions to response DTOs with topic and subscriber details return subscriptions .map((subscription) => { const topic = topicsMap.get(subscription.topicKey); if (!topic) { return null; } return mapTopicSubscriptionsToDto(subscription, subscriber, topic); }) .filter(Boolean) as TopicSubscriptionResponseDto[]; } } ================================================ FILE: apps/api/src/app/topics-v2/usecases/list-topic-subscriptions/list-topic-subscriptions.command.ts ================================================ import { IsArray, IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { EnvironmentCommand } from '../../../shared/commands/project.command'; export class ListTopicSubscriptionsCommand extends EnvironmentCommand { @IsString() @IsNotEmpty() topicKey: string; @IsString() @IsOptional() subscriberId?: string; @IsOptional() @IsArray() @IsString({ each: true }) contextKeys?: string[]; @IsOptional() limit?: number; @IsOptional() after?: string; @IsOptional() before?: string; @IsOptional() orderBy?: string; @IsOptional() orderDirection?: number; @IsOptional() includeCursor?: boolean; } ================================================ FILE: apps/api/src/app/topics-v2/usecases/list-topic-subscriptions/list-topic-subscriptions.usecase.ts ================================================ import { Injectable, NotFoundException } from '@nestjs/common'; import { InstrumentUsecase } from '@novu/application-generic'; import { SubscriberRepository, TopicEntity, TopicRepository, TopicSubscribersEntity, TopicSubscribersRepository, } from '@novu/dal'; import { DirectionEnum, EnvironmentId } from '@novu/shared'; import { ListTopicSubscriptionsResponseDto } from '../../dtos/list-topic-subscriptions-response.dto'; import { TopicSubscriptionResponseDto } from '../../dtos/topic-subscription-response.dto'; import { mapTopicSubscriptionsToDto } from '../list-topics/map-topic-entity-to.dto'; import { ListTopicSubscriptionsCommand } from './list-topic-subscriptions.command'; @Injectable() export class ListTopicSubscriptionsUseCase { constructor( private topicRepository: TopicRepository, private topicSubscribersRepository: TopicSubscribersRepository, private subscriberRepository: SubscriberRepository ) {} @InstrumentUsecase() async execute(command: ListTopicSubscriptionsCommand): Promise { const topic = await this.topicRepository.findTopicByKey( command.topicKey, command.organizationId, command.environmentId ); if (!topic) { throw new NotFoundException(`Topic with key ${command.topicKey} not found`); } const subscriptionsPagination = await this.topicSubscribersRepository.findTopicSubscriptionsWithPagination({ environmentId: command.environmentId, organizationId: command.organizationId, topicKey: command.topicKey, subscriberId: command.subscriberId, contextKeys: command.contextKeys, limit: command.limit || 10, before: command.before, after: command.after, orderDirection: command.orderDirection === 1 ? DirectionEnum.ASC : DirectionEnum.DESC, includeCursor: command.includeCursor, }); // Build detailed response with topic and subscriber info const subscriptionsWithDetails = await this.populateSubscriptionsData( topic, subscriptionsPagination.data, command.environmentId ); return { data: subscriptionsWithDetails, next: subscriptionsPagination.next, previous: subscriptionsPagination.previous, totalCount: subscriptionsPagination.totalCount, totalCountCapped: subscriptionsPagination.totalCountCapped, }; } private async populateSubscriptionsData( topic: TopicEntity, subscriptions: TopicSubscribersEntity[], environmentId: EnvironmentId ): Promise { if (subscriptions.length === 0) { return []; } // Get all subscriber IDs from subscriptions const subscriberIds = subscriptions.map((subscription) => subscription._subscriberId); // Fetch all subscribers in a single query const subscribers = await this.subscriberRepository.find({ _environmentId: environmentId, _id: { $in: subscriberIds }, }); // Create a map for quick lookup const subscriberMap = new Map(subscribers.map((subscriber) => [subscriber._id, subscriber])); // Map subscriptions to response DTOs with topic and subscriber details return subscriptions .map((subscription) => { const subscriber = subscriberMap.get(subscription._subscriberId); if (!subscriber) { return null; } return mapTopicSubscriptionsToDto(subscription, subscriber, topic); }) .filter(Boolean) as TopicSubscriptionResponseDto[]; } } ================================================ FILE: apps/api/src/app/topics-v2/usecases/list-topics/list-topics.command.ts ================================================ import { CursorBasedPaginatedCommand } from '@novu/application-generic'; import { TopicEntity } from '@novu/dal'; import { IsMongoId, IsNotEmpty, IsOptional, IsString } from 'class-validator'; export class ListTopicsCommand extends CursorBasedPaginatedCommand { @IsString() @IsOptional() key?: string; @IsString() @IsOptional() name?: string; @IsString() @IsNotEmpty() @IsMongoId() environmentId: string; @IsString() @IsMongoId() @IsNotEmpty() organizationId: string; } ================================================ FILE: apps/api/src/app/topics-v2/usecases/list-topics/list-topics.usecase.ts ================================================ import { Injectable } from '@nestjs/common'; import { InstrumentUsecase } from '@novu/application-generic'; import { TopicRepository } from '@novu/dal'; import { DirectionEnum } from '../../../shared/dtos/base-responses'; import { ListTopicsResponseDto } from '../../dtos/list-topics-response.dto'; import { ListTopicsCommand } from './list-topics.command'; import { mapTopicEntityToDto } from './map-topic-entity-to.dto'; @Injectable() export class ListTopicsUseCase { constructor(private topicRepository: TopicRepository) {} @InstrumentUsecase() async execute(command: ListTopicsCommand): Promise { const pagination = await this.topicRepository.listTopics({ after: command.after, before: command.before, limit: command.limit, sortDirection: command.orderDirection === DirectionEnum.ASC ? 1 : -1, sortBy: command.orderBy, key: command.key, name: command.name, environmentId: command.environmentId, organizationId: command.organizationId, includeCursor: command.includeCursor, }); return { data: pagination.topics.map((topic) => mapTopicEntityToDto(topic)), next: pagination.next, previous: pagination.previous, totalCount: pagination.totalCount, totalCountCapped: pagination.totalCountCapped, }; } } ================================================ FILE: apps/api/src/app/topics-v2/usecases/list-topics/map-topic-entity-to.dto.ts ================================================ import { SubscriberEntity, TopicEntity, TopicSubscribersEntity } from '@novu/dal'; import { TopicResponseDto } from '../../dtos/topic-response.dto'; import { TopicSubscriptionResponseDto } from '../../dtos/topic-subscription-response.dto'; export function mapTopicEntityToDto(topicEntity: TopicEntity): TopicResponseDto { return { _id: String(topicEntity._id), name: topicEntity.name, key: topicEntity.key, createdAt: topicEntity.createdAt, updatedAt: topicEntity.updatedAt, }; } export function mapTopicSubscriptionsToDto( subscription: TopicSubscribersEntity, subscriber: SubscriberEntity, topic: TopicEntity ): TopicSubscriptionResponseDto { return { _id: String(subscription._id), identifier: subscription.identifier ?? '', topic: mapTopicEntityToDto(topic), createdAt: subscription.createdAt!, contextKeys: subscription.contextKeys, subscriber: { _id: String(subscriber._id), subscriberId: subscriber.subscriberId, firstName: subscriber.firstName, lastName: subscriber.lastName, email: subscriber.email, avatar: subscriber.avatar, }, }; } ================================================ FILE: apps/api/src/app/topics-v2/usecases/update-topic/update-topic.command.ts ================================================ import { IsNotEmpty, IsString } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; export class UpdateTopicCommand extends EnvironmentWithUserCommand { @IsString() @IsNotEmpty() topicKey: string; @IsString() @IsNotEmpty() name: string; } ================================================ FILE: apps/api/src/app/topics-v2/usecases/update-topic/update-topic.usecase.ts ================================================ import { Injectable, NotFoundException } from '@nestjs/common'; import { InstrumentUsecase } from '@novu/application-generic'; import { TopicRepository } from '@novu/dal'; import { TopicResponseDto } from '../../dtos/topic-response.dto'; import { mapTopicEntityToDto } from '../list-topics/map-topic-entity-to.dto'; import { UpdateTopicCommand } from './update-topic.command'; @Injectable() export class UpdateTopicUseCase { constructor(private topicRepository: TopicRepository) {} @InstrumentUsecase() async execute(command: UpdateTopicCommand): Promise { const existingTopic = await this.topicRepository.findTopicByKey( command.topicKey, command.organizationId, command.environmentId ); if (!existingTopic) { throw new NotFoundException(`Topic with key ${command.topicKey} not found`); } const updatedTopic = await this.topicRepository.findOneAndUpdate( { _id: existingTopic._id, _environmentId: command.environmentId, _organizationId: command.organizationId, }, { $set: { name: command.name, }, }, { new: true } ); return mapTopicEntityToDto(updatedTopic!); } } ================================================ FILE: apps/api/src/app/topics-v2/usecases/upsert-topic/upsert-topic.command.ts ================================================ import { EnvironmentCommand } from '@novu/application-generic'; import { IsBoolean, IsNotEmpty, IsOptional, IsString, Length } from 'class-validator'; export class UpsertTopicCommand extends EnvironmentCommand { @IsString() @IsNotEmpty() @Length(1, 100) key: string; @IsString() @IsOptional() @Length(0, 100) name?: string; @IsBoolean() @IsOptional() failIfExists?: boolean; } ================================================ FILE: apps/api/src/app/topics-v2/usecases/upsert-topic/upsert-topic.usecase.ts ================================================ import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'; import { InstrumentUsecase } from '@novu/application-generic'; import { ErrorCodesEnum, TopicRepository } from '@novu/dal'; import { VALID_ID_REGEX } from '@novu/shared'; import { TopicResponseDto } from '../../dtos/topic-response.dto'; import { mapTopicEntityToDto } from '../list-topics/map-topic-entity-to.dto'; import { UpsertTopicCommand } from './upsert-topic.command'; @Injectable() export class UpsertTopicUseCase { constructor(private topicRepository: TopicRepository) {} @InstrumentUsecase() async execute(command: UpsertTopicCommand): Promise<{ topic: TopicResponseDto; created: boolean }> { let topic = await this.topicRepository.findTopicByKey(command.key, command.organizationId, command.environmentId); if (command.failIfExists && topic) { throw new ConflictException(`Topic with key "${command.key}" already exists`); } if (!topic) { this.isValidTopicKey(command.key); try { topic = await this.topicRepository.createTopic({ _environmentId: command.environmentId, _organizationId: command.organizationId, key: command.key, name: command.name, }); } catch (error: unknown) { if (this.isDuplicateKeyError(error)) { topic = await this.topicRepository.findTopicByKey(command.key, command.organizationId, command.environmentId); } else { throw error; } } } else { const updateBody: Record = {}; if (command.name) { updateBody.name = command.name; } topic = await this.topicRepository.findOneAndUpdate( { _id: topic._id, _environmentId: command.environmentId, _organizationId: command.organizationId, }, { $set: updateBody, } ); } return { topic: mapTopicEntityToDto(topic!), created: !topic, }; } private isValidTopicKey(key: string): void { if (VALID_ID_REGEX.test(key)) { return; } throw new BadRequestException( `Invalid topic key: "${key}". Topic keys must contain only alphanumeric characters (a-z, A-Z, 0-9), hyphens (-), underscores (_), colons (:), or be a valid email address.` ); } private isDuplicateKeyError(error: unknown): boolean { return ( typeof error === 'object' && error !== null && 'code' in error && error.code === ErrorCodesEnum.DUPLICATE_KEY ); } } ================================================ FILE: apps/api/src/app/translations/e2e/v1/create-translation.e2e-ee.ts ================================================ import { UserSession } from '@novu/testing'; import { expect } from 'chai'; describe('[V1 Translations] Create translation group - /translations/groups (POST) #novu-v2', async () => { let session: UserSession; beforeEach(async () => { session = new UserSession(); await session.initialize(); await session.testAgent.put(`/v1/organizations/language`).send({ locale: 'en_US', }); }); it('should create translation group', async () => { const result = await session.testAgent.post(`/v1/translations/groups`).send({ name: 'test', identifier: 'test', locales: ['en_US'], }); let group = result.body.data; const { id } = group; expect(group.name).to.eq('test'); expect(group.identifier).to.eq('test'); let data = await session.testAgent.get(`/v1/translations/groups/test`).send(); group = data.body.data; let locales = group.translations.map((t) => t.isoLanguage); expect(group.name).to.eq('test'); expect(group.identifier).to.eq('test'); expect(locales).to.deep.eq(['en_US']); expect(id).to.equal(group.id); await session.applyChanges({ enabled: false, }); await session.switchToProdEnvironment(); data = await session.testAgent.get(`/v1/translations/groups/test`).send(); group = data.body.data; locales = group.translations.map((t) => t.isoLanguage); expect(group.name).to.eq('test'); expect(group.identifier).to.eq('test'); expect(locales).to.deep.eq(['en_US']); }); it('should promote creation of default locale translation after translation group promotion', async () => { const result = await session.testAgent.post(`/v1/translations/groups`).send({ name: 'test', identifier: 'test', locales: ['en_US', 'sv_SE'], }); let group = result.body.data; const { id } = group; expect(group.name).to.eq('test'); expect(group.identifier).to.eq('test'); let data = await session.testAgent.get(`/v1/translations/groups/test`).send(); group = data.body.data; let locales = group.translations.map((t) => t.isoLanguage); expect(group.name).to.eq('test'); expect(group.identifier).to.eq('test'); expect(locales).to.deep.eq(['en_US', 'sv_SE']); expect(id).to.equal(group.id); await session.applyChanges({ enabled: false, _entityId: group.id, }); await session.switchToProdEnvironment(); data = await session.testAgent.get(`/v1/translations/groups/test`).send(); group = data.body.data; locales = group.translations.map((t) => t.isoLanguage); expect(group.name).to.eq('test'); expect(group.identifier).to.eq('test'); expect(locales).to.deep.eq(['en_US']); }); it('should check that default locale is included in group else add it', async () => { const result = await session.testAgent.post(`/v1/translations/groups`).send({ name: 'test', identifier: 'test1', locales: ['en_GB'], }); const data = await session.testAgent.get(`/v1/translations/groups/test1`).send(); const group = data.body.data; const locales = group.translations.map((t) => t.isoLanguage); expect(locales).to.deep.eq(['en_US', 'en_GB']); }); it('should check that locale is allowed', async () => { const result = await session.testAgent.post(`/v1/translations/groups`).send({ name: 'test', identifier: 'test', locales: ['en_US', 'test'], }); expect(result.body.message).to.be.eq('Locale could not be found'); expect(result.body.statusCode).to.be.eq(404); expect(result.body.error).to.be.eq('Not Found'); }); }); ================================================ FILE: apps/api/src/app/translations/e2e/v1/delete-translation-group.e2e-ee.ts ================================================ import { UserSession } from '@novu/testing'; import { expect } from 'chai'; describe('[V1 Translations] Delete a Translation group - /translations/group/:id (Delete) #novu-v2', async () => { let session: UserSession; beforeEach(async () => { session = new UserSession(); await session.initialize(); await session.testAgent.put(`/v1/organizations/language`).send({ locale: 'hi_IN', }); }); it('should delete the translation group', async () => { const createTranslationGroup = { name: 'test', identifier: 'test', locales: ['hi_IN'], }; const { body } = await session.testAgent.post('/v1/translations/groups').send(createTranslationGroup); const newTranslationGroupId = body.data._id; const { body: translationGroupList } = await session.testAgent.get('/v1/translations/groups').send(); expect(translationGroupList.data.length).to.equal(1); expect(translationGroupList.data[0].name).to.equal(createTranslationGroup.name); expect(translationGroupList.data[0].identifier).to.equal(createTranslationGroup.identifier); expect(translationGroupList.data[0].uiConfig.locales).to.eql(createTranslationGroup.locales); expect(translationGroupList.data[0]._id).to.equal(newTranslationGroupId); await session.testAgent.delete(`/v1/translations/groups/${createTranslationGroup.identifier}`).send(); const { body: translationGroupListAfterDelete } = await session.testAgent.get('/v1/translations/groups').send(); expect(translationGroupListAfterDelete.data.length).to.equal(0); }); it('should also delete the translations of the group', async () => { const createTranslationGroup = { name: 'test', identifier: 'test', locales: ['hi_IN'], }; const { body } = await session.testAgent.post('/v1/translations/groups').send(createTranslationGroup); const newTranslationGroupId = body.data._id; const { body: translationGroupList } = await session.testAgent.get('/v1/translations/groups').send(); expect(translationGroupList.data.length).to.equal(1); expect(translationGroupList.data[0].name).to.equal(createTranslationGroup.name); expect(translationGroupList.data[0].identifier).to.equal(createTranslationGroup.identifier); expect(translationGroupList.data[0].uiConfig.locales).to.eql(createTranslationGroup.locales); expect(translationGroupList.data[0]._id).to.equal(newTranslationGroupId); const { body: translationGroup } = await session.testAgent .get(`/v1/translations/groups/${createTranslationGroup.identifier}`) .send(); expect(translationGroup.data.name).to.equal(createTranslationGroup.name); expect(translationGroup.data.identifier).to.equal(createTranslationGroup.identifier); expect(translationGroup.data.translations.length).to.equal(1); expect(translationGroup.data.translations[0].isoLanguage).to.equal(createTranslationGroup.locales[0]); await session.testAgent.delete(`/v1/translations/groups/${createTranslationGroup.identifier}`).send(); const { body: translationGroupListAfterDelete } = await session.testAgent.get('/v1/translations/groups').send(); expect(translationGroupListAfterDelete.data.length).to.equal(0); const { body: translationGroupAfterDelete } = await session.testAgent .get(`/v1/translations/groups/${createTranslationGroup.identifier}`) .send(); expect(translationGroupAfterDelete.statusCode).to.equal(404); expect(translationGroupAfterDelete.message).to.equal('Group could not be found'); }); }); ================================================ FILE: apps/api/src/app/translations/e2e/v1/delete-translation.e2e-ee.ts ================================================ import { UserSession } from '@novu/testing'; import { expect } from 'chai'; describe('[V1 Translations] Delete a Translation - /translations/group/:id/locale/:locale (Delete) #novu-v2', async () => { let session: UserSession; beforeEach(async () => { session = new UserSession(); await session.initialize(); await session.testAgent.put(`/v1/organizations/language`).send({ locale: 'hi_IN', }); }); it('should delete the translation file', async () => { const createTranslationGroup = { name: 'test', identifier: 'test', locales: ['hi_IN'], }; const { body } = await session.testAgent.post('/v1/translations/groups').send(createTranslationGroup); const newTranslationGroupId = body.data._id; const { body: translationGroupList } = await session.testAgent.get('/v1/translations/groups').send(); expect(translationGroupList.data.length).to.equal(1); expect(translationGroupList.data[0].name).to.equal(createTranslationGroup.name); expect(translationGroupList.data[0].identifier).to.equal(createTranslationGroup.identifier); expect(translationGroupList.data[0].uiConfig.locales).to.eql(createTranslationGroup.locales); expect(translationGroupList.data[0]._id).to.equal(newTranslationGroupId); const jsonContent = { key1: 'value1', key2: 'value2', }; const buffer = Buffer.from(JSON.stringify(jsonContent)); const file = { fieldname: 'test.json', originalname: 'test.json', encoding: 'utf-8', mimetype: 'application/json', size: 123, buffer, }; const fileBuffer = Buffer.from(JSON.stringify(file), 'utf-8'); await session.testAgent .post(`/v1/translations/groups/${createTranslationGroup.identifier}`) .attach('files', fileBuffer, 'test.json') .field('locales', JSON.stringify(createTranslationGroup.locales)) .field('identifier', createTranslationGroup.identifier); const { body: translation } = await session.testAgent .get(`/v1/translations/groups/${createTranslationGroup.identifier}/locales/${createTranslationGroup.locales[0]}`) .send(); const translationData = translation.data; expect(translationData.isoLanguage).to.equal(createTranslationGroup.locales[0]); expect(translationData._groupId).to.equal(newTranslationGroupId); expect(translationData.translations).to.equal(JSON.stringify(file)); await session.applyChanges({ enabled: false, }); await session.switchToProdEnvironment(); const { body: translationProd } = await session.testAgent .get(`/v1/translations/groups/${createTranslationGroup.identifier}/locales/${createTranslationGroup.locales[0]}`) .send(); const translationProdData = translationProd.data; expect(translationProdData.isoLanguage).to.equal(createTranslationGroup.locales[0]); expect(translationProdData.translations).to.equal(JSON.stringify(file)); await session.switchToDevEnvironment(); await session.testAgent .delete( `/v1/translations/groups/${createTranslationGroup.identifier}/locales/${createTranslationGroup.locales[0]}` ) .send(); const { body: translationAfterDelete } = await session.testAgent .get(`/v1/translations/groups/${createTranslationGroup.identifier}/locales/${createTranslationGroup.locales[0]}`) .send(); const translationDataAfterDelete = translationAfterDelete.data; expect(translationDataAfterDelete.isoLanguage).to.equal(createTranslationGroup.locales[0]); expect(translationDataAfterDelete._groupId).to.equal(newTranslationGroupId); expect(translationDataAfterDelete.translations).to.not.exist; expect(translationDataAfterDelete.fileName).to.not.exist; await session.applyChanges({ enabled: false, }); await session.switchToProdEnvironment(); const { body: translationProdAfterDelete } = await session.testAgent .get(`/v1/translations/groups/${createTranslationGroup.identifier}/locales/${createTranslationGroup.locales[0]}`) .send(); const translationProdDataAfterDelete = translationProdAfterDelete.data; expect(translationProdDataAfterDelete.isoLanguage).to.equal(createTranslationGroup.locales[0]); expect(translationProdDataAfterDelete.translations).to.not.exist; }); }); ================================================ FILE: apps/api/src/app/translations/e2e/v1/edit-translation.e2e-ee.ts ================================================ import { UserSession } from '@novu/testing'; import { expect } from 'chai'; const createTranslationGroup = { name: 'test', identifier: 'test', locales: ['hi_IN'], }; describe('[V1 Translations] Edit translation - /translations/groups/:identifier/locales/:locale (PATCH) #novu-v2', async () => { let session: UserSession; before(async () => { session = new UserSession(); await session.initialize(); await session.testAgent.put(`/v1/organizations/language`).send({ locale: createTranslationGroup.locales[0], }); }); it('should edit translation', async () => { const { body } = await session.testAgent.post('/v1/translations/groups').send(createTranslationGroup); const newTranslationGroupId = body.data._id; const { body: translationGroupList } = await session.testAgent.get('/v1/translations/groups').send(); expect(translationGroupList.data.length).to.equal(1); expect(translationGroupList.data[0].name).to.equal(createTranslationGroup.name); expect(translationGroupList.data[0].identifier).to.equal(createTranslationGroup.identifier); expect(translationGroupList.data[0].uiConfig.locales).to.eql(createTranslationGroup.locales); expect(translationGroupList.data[0]._id).to.equal(newTranslationGroupId); const jsonContent = { key1: 'value1', key2: 'value2', }; const buffer = Buffer.from(JSON.stringify(jsonContent)); const file = { fieldname: 'test.json', originalname: 'test.json', encoding: 'utf-8', mimetype: 'application/json', size: 123, buffer, }; const fileBuffer = Buffer.from(JSON.stringify(file), 'utf-8'); await session.testAgent .post(`/v1/translations/groups/${createTranslationGroup.identifier}`) .attach('files', fileBuffer, 'test.json') .field('locales', JSON.stringify(createTranslationGroup.locales)) .field('identifier', createTranslationGroup.identifier); const { body: translation } = await session.testAgent .get(`/v1/translations/groups/${createTranslationGroup.identifier}/locales/${createTranslationGroup.locales[0]}`) .send(); const translationData = translation.data; expect(translationData.isoLanguage).to.equal(createTranslationGroup.locales[0]); expect(translationData._groupId).to.equal(newTranslationGroupId); expect(translationData.translations).to.equal(JSON.stringify(file)); await session.applyChanges({ enabled: false, }); await session.switchToProdEnvironment(); const { body: translationProd } = await session.testAgent .get(`/v1/translations/groups/${createTranslationGroup.identifier}/locales/${createTranslationGroup.locales[0]}`) .send(); const translationProdData = translationProd.data; expect(translationProdData.isoLanguage).to.equal(createTranslationGroup.locales[0]); expect(translationProdData.translations).to.equal(JSON.stringify(file)); await session.switchToDevEnvironment(); const editedFileName = 'edited.json'; const editedFileText = { key1: 'value1', key2: 'value2', key3: 'value3', }; const { body: editTranslationBody } = await session.testAgent .patch( `/v1/translations/groups/${createTranslationGroup.identifier}/locales/${createTranslationGroup.locales[0]}` ) .send({ translation: JSON.stringify(editedFileText), fileName: editedFileName, }); const editTranslation = editTranslationBody.data; expect(editTranslation.isoLanguage).to.equal(createTranslationGroup.locales[0]); expect(editTranslation._groupId).to.equal(newTranslationGroupId); expect(editTranslation.translations).to.equal(JSON.stringify(editedFileText)); expect(editTranslation.fileName).to.equal(editedFileName); await session.applyChanges({ enabled: false, }); await session.switchToProdEnvironment(); const { body: editTranslationProdBody } = await session.testAgent .get(`/v1/translations/groups/${createTranslationGroup.identifier}/locales/${createTranslationGroup.locales[0]}`) .send(); const editTranslationProd = editTranslationProdBody.data; expect(editTranslationProd.isoLanguage).to.equal(createTranslationGroup.locales[0]); expect(editTranslationProd.fileName).to.equal(editedFileName); expect(editTranslationProd.translations).to.equal(JSON.stringify(editedFileText)); }); }); ================================================ FILE: apps/api/src/app/translations/e2e/v1/get-locales-from-content.e2e-ee.ts ================================================ import { UserSession } from '@novu/testing'; import { expect } from 'chai'; const createTranslationGroup = { name: 'test', identifier: 'test', locales: ['hi_IN', 'en_US'], }; const content = 'Hello {{i18n "test.key1"}}, {{i18n "test.key2"}}, {{i18n "test.key3"}}'; describe('[V1 Translations] Get locales from content - /translations/groups/:identifier/locales/:locale (PATCH) #novu-v2', async () => { let session: UserSession; before(async () => { session = new UserSession(); await session.initialize(); await session.testAgent.put(`/v1/organizations/language`).send({ locale: createTranslationGroup.locales[0], }); await session.testAgent.post('/v1/translations/groups').send(createTranslationGroup); }); it('should get locales from the content', async () => { const { body } = await session.testAgent.post('/v1/translations/groups/preview/locales').send({ content, }); const locales = body.data; expect(locales.length).to.equal(2); expect(locales[0].langIso).to.equal(createTranslationGroup.locales[0]); expect(locales[1].langIso).to.equal(createTranslationGroup.locales[1]); }); }); ================================================ FILE: apps/api/src/app/translations/e2e/v1/get-locales.e2e-ee.ts ================================================ import { UserSession } from '@novu/testing'; import { expect } from 'chai'; describe('[V1 Translations] get locales - /translations/locales (GET) #novu-v2', async () => { let session: UserSession; before(async () => { session = new UserSession(); await session.initialize(); }); it('should get locales', async () => { const data = await session.testAgent.get(`/v1/translations/locales`).send(); const locales: any[] = data.body.data; expect(locales.length).to.equal(482); expect(Object.keys(locales[0])).to.deep.equal([ 'name', 'officialName', 'numeric', 'alpha2', 'alpha3', 'currencyName', 'currencyAlphabeticCode', 'langName', 'langIso', ]); }); }); ================================================ FILE: apps/api/src/app/translations/e2e/v1/get-translation-group.e2e-ee.ts ================================================ import { UserSession } from '@novu/testing'; import { expect } from 'chai'; describe('[V1 Translations] get translation group - /translations/groups/:identifier (GET) #novu-v2', async () => { let session: UserSession; before(async () => { session = new UserSession(); await session.initialize(); await session.testAgent.put(`/v1/organizations/language`).send({ locale: 'en_US', }); await session.testAgent.post(`/v1/translations/groups`).send({ name: 'test', identifier: 'test', locales: ['en_US'], }); }); it('should get translation group', async () => { const data = await session.testAgent.get(`/v1/translations/groups/test`).send(); const group = data.body.data; const locales = group.translations.map((t) => t.isoLanguage); expect(group.name).to.eq('test'); expect(group.identifier).to.eq('test'); expect(locales).to.deep.eq(['en_US']); }); it('should return 404 on trying getting a translation group that does not exist', async () => { const data = await session.testAgent.get(`/v1/translations/groups/hej`).send(); const result = data.body; expect(result.message).to.equal('Group could not be found'); expect(result.statusCode).to.be.eq(404); expect(result.error).to.be.eq('Not Found'); }); }); ================================================ FILE: apps/api/src/app/translations/e2e/v1/get-translation-groups.e2e-ee.ts ================================================ import { UserSession } from '@novu/testing'; import { expect } from 'chai'; describe('[V1 Translations] get translation groups - /translations/groups (GET) #novu-v2', async () => { let session: UserSession; before(async () => { session = new UserSession(); await session.initialize(); await session.testAgent.put(`/v1/organizations/language`).send({ locale: 'en_US', }); await session.testAgent.post(`/v1/translations/groups`).send({ name: 'test', identifier: 'test', locales: ['en_US'], }); await session.testAgent.post(`/v1/translations/groups`).send({ name: 'test1', identifier: 'test1', locales: ['en_US', 'en_GB'], }); await session.testAgent.post(`/v1/translations/groups`).send({ name: 'test2', identifier: 'test2', locales: ['en_US', 'sv_SE'], }); }); it('should get translation groups', async () => { const data = await session.testAgent.get(`/v1/translations/groups`).send(); const groups = data.body.data; const testGroup = groups[0]; expect(testGroup.identifier).to.equal('test'); expect(testGroup.name).to.equal('test'); expect(testGroup.uiConfig.locales).to.deep.equal(['en_US']); expect(testGroup.uiConfig.localesMissingTranslations).to.deep.equal(['en_US']); const test1Group = groups[1]; expect(test1Group.identifier).to.equal('test1'); expect(test1Group.name).to.equal('test1'); expect(test1Group.uiConfig.locales).to.deep.equal(['en_US', 'en_GB']); expect(test1Group.uiConfig.localesMissingTranslations).to.deep.equal(['en_US', 'en_GB']); const test2Group = groups[2]; expect(test2Group.identifier).to.equal('test2'); expect(test2Group.name).to.equal('test2'); expect(test2Group.uiConfig.locales).to.deep.equal(['en_US', 'sv_SE']); expect(test2Group.uiConfig.localesMissingTranslations).to.deep.equal(['en_US', 'sv_SE']); }); }); ================================================ FILE: apps/api/src/app/translations/e2e/v1/get-translation.e2e-ee.ts ================================================ import { UserSession } from '@novu/testing'; import { expect } from 'chai'; describe('[V1 Translations] GET translation - /translations/groups/:identifier/locales/:locale (GET) #novu-v2', async () => { let session: UserSession; before(async () => { session = new UserSession(); await session.initialize(); await session.testAgent.put(`/v1/organizations/language`).send({ locale: 'en_US', }); }); it('should get translation', async () => { let result = await session.testAgent.post(`/v1/translations/groups`).send({ name: 'test', identifier: 'test', locales: ['en_US', 'sv_SE'], }); const group = result.body.data; result = await session.testAgent.get(`/v1/translations/groups/test/locales/sv_SE`).send(); const translation = result.body.data; expect(translation.isoLanguage).to.equal('sv_SE'); expect(translation._groupId).to.equal(group.id); }); }); ================================================ FILE: apps/api/src/app/translations/e2e/v1/update-default-locale.e2e-ee.ts ================================================ import { OrganizationRepository } from '@novu/dal'; import { getEERepository, UserSession } from '@novu/testing'; import { expect } from 'chai'; describe('[V1 Translations] Update default locale and add new translations - /translations/language (PATCH) #novu-v2', async () => { let session: UserSession; const organizationRepository = getEERepository('OrganizationRepository'); before(async () => { session = new UserSession(); await session.initialize(); await session.testAgent.put(`/v1/organizations/language`).send({ locale: 'en_US', }); }); it('should update default locale and add that locale to groups', async () => { await session.testAgent.post(`/v1/translations/groups`).send({ name: 'test', identifier: 'test', locales: ['en_US', 'sv_SE'], }); await session.applyChanges({ enabled: false, }); await session.testAgent.patch(`/v1/translations/language`).send({ locale: 'en_GB', }); const org = await organizationRepository.findById(session.organization._id); expect(org?.defaultLocale).to.be.equal('en_GB'); const result = await session.testAgent.get(`/v1/translations/groups/test`).send(); let group = result.body.data; let locales = group.translations.map((t) => t.isoLanguage); expect(locales).to.deep.equal(['en_US', 'sv_SE', 'en_GB']); await session.applyChanges({ enabled: false, }); await session.switchToProdEnvironment(); const data = await session.testAgent.get(`/v1/translations/groups/test`).send(); group = data.body.data; locales = group.translations.map((t) => t.isoLanguage); expect(locales).to.deep.equal(['en_US', 'sv_SE', 'en_GB']); }); }); ================================================ FILE: apps/api/src/app/translations/e2e/v1/update-translation.e2e-ee.ts ================================================ import { UserSession } from '@novu/testing'; import { expect } from 'chai'; describe('[V1 Translations] Update translation - /translations/groups (PATCH) #novu-v2', async () => { let session: UserSession; before(async () => { session = new UserSession(); await session.initialize(); await session.testAgent.put(`/v1/organizations/language`).send({ locale: 'en_US', }); }); it('should update translation', async () => { await session.testAgent.post(`/v1/translations/groups`).send({ name: 'test', identifier: 'test', locales: ['en_US', 'sv_SE'], }); await session.applyChanges({ enabled: false, }); let result = await session.testAgent.patch(`/v1/translations/groups/test`).send({ name: 'test1', identifier: 'test1', locales: ['en_US', 'en_GB'], }); let group = result.body.data; let locales = group.translations.map((t) => t.isoLanguage); expect(group.identifier).to.equal('test1'); expect(group.name).to.equal('test1'); expect(locales).to.deep.equal(['en_US', 'en_GB']); result = await session.testAgent.get(`/v1/translations/groups/test1/locales/sv_SE`).send(); expect(result.body.message).to.equal('Translation could not be found'); expect(result.body.error).to.equal('Not Found'); expect(result.body.statusCode).to.equal(404); await session.applyChanges({ enabled: false, }); await session.switchToProdEnvironment(); const data = await session.testAgent.get(`/v1/translations/groups/test1`).send(); group = data.body.data; locales = group.translations.map((t) => t.isoLanguage); expect(group.identifier).to.equal('test1'); expect(group.name).to.equal('test1'); expect(locales).to.deep.equal(['en_US', 'en_GB']); }); }); ================================================ FILE: apps/api/src/app/translations/e2e/v2/create-translation.e2e-ee.ts ================================================ import { Novu } from '@novu/api'; import { LocalizationResourceEnum } from '@novu/dal'; import { ApiServiceLevelEnum, StepTypeEnum, WorkflowCreationSourceEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { initNovuClassSdkInternalAuth } from '../../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('Create/update translation - /v2/translations (POST) #novu-v2', async () => { let session: UserSession; let novuClient: Novu; let workflowId: string; beforeEach(async () => { session = new UserSession(); await session.initialize(); // Set organization service level to business to avoid payment required errors await session.updateOrganizationServiceLevel(ApiServiceLevelEnum.BUSINESS); novuClient = initNovuClassSdkInternalAuth(session); const { result: workflow } = await novuClient.workflows.create({ name: 'Test Workflow for Translations', workflowId: `test-workflow-${Date.now()}`, source: WorkflowCreationSourceEnum.EDITOR, active: true, isTranslationEnabled: true, steps: [ { name: 'In-App Step', type: StepTypeEnum.IN_APP, controlValues: { body: 'Test content', }, }, ], }); workflowId = workflow.workflowId; }); it('should create new translation successfully', async () => { const requestBody = { resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'en_US', content: { 'welcome.title': 'Welcome', 'welcome.message': 'Hello there!', 'button.submit': 'Submit', }, }; const response = await novuClient.translations.create(requestBody); expect(response.locale).to.equal('en_US'); expect(response.resourceId).to.equal(workflowId); expect(response.resourceType).to.equal(LocalizationResourceEnum.WORKFLOW); expect(response.content).to.deep.equal(requestBody.content); expect(response.createdAt).to.be.a('string'); expect(response.updatedAt).to.be.a('string'); }); it('should update existing translation', async () => { const originalContent = { key1: 'original value', key2: 'another value', }; const updatedContent = { key1: 'updated value', key3: 'new value', }; // Create initial translation await novuClient.translations.create({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'en_US', content: originalContent, }); // Update the translation const response = await novuClient.translations.create({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'en_US', content: updatedContent, }); expect(response.content).to.deep.equal(updatedContent); }); it('should validate locale format', async () => { try { await novuClient.translations.create({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: '123', content: { key: 'value' }, }); throw new Error('Should have thrown an error'); } catch (error: any) { expect(error.statusCode).to.equal(422); } }); }); ================================================ FILE: apps/api/src/app/translations/e2e/v2/delete-translation-group.e2e-ee.ts ================================================ import { Novu } from '@novu/api'; import { LocalizationResourceEnum } from '@novu/dal'; import { ApiServiceLevelEnum, StepTypeEnum, WorkflowCreationSourceEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { initNovuClassSdkInternalAuth } from '../../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('Delete translation group - /v2/translations/:resourceType/:resourceId (DELETE) #novu-v2', async () => { let session: UserSession; let novuClient: Novu; let workflowId: string; beforeEach(async () => { session = new UserSession(); await session.initialize(); // Set organization service level to business to avoid payment required errors await session.updateOrganizationServiceLevel(ApiServiceLevelEnum.BUSINESS); novuClient = initNovuClassSdkInternalAuth(session); const { result: workflow } = await novuClient.workflows.create({ name: 'Test Workflow for Translation Group Deletion', workflowId: `test-workflow-${Date.now()}`, source: WorkflowCreationSourceEnum.EDITOR, active: true, isTranslationEnabled: true, steps: [ { name: 'In-App Step', type: StepTypeEnum.IN_APP, controlValues: { body: 'Test content', }, }, ], }); workflowId = workflow.workflowId; }); it('should delete entire translation group with all translations successfully', async () => { const translations = [ { resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'en_US', content: { 'welcome.title': 'Welcome', 'welcome.message': 'Hello there!' }, }, { resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'es_ES', content: { 'welcome.title': 'Bienvenido', 'welcome.message': '¡Hola!' }, }, { resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'fr_FR', content: { 'welcome.title': 'Bienvenue', 'welcome.message': 'Bonjour!' }, }, ]; // Create multiple translations for (const translation of translations) { await novuClient.translations.create(translation); } // Delete the entire translation group await novuClient.translations.groups.delete(LocalizationResourceEnum.WORKFLOW, workflowId); // Verify all translations are deleted for (const translation of translations) { try { await novuClient.translations.retrieve({ resourceType: translation.resourceType, resourceId: translation.resourceId, locale: translation.locale, }); throw new Error('Should have thrown 404'); } catch (error: any) { expect(error.statusCode).to.equal(404); } } }); it('should return 404 when trying to delete non-existent translation group', async () => { const fakeWorkflowId = '507f1f77bcf86cd799439011'; try { await novuClient.translations.groups.delete(LocalizationResourceEnum.WORKFLOW, fakeWorkflowId); throw new Error('Should have thrown 404'); } catch (error: any) { expect(error.statusCode).to.equal(404); } }); it('should return 404 when trying to delete non-existent translation group for workflow without translations enabled', async () => { // Create a workflow with translations disabled (no translation group created) const { result: workflowWithoutTranslations } = await novuClient.workflows.create({ name: 'Workflow Without Translations', workflowId: `workflow-no-translations-${Date.now()}`, source: WorkflowCreationSourceEnum.EDITOR, active: true, isTranslationEnabled: false, // This prevents automatic translation group creation steps: [ { name: 'No Translation Step', type: StepTypeEnum.IN_APP, controlValues: { body: 'No translation content', }, }, ], }); try { await novuClient.translations.groups.delete( LocalizationResourceEnum.WORKFLOW, workflowWithoutTranslations.workflowId ); throw new Error('Should have thrown 404'); } catch (error: any) { expect(error.statusCode).to.equal(404); } }); }); ================================================ FILE: apps/api/src/app/translations/e2e/v2/delete-translation.e2e-ee.ts ================================================ import { Novu } from '@novu/api'; import { LocalizationResourceEnum } from '@novu/dal'; import { ApiServiceLevelEnum, StepTypeEnum, WorkflowCreationSourceEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { initNovuClassSdkInternalAuth } from '../../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('Delete translation - /v2/translations/:resourceType/:resourceId/:locale (DELETE) #novu-v2', async () => { let session: UserSession; let novuClient: Novu; let workflowId: string; beforeEach(async () => { session = new UserSession(); await session.initialize(); // Set organization service level to business to avoid payment required errors await session.updateOrganizationServiceLevel(ApiServiceLevelEnum.BUSINESS); novuClient = initNovuClassSdkInternalAuth(session); const { result: workflow } = await novuClient.workflows.create({ name: 'Test Workflow for Translations', workflowId: `test-workflow-${Date.now()}`, source: WorkflowCreationSourceEnum.EDITOR, active: true, isTranslationEnabled: true, steps: [ { name: 'In-App Step', type: StepTypeEnum.IN_APP, controlValues: { body: 'Test content', }, }, ], }); workflowId = workflow.workflowId; }); it('should delete existing translation successfully', async () => { const translationContent = { 'welcome.title': 'Welcome', 'welcome.message': 'Hello there!', 'button.submit': 'Submit', }; // Create translation first await novuClient.translations.create({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'en_US', content: translationContent, }); // Verify translation exists await novuClient.translations.retrieve({ resourceType: LocalizationResourceEnum.WORKFLOW, resourceId: workflowId, locale: 'en_US', }); // Delete the translation await novuClient.translations.delete({ resourceType: LocalizationResourceEnum.WORKFLOW, resourceId: workflowId, locale: 'en_US', }); // Verify translation no longer exists try { await novuClient.translations.retrieve({ resourceType: LocalizationResourceEnum.WORKFLOW, resourceId: workflowId, locale: 'en_US', }); throw new Error('Should have thrown 404'); } catch (error: any) { expect(error.statusCode).to.equal(404); } }); it('should return 404 when trying to delete non-existent translation', async () => { try { await novuClient.translations.delete({ resourceType: LocalizationResourceEnum.WORKFLOW, resourceId: workflowId, locale: 'fr_FR', }); throw new Error('Should have thrown 404'); } catch (error: any) { expect(error.statusCode).to.equal(404); } }); it('should return 404 when trying to delete translation for non-existent workflow', async () => { const fakeWorkflowId = '507f1f77bcf86cd799439011'; try { await novuClient.translations.delete({ resourceType: LocalizationResourceEnum.WORKFLOW, resourceId: fakeWorkflowId, locale: 'en_US', }); throw new Error('Should have thrown 404'); } catch (error: any) { expect(error.statusCode).to.equal(404); } }); it('should validate locale format in URL parameter', async () => { try { await novuClient.translations.delete({ resourceType: LocalizationResourceEnum.WORKFLOW, resourceId: workflowId, locale: 'invalid-locale-123', }); throw new Error('Should have thrown 400'); } catch (error: any) { expect(error.statusCode).to.equal(400); } }); it('should handle underscores in locale and normalize them', async () => { const translationContent = { 'test.key': 'Test value', }; // Create translation with underscore format await novuClient.translations.create({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'en_US', content: translationContent, }); // Delete with dash format (should be normalized to underscore) await novuClient.translations.delete({ resourceType: LocalizationResourceEnum.WORKFLOW, resourceId: workflowId, locale: 'en-US', }); // Verify translation no longer exists try { await novuClient.translations.retrieve({ resourceType: LocalizationResourceEnum.WORKFLOW, resourceId: workflowId, locale: 'en_US', }); throw new Error('Should have thrown 404'); } catch (error: any) { expect(error.statusCode).to.equal(404); } }); it('should delete only the specified locale, leaving others intact', async () => { const englishContent = { 'welcome.title': 'Welcome', 'welcome.message': 'Hello there!', }; const frenchContent = { 'welcome.title': 'Bienvenue', 'welcome.message': 'Bonjour!', }; // Create translations in multiple locales await novuClient.translations.create({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'en_US', content: englishContent, }); await novuClient.translations.create({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'fr_FR', content: frenchContent, }); // Delete only the English translation await novuClient.translations.delete({ resourceType: LocalizationResourceEnum.WORKFLOW, resourceId: workflowId, locale: 'en_US', }); // Verify English translation is gone try { await novuClient.translations.retrieve({ resourceType: LocalizationResourceEnum.WORKFLOW, resourceId: workflowId, locale: 'en_US', }); throw new Error('Should have thrown 404'); } catch (error: any) { expect(error.statusCode).to.equal(404); } // Verify French translation still exists const response = await novuClient.translations.retrieve({ resourceType: LocalizationResourceEnum.WORKFLOW, resourceId: workflowId, locale: 'fr_FR', }); expect(response.content).to.deep.equal(frenchContent); }); it('should work with complex locale codes', async () => { const translationContent = { 'test.key': 'Chinese Simplified content', }; // Create translation with complex locale await novuClient.translations.create({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'zh_CN', content: translationContent, }); // Delete the translation await novuClient.translations.delete({ resourceType: LocalizationResourceEnum.WORKFLOW, resourceId: workflowId, locale: 'zh_CN', }); // Verify translation no longer exists try { await novuClient.translations.retrieve({ resourceType: LocalizationResourceEnum.WORKFLOW, resourceId: workflowId, locale: 'zh_CN', }); throw new Error('Should have thrown 404'); } catch (error: any) { expect(error.statusCode).to.equal(404); } }); }); ================================================ FILE: apps/api/src/app/translations/e2e/v2/export-master-json.e2e-ee.ts ================================================ import { Novu } from '@novu/api'; import { LocalizationResourceEnum } from '@novu/dal'; import { ApiServiceLevelEnum, StepTypeEnum, WorkflowCreationSourceEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { initNovuClassSdkInternalAuth } from '../../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('Export master JSON - /v2/translations/master-json (GET) #novu-v2', async () => { let session: UserSession; let novuClient: Novu; let workflowId1: string; let workflowId2: string; beforeEach(async () => { session = new UserSession(); await session.initialize(); // Set organization service level to business to avoid payment required errors await session.updateOrganizationServiceLevel(ApiServiceLevelEnum.BUSINESS); novuClient = initNovuClassSdkInternalAuth(session); // Create first workflow with translations const { result: workflow1 } = await novuClient.workflows.create({ name: 'User Onboarding Workflow', workflowId: `user-onboarding-workflow-${Date.now()}`, source: WorkflowCreationSourceEnum.EDITOR, active: true, isTranslationEnabled: true, steps: [ { name: 'Welcome Email', type: StepTypeEnum.EMAIL, controlValues: { subject: 'Welcome to our platform', body: 'Welcome {{payload.name}}!', }, }, ], }); workflowId1 = workflow1.workflowId; // Create second workflow without translations (for testing filtering) const { result: workflow2 } = await novuClient.workflows.create({ name: 'No Translation Workflow', workflowId: `no-translation-workflow-${Date.now()}`, source: WorkflowCreationSourceEnum.EDITOR, active: true, isTranslationEnabled: false, steps: [ { name: 'Simple Email', type: StepTypeEnum.EMAIL, controlValues: { subject: 'Simple notification', body: 'This workflow has no translations', }, }, ], }); workflowId2 = workflow2.workflowId; // Create translations for first workflow in multiple locales await novuClient.translations.create({ resourceId: workflowId1, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'en_US', content: { 'welcome.title': 'Welcome to our platform', 'welcome.message': 'Hello {{payload.name}}, welcome aboard!', }, }); await novuClient.translations.create({ resourceId: workflowId1, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'es_ES', content: { 'welcome.title': 'Bienvenido a nuestra plataforma', 'welcome.message': 'Hola {{payload.name}}, ¡bienvenido!', }, }); }); it('should export master JSON with correct structure and content filtering', async () => { const response = await novuClient.translations.master.retrieve('en_US'); // Verify response structure expect(response).to.have.property('workflows'); expect(response.workflows).to.be.an('object'); // Should include workflow with translations expect(response.workflows).to.have.property(workflowId1); // Should not include workflow without translations expect(response.workflows).to.not.have.property(workflowId2); // Verify content structure and liquid variables expect(response.workflows[workflowId1]).to.deep.equal({ 'welcome.title': 'Welcome to our platform', 'welcome.message': 'Hello {{payload.name}}, welcome aboard!', }); }); it('should filter by locale correctly', async () => { // Test Spanish locale const spanishResponse = await novuClient.translations.master.retrieve('es_ES'); expect(spanishResponse.workflows).to.have.property(workflowId1); expect(spanishResponse.workflows[workflowId1]).to.deep.equal({ 'welcome.title': 'Bienvenido a nuestra plataforma', 'welcome.message': 'Hola {{payload.name}}, ¡bienvenido!', }); // Test non-existent locale const emptyResponse = await novuClient.translations.master.retrieve('de_DE'); expect(emptyResponse.workflows).to.be.an('object'); expect(Object.keys(emptyResponse.workflows)).to.have.lengthOf(0); }); it('should work without locale parameter', async () => { const response = await novuClient.translations.master.retrieve(); expect(response).to.have.property('workflows'); expect(response.workflows).to.be.an('object'); }); it('should validate locale format', async () => { try { await novuClient.translations.master.retrieve('invalid-locale'); throw new Error('Should have thrown 422'); } catch (error: any) { expect(error.statusCode).to.equal(422); } }); }); ================================================ FILE: apps/api/src/app/translations/e2e/v2/get-translation-group.e2e-ee.ts ================================================ import { Novu } from '@novu/api'; import { LocalizationResourceEnum } from '@novu/dal'; import { ApiServiceLevelEnum, StepTypeEnum, WorkflowCreationSourceEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { initNovuClassSdkInternalAuth } from '../../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('Get single translation group - /v2/translations/group/:resourceType/:resourceId (GET) #novu-v2', async () => { let session: UserSession; let novuClient: Novu; let workflowId: string; beforeEach(async () => { session = new UserSession(); await session.initialize(); // Set organization service level to business to avoid payment required errors await session.updateOrganizationServiceLevel(ApiServiceLevelEnum.BUSINESS); novuClient = initNovuClassSdkInternalAuth(session); const { result: workflow } = await novuClient.workflows.create({ name: 'Test Workflow for Translation Group', workflowId: `test-workflow-${Date.now()}`, source: WorkflowCreationSourceEnum.EDITOR, active: true, isTranslationEnabled: true, steps: [ { name: 'In-App Step', type: StepTypeEnum.IN_APP, controlValues: { body: 'Test content', }, }, ], }); workflowId = workflow.workflowId; }); it('should get translation group with multiple locales', async () => { const translations = [ { resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'en_US', content: { 'welcome.title': 'Welcome', 'welcome.message': 'Hello there!', 'button.submit': 'Submit', }, }, { resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'es_ES', content: { 'welcome.title': 'Bienvenido', 'welcome.message': '¡Hola!', 'button.submit': 'Enviar', }, }, { resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'fr_FR', content: { 'welcome.title': 'Bienvenue', 'welcome.message': 'Bonjour!', 'button.submit': 'Soumettre', }, }, ]; // Create translations for (const translation of translations) { await novuClient.translations.create(translation); } // Get the translation group const response = await novuClient.translations.groups.retrieve(LocalizationResourceEnum.WORKFLOW, workflowId); expect(response.resourceId).to.equal(workflowId); expect(response.resourceType).to.equal(LocalizationResourceEnum.WORKFLOW); expect(response.resourceName).to.equal('Test Workflow for Translation Group'); expect(response.locales).to.be.an('array'); expect(response.locales).to.have.lengthOf(3); expect(response.locales).to.include.members(['en_US', 'es_ES', 'fr_FR']); expect(response.createdAt).to.be.a('string'); expect(response.updatedAt).to.be.a('string'); }); it('should include outdatedLocales when present', async () => { // First, set organization default locale and target locales await session.testAgent .patch('/v1/organizations/settings') .send({ defaultLocale: 'en_US', targetLocales: ['es_ES', 'fr_FR', 'de_DE'], }) .expect(200); const translations = [ { resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'en_US', content: { 'welcome.title': 'Welcome', 'welcome.message': 'Hello there!', }, }, { resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'es_ES', content: { 'welcome.title': 'Bienvenido', 'welcome.message': '¡Hola!', }, }, ]; /* * Create translations for en_US (default) and es_ES only * fr_FR and de_DE are configured as targets but missing = outdated */ for (const translation of translations) { await novuClient.translations.create(translation); } // Update the default locale (en_US) to add new keys, making es_ES out of sync await novuClient.translations.create({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'en_US', content: { 'welcome.title': 'Welcome Updated', 'welcome.message': 'Hello there, updated!', 'new.key': 'New content', }, }); // Get the translation group const response = await novuClient.translations.groups.retrieve(LocalizationResourceEnum.WORKFLOW, workflowId); expect(response.resourceId).to.equal(workflowId); expect(response.locales).to.include.members(['en_US', 'es_ES']); // Should include outdatedLocales: es_ES (out of sync), fr_FR (missing), de_DE (missing) expect(response.outdatedLocales).to.be.an('array'); expect(response.outdatedLocales).to.have.lengthOf(3); expect(response.outdatedLocales).to.include.members(['es_ES', 'fr_FR', 'de_DE']); }); it('should not include outdatedLocales when no target locales are configured', async () => { // Ensure no target locales are configured (only default locale) await session.testAgent .patch('/v1/organizations/settings') .send({ defaultLocale: 'en_US', }) .expect(200); // Create some translations await novuClient.translations.create({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'en_US', content: { 'welcome.title': 'Welcome', 'welcome.message': 'Hello there!', }, }); // Even if we have other locales not in target list await novuClient.translations.create({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'es_ES', content: { 'welcome.title': 'Bienvenido', }, }); // Get the translation group const response = await novuClient.translations.groups.retrieve(LocalizationResourceEnum.WORKFLOW, workflowId); expect(response.resourceId).to.equal(workflowId); expect(response.locales).to.include.members(['en_US', 'es_ES']); // Should not include outdatedLocales since no target locales are configured expect(response).to.not.have.property('outdatedLocales'); }); it('should return 404 for non-existent translation group', async () => { const fakeWorkflowId = '507f1f77bcf86cd799439011'; try { await novuClient.translations.groups.retrieve(LocalizationResourceEnum.WORKFLOW, fakeWorkflowId); throw new Error('Should have thrown 404'); } catch (error: any) { expect(error.statusCode).to.equal(404); } }); it('should return 404 for workflow without translations', async () => { // Create a workflow without any translations const { result: workflowWithoutTranslations } = await novuClient.workflows.create({ name: 'Workflow Without Translations', workflowId: `workflow-no-translations-${Date.now()}`, source: WorkflowCreationSourceEnum.EDITOR, active: true, isTranslationEnabled: false, steps: [ { name: 'No Translation Step', type: StepTypeEnum.IN_APP, controlValues: { body: 'No translation content', }, }, ], }); try { await novuClient.translations.groups.retrieve( LocalizationResourceEnum.WORKFLOW, workflowWithoutTranslations.workflowId ); throw new Error('Should have thrown 404'); } catch (error: any) { expect(error.statusCode).to.equal(404); } }); it('should return consistent structure with list endpoint', async () => { // Create translation await novuClient.translations.create({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'en_US', content: { 'test.key': 'Test value' }, }); // Get single group const singleGroup = await novuClient.translations.groups.retrieve(LocalizationResourceEnum.WORKFLOW, workflowId); // Get list and find the same group (using testAgent as list endpoint is @ApiExcludeEndpoint) const { body: listResponse } = await session.testAgent.get('/v2/translations/list').expect(200); const groupFromList = listResponse.data.find((group: any) => group.resourceId === workflowId); // Both should have the same structure expect(singleGroup).to.have.property('resourceId'); expect(singleGroup).to.have.property('resourceType'); expect(singleGroup).to.have.property('resourceName'); expect(singleGroup).to.have.property('locales'); expect(singleGroup).to.have.property('createdAt'); expect(singleGroup).to.have.property('updatedAt'); expect(groupFromList).to.have.property('resourceId'); expect(groupFromList).to.have.property('resourceType'); expect(groupFromList).to.have.property('resourceName'); expect(groupFromList).to.have.property('locales'); expect(groupFromList).to.have.property('createdAt'); expect(groupFromList).to.have.property('updatedAt'); // Values should match expect(singleGroup.resourceId).to.equal(groupFromList.resourceId); expect(singleGroup.resourceType).to.equal(groupFromList.resourceType); expect(singleGroup.resourceName).to.equal(groupFromList.resourceName); expect(singleGroup.locales).to.deep.equal(groupFromList.locales); }); }); ================================================ FILE: apps/api/src/app/translations/e2e/v2/get-translation.e2e-ee.ts ================================================ import { Novu } from '@novu/api'; import { LocalizationResourceEnum } from '@novu/dal'; import { ApiServiceLevelEnum, StepTypeEnum, WorkflowCreationSourceEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { initNovuClassSdkInternalAuth } from '../../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('Get single translation - /v2/translations/:resourceType/:resourceId/:locale (GET) #novu-v2', async () => { let session: UserSession; let novuClient: Novu; let workflowId: string; beforeEach(async () => { session = new UserSession(); await session.initialize(); // Set organization service level to business to avoid payment required errors await session.updateOrganizationServiceLevel(ApiServiceLevelEnum.BUSINESS); novuClient = initNovuClassSdkInternalAuth(session); const { result: workflow } = await novuClient.workflows.create({ name: 'Test Workflow for Translations', workflowId: `test-workflow-${Date.now()}`, source: WorkflowCreationSourceEnum.EDITOR, active: true, isTranslationEnabled: true, steps: [ { name: 'In-App Step', type: StepTypeEnum.IN_APP, controlValues: { body: 'Test content', }, }, ], }); workflowId = workflow.workflowId; }); it('should get existing translation', async () => { const translationContent = { 'welcome.title': 'Welcome', 'welcome.message': 'Hello there!', }; // Create translation first await novuClient.translations.create({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'en_US', content: translationContent, }); // Get the translation const response = await novuClient.translations.retrieve({ resourceType: LocalizationResourceEnum.WORKFLOW, resourceId: workflowId, locale: 'en_US', }); expect(response.resourceId).to.equal(workflowId); expect(response.resourceType).to.equal(LocalizationResourceEnum.WORKFLOW); expect(response.locale).to.equal('en_US'); expect(response.content).to.deep.equal(translationContent); expect(response.createdAt).to.be.a('string'); expect(response.updatedAt).to.be.a('string'); }); it('should return 404 for non-existent translation', async () => { try { await novuClient.translations.retrieve({ resourceType: LocalizationResourceEnum.WORKFLOW, resourceId: workflowId, locale: 'fr_FR', }); throw new Error('Should have thrown 404'); } catch (error: any) { expect(error.statusCode).to.equal(404); } }); it('should return 404 for non-existent workflow', async () => { const fakeWorkflowId = '507f1f77bcf86cd799439011'; try { await novuClient.translations.retrieve({ resourceType: LocalizationResourceEnum.WORKFLOW, resourceId: fakeWorkflowId, locale: 'en_US', }); throw new Error('Should have thrown 404'); } catch (error: any) { expect(error.statusCode).to.equal(404); } }); it('should validate locale format in URL parameter', async () => { try { await novuClient.translations.retrieve({ resourceType: LocalizationResourceEnum.WORKFLOW, resourceId: workflowId, locale: 'invalid-locale-123', }); throw new Error('Should have thrown 400'); } catch (error: any) { expect(error.statusCode).to.equal(400); } }); }); ================================================ FILE: apps/api/src/app/translations/e2e/v2/get-translations-list.e2e-ee.ts ================================================ import { Novu } from '@novu/api'; import { LocalizationResourceEnum } from '@novu/dal'; import { ApiServiceLevelEnum, StepTypeEnum, WorkflowCreationSourceEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { initNovuClassSdkInternalAuth } from '../../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('Get translations list - /v2/translations/list (GET) #novu-v2', async () => { let session: UserSession; let novuClient: Novu; let workflowId1: string; let workflowId2: string; let workflowId3: string; beforeEach(async () => { session = new UserSession(); await session.initialize(); // Set organization service level to business to avoid payment required errors await session.updateOrganizationServiceLevel(ApiServiceLevelEnum.BUSINESS); novuClient = initNovuClassSdkInternalAuth(session); // Create first workflow const { result: workflow1 } = await novuClient.workflows.create({ name: 'User Onboarding Workflow', workflowId: `user-onboarding-workflow-${Date.now()}`, source: WorkflowCreationSourceEnum.EDITOR, active: true, isTranslationEnabled: true, steps: [ { name: 'Welcome Email', type: StepTypeEnum.EMAIL, controlValues: { subject: 'Welcome to our platform', body: 'Welcome {{payload.name}}!', }, }, ], }); workflowId1 = workflow1.workflowId; // Create second workflow const { result: workflow2 } = await novuClient.workflows.create({ name: 'Order Confirmation Workflow', workflowId: `order-confirmation-workflow-${Date.now()}`, source: WorkflowCreationSourceEnum.EDITOR, active: true, isTranslationEnabled: true, steps: [ { name: 'Order Email', type: StepTypeEnum.EMAIL, controlValues: { subject: 'Order confirmed', body: 'Your order #{{payload.orderId}} is confirmed', }, }, ], }); workflowId2 = workflow2.workflowId; // Create third workflow const { result: workflow3 } = await novuClient.workflows.create({ name: 'Password Reset Workflow', workflowId: `password-reset-workflow-${Date.now()}`, source: WorkflowCreationSourceEnum.EDITOR, active: true, isTranslationEnabled: true, steps: [ { name: 'Reset Email', type: StepTypeEnum.EMAIL, controlValues: { subject: 'Reset your password', body: 'Click here to reset: {{payload.resetLink}}', }, }, ], }); workflowId3 = workflow3.workflowId; // Create translations for different workflows and locales const translations = [ // User Onboarding - Multiple locales { resourceId: workflowId1, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'en_US', content: { 'welcome.title': 'Welcome to our platform', 'welcome.message': 'Hello {{payload.name}}, welcome aboard!', 'button.getStarted': 'Get Started', }, }, { resourceId: workflowId1, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'es_ES', content: { 'welcome.title': 'Bienvenido a nuestra plataforma', 'welcome.message': 'Hola {{payload.name}}, ¡bienvenido!', 'button.getStarted': 'Empezar', }, }, { resourceId: workflowId1, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'fr_FR', content: { 'welcome.title': 'Bienvenue sur notre plateforme', 'welcome.message': 'Bonjour {{payload.name}}, bienvenue!', 'button.getStarted': 'Commencer', }, }, // Order Confirmation - Two locales { resourceId: workflowId2, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'en_US', content: { 'order.title': 'Order Confirmation', 'order.message': 'Your order #{{payload.orderId}} has been confirmed', 'order.total': 'Total: {{payload.total}}', }, }, { resourceId: workflowId2, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'de_DE', content: { 'order.title': 'Bestellbestätigung', 'order.message': 'Ihre Bestellung #{{payload.orderId}} wurde bestätigt', 'order.total': 'Gesamt: {{payload.total}} EUR', }, }, // Password Reset - One locale { resourceId: workflowId3, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'en_US', content: { 'reset.title': 'Password Reset', 'reset.message': 'Click the link below to reset your password', 'reset.button': 'Reset Password', }, }, ]; // Create all translations for (const translation of translations) { await novuClient.translations.create(translation); } }); it('should get paginated list of translation groups without query', async () => { const { body } = await session.testAgent.get('/v2/translations/list').expect(200); expect(body.data).to.be.an('array'); expect(body.total).to.be.a('number'); expect(body.limit).to.equal(50); // Default limit expect(body.offset).to.equal(0); // Default offset // Should have 3 groups (one per workflow) expect(body.total).to.equal(3); expect(body.data).to.have.lengthOf(3); // Verify structure of translation groups body.data.forEach((group: any) => { expect(group).to.have.property('resourceId'); expect(group).to.have.property('resourceType'); expect(group).to.have.property('resourceName'); expect(group).to.have.property('locales'); expect(group).to.have.property('createdAt'); expect(group).to.have.property('updatedAt'); expect(group.locales).to.be.an('array'); expect(group.resourceType).to.equal(LocalizationResourceEnum.WORKFLOW); }); // Verify specific locale counts const onboardingGroup = body.data.find((group: any) => group.resourceId === workflowId1); const orderGroup = body.data.find((group: any) => group.resourceId === workflowId2); const resetGroup = body.data.find((group: any) => group.resourceId === workflowId3); expect(onboardingGroup.locales).to.have.lengthOf(3); expect(onboardingGroup.locales).to.include.members(['en_US', 'es_ES', 'fr_FR']); expect(orderGroup.locales).to.have.lengthOf(2); expect(orderGroup.locales).to.include.members(['en_US', 'de_DE']); expect(resetGroup.locales).to.have.lengthOf(1); expect(resetGroup.locales).to.include('en_US'); }); it('should handle pagination with custom limit and offset', async () => { // Get first page with limit 2 const { body: page1 } = await session.testAgent.get('/v2/translations/list?limit=2&offset=0').expect(200); expect(page1.data).to.have.lengthOf(2); expect(page1.total).to.equal(3); expect(page1.limit).to.equal(2); expect(page1.offset).to.equal(0); // Get second page const { body: page2 } = await session.testAgent.get('/v2/translations/list?limit=2&offset=2').expect(200); expect(page2.data).to.have.lengthOf(1); expect(page2.total).to.equal(3); expect(page2.limit).to.equal(2); expect(page2.offset).to.equal(2); // Verify no overlap between pages const page1Ids = page1.data.map((group: any) => group.resourceId); const page2Ids = page2.data.map((group: any) => group.resourceId); const intersection = page1Ids.filter((id: string) => page2Ids.includes(id)); expect(intersection).to.have.lengthOf(0); // Verify locales are populated correctly in paginated results page1.data.forEach((group: any) => { expect(group.locales).to.be.an('array'); expect(group.locales.length).to.be.greaterThan(0); }); page2.data.forEach((group: any) => { expect(group.locales).to.be.an('array'); expect(group.locales.length).to.be.greaterThan(0); }); }); it('should filter translation groups by search query matching workflow name', async () => { const { body } = await session.testAgent.get('/v2/translations/list?query=onboarding').expect(200); expect(body.data).to.have.lengthOf(1); expect(body.total).to.equal(1); const group = body.data[0]; expect(group.resourceId).to.equal(workflowId1); expect(group.locales).to.be.an('array'); expect(group.locales).to.have.lengthOf(3); expect(group.locales).to.include.members(['en_US', 'es_ES', 'fr_FR']); }); it('should filter translation groups by search query matching workflow ID', async () => { // Search by partial workflow ID const searchTerm = workflowId2.split('-')[0]; // Get the prefix part const { body } = await session.testAgent.get(`/v2/translations/list?query=${searchTerm}`).expect(200); expect(body.data).to.have.lengthOf(1); expect(body.total).to.equal(1); expect(body.data[0].resourceId).to.equal(workflowId2); expect(body.data[0].locales).to.have.lengthOf(2); expect(body.data[0].locales).to.include.members(['en_US', 'de_DE']); }); it('should return empty results for non-matching search query', async () => { const { body } = await session.testAgent.get('/v2/translations/list?query=nonexistent').expect(200); expect(body.data).to.have.lengthOf(0); expect(body.total).to.equal(0); }); it('should handle case-insensitive search', async () => { const { body } = await session.testAgent.get('/v2/translations/list?query=ORDER').expect(200); expect(body.data).to.have.lengthOf(1); expect(body.total).to.equal(1); expect(body.data[0].resourceId).to.equal(workflowId2); expect(body.data[0].locales).to.have.lengthOf(2); expect(body.data[0].locales).to.include.members(['en_US', 'de_DE']); }); it('should combine search query with pagination', async () => { // Create additional workflows to test pagination with search const { result: workflow4 } = await novuClient.workflows.create({ name: 'User Onboarding Advanced Workflow', workflowId: `user-onboarding-advanced-${Date.now()}`, source: WorkflowCreationSourceEnum.EDITOR, active: true, isTranslationEnabled: true, steps: [ { name: 'Advanced Welcome', type: StepTypeEnum.EMAIL, controlValues: { subject: 'Advanced welcome', body: 'Advanced onboarding process', }, }, ], }); // Add translation for the new workflow await novuClient.translations.create({ resourceId: workflow4.workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'en_US', content: { 'advanced.welcome': 'Advanced Welcome' }, }); // Search for "onboarding" should now return 2 results const { body } = await session.testAgent.get('/v2/translations/list?query=onboarding&limit=1&offset=0').expect(200); expect(body.data).to.have.lengthOf(1); expect(body.total).to.equal(2); expect(body.limit).to.equal(1); expect(body.offset).to.equal(0); // Verify the returned group has locales expect(body.data[0].locales).to.be.an('array'); expect(body.data[0].locales.length).to.be.greaterThan(0); }); it('should return correct locale counts for each translation group', async () => { const { body } = await session.testAgent.get('/v2/translations/list').expect(200); // Find the user onboarding workflow const onboardingGroup = body.data.find((group: any) => group.resourceId === workflowId1); expect(onboardingGroup).to.exist; expect(onboardingGroup.locales).to.be.an('array'); expect(onboardingGroup.locales).to.have.lengthOf(3); expect(onboardingGroup.locales).to.include.members(['en_US', 'es_ES', 'fr_FR']); // Find the order confirmation workflow const orderGroup = body.data.find((group: any) => group.resourceId === workflowId2); expect(orderGroup).to.exist; expect(orderGroup.locales).to.be.an('array'); expect(orderGroup.locales).to.have.lengthOf(2); expect(orderGroup.locales).to.include.members(['en_US', 'de_DE']); // Find the password reset workflow const resetGroup = body.data.find((group: any) => group.resourceId === workflowId3); expect(resetGroup).to.exist; expect(resetGroup.locales).to.be.an('array'); expect(resetGroup.locales).to.have.lengthOf(1); expect(resetGroup.locales).to.include('en_US'); }); it('should handle large offset gracefully', async () => { const { body } = await session.testAgent.get('/v2/translations/list?offset=1000').expect(200); expect(body.data).to.have.lengthOf(0); expect(body.total).to.equal(3); expect(body.offset).to.equal(1000); }); it('should validate limit parameter bounds', async () => { // Test with limit = 10 (should work) const { body: smallLimit } = await session.testAgent.get('/v2/translations/list?limit=10').expect(200); expect(smallLimit.data).to.have.lengthOf(3); // Only 3 items available expect(smallLimit.limit).to.equal(10); // Verify locales are populated smallLimit.data.forEach((group: any) => { expect(group.locales).to.be.an('array'); expect(group.locales.length).to.be.greaterThan(0); }); const { body: largeLimit } = await session.testAgent.get('/v2/translations/list?limit=100').expect(200); expect(largeLimit.data).to.have.lengthOf(3); // Should return all available expect(largeLimit.limit).to.equal(100); // Verify locales are populated largeLimit.data.forEach((group: any) => { expect(group.locales).to.be.an('array'); expect(group.locales.length).to.be.greaterThan(0); }); }); }); ================================================ FILE: apps/api/src/app/translations/e2e/v2/import-master-json.e2e-ee.ts ================================================ import { Novu } from '@novu/api'; import { LocalizationResourceEnum } from '@novu/dal'; import { ApiServiceLevelEnum, StepTypeEnum, WorkflowCreationSourceEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { initNovuClassSdkInternalAuth } from '../../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('Import master JSON - /v2/translations/master-json (POST) #novu-v2', async () => { let session: UserSession; let novuClient: Novu; let workflowId1: string; let workflowId2: string; beforeEach(async () => { session = new UserSession(); await session.initialize(); // Set organization service level to business to avoid payment required errors await session.updateOrganizationServiceLevel(ApiServiceLevelEnum.BUSINESS); novuClient = initNovuClassSdkInternalAuth(session); // Create first workflow with translations enabled const { result: workflow1 } = await novuClient.workflows.create({ name: 'User Onboarding Workflow', workflowId: `user-onboarding-workflow-${Date.now()}`, source: WorkflowCreationSourceEnum.EDITOR, active: true, isTranslationEnabled: true, steps: [ { name: 'Welcome Email', type: StepTypeEnum.EMAIL, controlValues: { subject: 'Welcome to our platform', body: 'Welcome {{payload.name}}!', }, }, ], }); workflowId1 = workflow1.workflowId; // Create second workflow without translations for testing graceful skipping const { result: workflow2 } = await novuClient.workflows.create({ name: 'No Translation Workflow', workflowId: `no-translation-workflow-${Date.now()}`, source: WorkflowCreationSourceEnum.EDITOR, active: true, isTranslationEnabled: false, steps: [ { name: 'Simple Email', type: StepTypeEnum.EMAIL, controlValues: { subject: 'Simple notification', body: 'This workflow has no translations', }, }, ], }); workflowId2 = workflow2.workflowId; }); it('should import master JSON with valid workflows only', async () => { const masterJson = { workflows: { [workflowId1]: { 'welcome.title': 'Welcome to our platform', 'welcome.message': 'Hello {{payload.name | upcase}}, welcome aboard!', 'button.getStarted': 'Get Started', }, [workflowId2]: { 'disabled.key': 'Content for workflow with translations disabled', }, }, }; const response = await novuClient.translations.master.import({ locale: 'en_US', masterJson, }); expect(response.success).to.be.true; expect(response.message).to.include('2 resource'); // Test new response structure expect(response.successful).to.be.an('array'); expect(response.successful).to.have.lengthOf(2); expect(response.successful).to.include(workflowId1); expect(response.successful).to.include(workflowId2); expect(response.failed).to.be.undefined; // Verify translation was created for workflow1 const translation1 = await novuClient.translations.retrieve({ resourceType: LocalizationResourceEnum.WORKFLOW, resourceId: workflowId1, locale: 'en_US', }); expect(translation1.content).to.deep.equal(masterJson.workflows[workflowId1]); // Verify translation was created for workflow2 (even though translations disabled) const translation2 = await novuClient.translations.retrieve({ resourceType: LocalizationResourceEnum.WORKFLOW, resourceId: workflowId2, locale: 'en_US', }); expect(translation2.content).to.deep.equal(masterJson.workflows[workflowId2]); }); it('should gracefully skip missing workflows but import valid ones', async () => { const nonExistentWorkflowId = '507f1f77bcf86cd799439011'; const masterJson = { workflows: { [workflowId1]: { 'valid.key': 'Valid content', }, [nonExistentWorkflowId]: { 'invalid.key': 'Content for non-existent workflow', }, }, }; const response = await novuClient.translations.master.import({ locale: 'en_US', masterJson, }); expect(response.success).to.be.true; expect(response.message).to.include('Partial import completed'); // Test enhanced response structure for partial success expect(response.successful).to.be.an('array'); expect(response.successful).to.have.lengthOf(1); expect(response.successful).to.include(workflowId1); expect(response.failed).to.be.an('array'); expect(response.failed).to.have.lengthOf(1); expect(response.failed).to.include(nonExistentWorkflowId); // Verify valid translation was created const translation1 = await novuClient.translations.retrieve({ resourceType: LocalizationResourceEnum.WORKFLOW, resourceId: workflowId1, locale: 'en_US', }); expect(translation1.content).to.deep.equal(masterJson.workflows[workflowId1]); }); it('should handle complete failure gracefully', async () => { const nonExistentWorkflowId1 = '507f1f77bcf86cd799439011'; const nonExistentWorkflowId2 = '507f1f77bcf86cd799439012'; const masterJson = { workflows: { [nonExistentWorkflowId1]: { 'invalid.key1': 'Content for non-existent workflow 1', }, [nonExistentWorkflowId2]: { 'invalid.key2': 'Content for non-existent workflow 2', }, }, }; const response = await novuClient.translations.master.import({ locale: 'en_US', masterJson, }); expect(response.success).to.be.false; expect(response.message).to.include('Failed to import any resources'); // Test response structure for complete failure expect(response.successful).to.be.undefined; expect(response.failed).to.be.an('array'); expect(response.failed).to.have.lengthOf(2); expect(response.failed).to.include(nonExistentWorkflowId1); expect(response.failed).to.include(nonExistentWorkflowId2); }); it('should update existing translations correctly', async () => { // Create initial translation await novuClient.translations.create({ resourceId: workflowId1, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'en_US', content: { 'old.key': 'Old value', 'existing.key': 'Will be updated', }, }); const masterJson = { workflows: { [workflowId1]: { 'existing.key': 'Updated value', 'new.key': 'New value', }, }, }; const response = await novuClient.translations.master.import({ locale: 'en_US', masterJson, }); expect(response.success).to.be.true; expect(response.successful).to.include(workflowId1); expect(response.failed).to.be.undefined; // Verify translation was updated (replaces entire content) const translation = await novuClient.translations.retrieve({ resourceType: LocalizationResourceEnum.WORKFLOW, resourceId: workflowId1, locale: 'en_US', }); expect(translation.content).to.deep.equal(masterJson.workflows[workflowId1]); expect(translation.content).to.not.have.property('old.key'); }); it('should handle empty master JSON gracefully', async () => { const masterJson = { workflows: {}, }; const response = await novuClient.translations.master.import({ locale: 'en_US', masterJson, }); expect(response.success).to.be.false; expect(response.message).to.include('No supported resources found'); expect(response.successful).to.be.undefined; expect(response.failed).to.be.undefined; }); }); ================================================ FILE: apps/api/src/app/translations/e2e/v2/translation-replacement.e2e-ee.ts ================================================ import { Novu } from '@novu/api'; import { LayoutCreationSourceEnum } from '@novu/application-generic'; import { LocalizationResourceEnum } from '@novu/dal'; import { ApiServiceLevelEnum, StepTypeEnum, WorkflowCreationSourceEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { initNovuClassSdkInternalAuth } from '../../../shared/helpers/e2e/sdk/e2e-sdk.helper'; /** * Translation Replacement E2E Tests for V2 Workflows * * These tests verify that translation keys ({{t.key}}) are correctly replaced with * their translated values in workflow step content (subject, body, etc.). * * We use generatePreview instead of actual workflow delivery because: * * Actual workflow delivery processes jobs asynchronously through queues. Each step * creates separate jobs that execute independently, and execution details are written * incrementally (job queued → bridge execution → message created → sent, etc.). * This requires polling/waiting for job completion and querying execution details, * which may not be immediately available. * * generatePreview executes synchronously, returning results immediately without jobs * or queues. It uses the same translation logic (BaseTranslationRendererUsecase) as * actual delivery, ensuring equivalent behavior for testing translation replacement. */ describe('Translation Replacement - V2 Workflows #novu-v2', async () => { let session: UserSession; let novuClient: Novu; let workflowId: string; let emailStepId: string; let inAppStepId: string; let smsStepId: string; let chatStepId: string; beforeEach(async () => { session = new UserSession(); await session.initialize(); // Set organization service level to business for enterprise features await session.updateOrganizationServiceLevel(ApiServiceLevelEnum.BUSINESS); novuClient = initNovuClassSdkInternalAuth(session); // Create workflow with multiple channel types const { result: workflow } = await novuClient.workflows.create({ name: 'Translation Replacement Test Workflow', workflowId: `translation-test-${Date.now()}`, source: WorkflowCreationSourceEnum.EDITOR, active: true, isTranslationEnabled: true, payloadSchema: { type: 'object', properties: { name: { type: 'string' }, email: { type: 'string' }, firstName: { type: 'string' }, message: { type: 'string' }, username: { type: 'string' }, sender: { type: 'string' }, code: { type: 'string' }, appleCount: { type: 'number' }, itemCount: { type: 'number' }, address: { type: 'object', properties: { city: { type: 'string' }, country: { type: 'string' }, }, }, }, additionalProperties: false, }, steps: [ { name: 'Email Step', type: StepTypeEnum.EMAIL, controlValues: { subject: 'Test Email', body: '

Test content

', }, }, { name: 'In-App Step', type: StepTypeEnum.IN_APP, controlValues: { body: 'Test content', }, }, { name: 'SMS Step', type: StepTypeEnum.SMS, controlValues: { body: 'Test SMS', }, }, { name: 'Chat Step', type: StepTypeEnum.CHAT, controlValues: { body: 'Test Chat', }, }, ], }); workflowId = workflow.workflowId; emailStepId = (workflow.steps[0] as any).id; inAppStepId = (workflow.steps[1] as any).id; smsStepId = (workflow.steps[2] as any).id; chatStepId = (workflow.steps[3] as any).id; }); it('simple translation keys replacement', async () => { await novuClient.translations.create({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'en_US', content: { greeting: 'Hello', closing: 'Thank you', 'email.subject': 'Welcome to Our Service', 'email.body.title': 'Getting Started', 'email.body.content': 'Thanks for joining', }, }); const { result } = await novuClient.workflows.steps.generatePreview({ workflowId, stepId: emailStepId, generatePreviewRequestDto: { controlValues: { subject: '{{t.email.subject}}', body: '

{{t.email.body.title}}

{{t.greeting}}! {{t.email.body.content}}. {{t.closing}}!

', }, }, }); const preview = result.result.preview as any; expect(preview.subject).to.equal('Welcome to Our Service'); expect(preview.body).to.include('Getting Started'); expect(preview.body).to.include('Hello!'); expect(preview.body).to.include('Thanks for joining'); expect(preview.body).to.include('Thank you!'); expect(preview.body).to.not.include('{{t.'); }); describe('Locale Resolution and Fallback', () => { it('should use subscriber locale for translation', async () => { // Create translations for different locales await novuClient.translations.create({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'en_US', content: { greeting: 'Hello', }, }); await novuClient.translations.create({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'es_ES', content: { greeting: 'Hola', }, }); // Preview with Spanish locale const { result } = await novuClient.workflows.steps.generatePreview({ workflowId, stepId: emailStepId, generatePreviewRequestDto: { controlValues: { subject: 'Test', body: '

{{t.greeting}}

', }, previewPayload: { subscriber: { locale: 'es_ES', }, }, }, }); const preview = result.result.preview as any; expect(preview.body).to.include('Hola'); expect(preview.body).to.not.include('Hello'); }); it('should fallback to default locale when subscriber locale not available', async () => { await novuClient.translations.create({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'en_US', content: { greeting: 'Hello', }, }); // Preview with unsupported locale const { result } = await novuClient.workflows.steps.generatePreview({ workflowId, stepId: emailStepId, generatePreviewRequestDto: { controlValues: { subject: 'Test', body: '

{{t.greeting}}

', }, previewPayload: { subscriber: { locale: 'de_DE', // German not available }, }, }, }); const preview = result.result.preview as any; expect(preview.body).to.include('Hello'); // Falls back to en_US }); it('should fallback to default locale when subscriber has no locale', async () => { await novuClient.translations.create({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'en_US', content: { greeting: 'Hello', }, }); // Preview without subscriber locale const { result } = await novuClient.workflows.steps.generatePreview({ workflowId, stepId: emailStepId, generatePreviewRequestDto: { controlValues: { subject: 'Test', body: '

{{t.greeting}}

', }, previewPayload: { subscriber: {}, }, }, }); const preview = result.result.preview as any; expect(preview.body).to.include('Hello'); // Falls back to en_US }); it('should use per-key fallback when subscriber locale has partial translations', async () => { // Create default locale with all keys await novuClient.translations.create({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'en_US', content: { greeting: 'Hello', farewell: 'Goodbye', }, }); // Create Spanish locale with only some keys await novuClient.translations.create({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'es_ES', content: { greeting: 'Hola', // 'farewell' is missing in Spanish }, }); // Preview with Spanish locale using both keys const { result } = await novuClient.workflows.steps.generatePreview({ workflowId, stepId: emailStepId, generatePreviewRequestDto: { controlValues: { subject: 'Test', body: '

{{t.greeting}}, {{t.farewell}}

', }, previewPayload: { subscriber: { locale: 'es_ES', }, }, }, }); const preview = result.result.preview as any; expect(preview.body).to.include('Hola'); // Uses Spanish for available key expect(preview.body).to.include('Goodbye'); // Falls back to English for missing key expect(preview.body).to.not.include('Hello'); // Should not use English for available Spanish key }); }); describe('Liquid Variables in Translations', () => { it('should process liquid variables within translated content', async () => { await novuClient.translations.create({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'en_US', content: { personalized: 'Hello {{payload.name}}!', }, }); const { result } = await novuClient.workflows.steps.generatePreview({ workflowId, stepId: emailStepId, generatePreviewRequestDto: { controlValues: { subject: 'Test', body: '

{{t.personalized}}

', }, previewPayload: { payload: { name: 'John', }, }, }, }); const preview = result.result.preview as any; expect(preview.body).to.include('Hello John!'); expect(preview.body).to.not.include('{{payload.name}}'); }); it('should process liquid filters in translated content', async () => { await novuClient.translations.create({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'en_US', content: { uppercase: 'Welcome {{payload.name | upcase}}!', lowercase: 'Email: {{payload.email | downcase}}', capitalize: 'Hello {{payload.firstName | capitalize}}', }, }); const { result } = await novuClient.workflows.steps.generatePreview({ workflowId, stepId: emailStepId, generatePreviewRequestDto: { controlValues: { subject: 'Test', body: '

{{t.uppercase}} {{t.lowercase}} {{t.capitalize}}

', }, previewPayload: { payload: { name: 'john', email: 'JOHN@EXAMPLE.COM', firstName: 'mary', }, }, }, }); const preview = result.result.preview as any; expect(preview.body).to.include('Welcome JOHN!'); expect(preview.body).to.include('Email: john@example.com'); expect(preview.body).to.include('Hello Mary'); }); it('should handle nested object access in liquid variables', async () => { await novuClient.translations.create({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'en_US', content: { address: 'Shipping to {{payload.address.city}}, {{payload.address.country}}', }, }); const { result } = await novuClient.workflows.steps.generatePreview({ workflowId, stepId: emailStepId, generatePreviewRequestDto: { controlValues: { subject: 'Test', body: '

{{t.address}}

', }, previewPayload: { payload: { address: { city: 'New York', country: 'USA', }, }, }, }, }); const preview = result.result.preview as any; expect(preview.body).to.include('Shipping to New York, USA'); }); it('should handle pluralize filter with translation keys inside translations', async () => { await novuClient.translations.create({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'en_US', content: { appleSingular: 'apple', applePlural: 'apples', itemSingular: 'item', itemPlural: 'items', suffix: ' in cart', }, }); const { result } = await novuClient.workflows.steps.generatePreview({ workflowId, stepId: emailStepId, generatePreviewRequestDto: { controlValues: { subject: 'Test', body: "You have {{payload.appleCount | pluralize: 't.appleSingular', 't.applePlural', 'false'}} and {{payload.itemCount | pluralize: 't.itemSingular', 't.itemPlural', 'false' | append: 't.suffix'}}", }, previewPayload: { payload: { appleCount: 1, itemCount: 5, }, }, }, }); const preview = result.result.preview as any; expect(preview.body).to.include('You have apple and items in cart'); expect(preview.body).to.include('apple'); // Singular for count=1 expect(preview.body).to.include('items'); // Plural for count=5 expect(preview.body).to.not.include('apples'); // Should not use plural for count=1 expect(preview.body).to.not.include('item '); // Should not use singular for count=5 }); it('should render empty string for missing payload variables (consistent with non-translated content)', async () => { await novuClient.translations.create({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'en_US', content: { personalized: 'Hello {{payload.missingVar}}!', withMultiple: 'First: {{payload.undefinedField}}, Second: {{payload.notInSchema}}', }, }); const { result } = await novuClient.workflows.steps.generatePreview({ workflowId, stepId: emailStepId, generatePreviewRequestDto: { controlValues: { subject: 'Test', body: '

{{t.personalized}}

{{t.withMultiple}}

', }, previewPayload: { payload: { // missingVar, undefinedField, and notInSchema are not defined }, }, }, }); const preview = result.result.preview as any; expect(preview.body).to.include('Hello !'); // Missing variable renders as empty string expect(preview.body).to.include('First: , Second: '); // Both missing render as empty strings expect(preview.body).to.not.include('{{payload.'); // Variables should be processed }); }); describe('Layout Translations', () => { it('should replace translation keys in layout content when used in workflow step', async () => { // Create layout const { result: layout } = await novuClient.layouts.create({ layoutId: `layout-translation-${Date.now()}`, name: 'Layout Translation Test', source: LayoutCreationSourceEnum.DASHBOARD, }); // Update layout with translation enabled and layout content await novuClient.layouts.update( { name: 'Layout Translation Test', isTranslationEnabled: true, controlValues: { email: { body: ` Layout Translation Test
{{content}}

Footer: {{t.layout.footer}}

`, editorType: 'html', }, }, }, layout.layoutId ); // Create layout translations await novuClient.translations.create({ resourceId: layout.layoutId, resourceType: LocalizationResourceEnum.LAYOUT, locale: 'en_US', content: { 'layout.footer': '© 2024 Our Company', }, }); // Create workflow step that uses the layout const { result: workflow } = await novuClient.workflows.create({ name: 'Layout Translation Workflow', workflowId: `layout-workflow-${Date.now()}`, source: WorkflowCreationSourceEnum.EDITOR, active: true, isTranslationEnabled: true, steps: [ { name: 'Email Step', type: StepTypeEnum.EMAIL, controlValues: { subject: 'Test', body: '

Workflow content

', layoutId: layout.layoutId, }, }, ], }); const { result } = await novuClient.workflows.steps.generatePreview({ workflowId: workflow.workflowId, stepId: (workflow.steps[0] as any).id, generatePreviewRequestDto: { controlValues: { subject: 'Test', body: '

Workflow content

', layoutId: layout.layoutId, }, }, }); const preview = result.result.preview as any; expect(preview.body).to.include('© 2024 Our Company'); expect(preview.body).to.include('Workflow content'); expect(preview.body).to.not.include('{{t.layout.footer}}'); }); }); describe('Different Channel Types', () => { it('should replace translations in in-app notifications', async () => { await novuClient.translations.create({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'en_US', content: { 'inapp.subject': 'New Notification', 'inapp.body': 'You have a new message from {{payload.sender}}', }, }); const { result } = await novuClient.workflows.steps.generatePreview({ workflowId, stepId: inAppStepId, generatePreviewRequestDto: { controlValues: { subject: '{{t.inapp.subject}}', body: '{{t.inapp.body}}', }, previewPayload: { payload: { sender: 'Admin', }, }, }, }); const preview = result.result.preview as any; expect(preview.subject).to.equal('New Notification'); expect(preview.body).to.include('You have a new message from Admin'); }); it('should replace translations in SMS messages', async () => { await novuClient.translations.create({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'en_US', content: { 'sms.message': 'Your code is {{payload.code}}', }, }); const { result } = await novuClient.workflows.steps.generatePreview({ workflowId, stepId: smsStepId, generatePreviewRequestDto: { controlValues: { body: '{{t.sms.message}}', }, previewPayload: { payload: { code: '123456', }, }, }, }); const preview = result.result.preview as any; expect(preview.body).to.equal('Your code is 123456'); }); it('should replace translations in chat messages', async () => { await novuClient.translations.create({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'en_US', content: { 'chat.message': 'New message: {{payload.message}}', }, }); const { result } = await novuClient.workflows.steps.generatePreview({ workflowId, stepId: chatStepId, generatePreviewRequestDto: { controlValues: { body: '{{t.chat.message}}', }, previewPayload: { payload: { message: 'Hello from chat!', }, }, }, }); const preview = result.result.preview as any; expect(preview.body).to.equal('New message: Hello from chat!'); }); }); describe('Escaped Characters in Translations', () => { it('should handle translations with escaped quotes', async () => { await novuClient.translations.create({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'en_US', content: { quoted: 'Welcome to "Our Service" - You\'re all set!', }, }); const { result } = await novuClient.workflows.steps.generatePreview({ workflowId, stepId: emailStepId, generatePreviewRequestDto: { controlValues: { subject: '{{t.quoted}}', body: '

Test

', }, }, }); const preview = result.result.preview as any; expect(preview.subject).to.include('"Our Service"'); expect(preview.subject).to.include("You're all set!"); expect(preview.subject).to.not.include('\\"'); expect(preview.subject).to.not.include("\\'"); }); it('should handle translations with newlines and special characters', async () => { await novuClient.translations.create({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'en_US', content: { multiline: 'Line 1\nLine 2\tTabbed content', }, }); const { result } = await novuClient.workflows.steps.generatePreview({ workflowId, stepId: emailStepId, generatePreviewRequestDto: { controlValues: { subject: 'Test', body: '

{{t.multiline}}

', }, }, }); const preview = result.result.preview as any; expect(preview.body).to.include('Line 1'); expect(preview.body).to.include('Line 2'); expect(preview.body).to.not.include('\\n'); expect(preview.body).to.not.include('\\t'); }); }); describe('Error Handling', () => { /* * Note: These tests use generatePreview instead of actual workflow delivery. * PreviewUsecase gracefully handles translation errors by catching exceptions * and returning an empty preview object ({}) for UI stability (questionable choice). * An empty preview (where subject and body are undefined) indicates that a translation error occurred. * * TODO: To actually see the error messages from bridge execution (e.g., "Translation is not enabled * for this resource", "Missing translation for key 'xyz'"), we should either: * 1. Rework these tests to use actual workflow delivery (novuClient.trigger) and check execution * details for bridge execution failures, OR * 2. Rework generatePreview to return errors instead of silently returning empty preview objects. * This would provide more detailed error information than empty preview objects. */ it('should return empty preview when translation keys used but translation not enabled for resource', async () => { // Create workflow with translation explicitly disabled const { result: workflow } = await novuClient.workflows.create({ name: 'No Translation Workflow', workflowId: `no-translation-${Date.now()}`, source: WorkflowCreationSourceEnum.EDITOR, active: true, isTranslationEnabled: false, // Disabled steps: [ { name: 'Email Step', type: StepTypeEnum.EMAIL, controlValues: { subject: 'Test', body: '

{{t.greeting}}

', // Using translation key when disabled }, }, ], }); // generatePreview catches translation errors and returns empty object const { result } = await novuClient.workflows.steps.generatePreview({ workflowId: workflow.workflowId, stepId: (workflow.steps[0] as any).id, generatePreviewRequestDto: { controlValues: { subject: 'Test', body: '

{{t.greeting}}

', }, }, }); // Empty preview (undefined subject/body) indicates translation error occurred const preview = result.result.preview as any; expect(preview).to.be.an('object'); expect(preview.subject).to.be.undefined; expect(preview.body).to.be.undefined; }); it('should return empty preview for missing translation key', async () => { // Create workflow with translation enabled const { result: workflow } = await novuClient.workflows.create({ name: 'Missing Translation Key Workflow', workflowId: `missing-key-${Date.now()}`, source: WorkflowCreationSourceEnum.EDITOR, active: true, isTranslationEnabled: true, steps: [ { name: 'Email Step', type: StepTypeEnum.EMAIL, controlValues: { subject: 'Test', body: '

{{t.missingKey}}

', // Key doesn't exist }, }, ], }); // Create translation with wrong key (missing 'missingKey') await novuClient.translations.create({ resourceId: workflow.workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, locale: 'en_US', content: { existingKey: 'This exists', }, }); // generatePreview catches missing translation key errors and returns empty object const { result } = await novuClient.workflows.steps.generatePreview({ workflowId: workflow.workflowId, stepId: (workflow.steps[0] as any).id, generatePreviewRequestDto: { controlValues: { subject: 'Test', body: '

{{t.missingKey}}

', }, }, }); // Empty preview (undefined subject/body) indicates missing translation key error const preview = result.result.preview as any; expect(preview).to.be.an('object'); expect(preview.subject).to.be.undefined; expect(preview.body).to.be.undefined; }); it('should return empty preview when translations not created but translation keys used', async () => { // Create workflow with translation enabled but no translations created const { result: workflow } = await novuClient.workflows.create({ name: 'No Translations Created', workflowId: `no-translations-created-${Date.now()}`, source: WorkflowCreationSourceEnum.EDITOR, active: true, isTranslationEnabled: true, // Enabled but no translations created steps: [ { name: 'Email Step', type: StepTypeEnum.EMAIL, controlValues: { subject: 'Test', body: '

{{t.greeting}}

', // Translation key but no translations exist }, }, ], }); // generatePreview catches "no translations found" errors and returns empty object const { result } = await novuClient.workflows.steps.generatePreview({ workflowId: workflow.workflowId, stepId: (workflow.steps[0] as any).id, generatePreviewRequestDto: { controlValues: { subject: 'Test', body: '

{{t.greeting}}

', }, }, }); // Empty preview (undefined subject/body) indicates no translations found error const preview = result.result.preview as any; expect(preview).to.be.an('object'); expect(preview.subject).to.be.undefined; expect(preview.body).to.be.undefined; }); }); }); describe('Translation Feature Access - V2 Workflows #novu-v2', async () => { let session: UserSession; let novuClient: Novu; it('should throw PaymentRequired error when organization lacks translation feature', async () => { session = new UserSession(); await session.initialize(); // Keep organization at FREE tier (no BUSINESS upgrade) novuClient = initNovuClassSdkInternalAuth(session); // Attempt to create workflow with translation enabled on FREE tier try { await novuClient.workflows.create({ name: 'Translation Test Workflow', workflowId: `translation-free-tier-${Date.now()}`, source: WorkflowCreationSourceEnum.EDITOR, active: true, isTranslationEnabled: true, // This should fail on FREE tier steps: [ { name: 'Email Step', type: StepTypeEnum.EMAIL, controlValues: { subject: 'Test Email', body: '

Test content

', }, }, ], }); expect.fail('Should have thrown PaymentRequired error'); } catch (error: any) { expect(error.statusCode).to.equal(402); expect(error.message).to.match(/payment required|not available on your plan/i); } }); }); ================================================ FILE: apps/api/src/app/translations/e2e/v2/upload-master-json.e2e-ee.ts ================================================ import { Novu } from '@novu/api'; import { LocalizationResourceEnum } from '@novu/dal'; import { ApiServiceLevelEnum, StepTypeEnum, WorkflowCreationSourceEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { initNovuClassSdkInternalAuth } from '../../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('Upload master JSON file - /v2/translations/master-json/upload (POST) #novu-v2', async () => { let session: UserSession; let novuClient: Novu; let workflowId: string; beforeEach(async () => { session = new UserSession(); await session.initialize(); // Set organization service level to business to avoid payment required errors await session.updateOrganizationServiceLevel(ApiServiceLevelEnum.BUSINESS); novuClient = initNovuClassSdkInternalAuth(session); // Create workflow for basic integration test const { result: workflow } = await novuClient.workflows.create({ name: 'Test Workflow', workflowId: `test-workflow-${Date.now()}`, source: WorkflowCreationSourceEnum.EDITOR, active: true, isTranslationEnabled: true, steps: [ { name: 'Test Email', type: StepTypeEnum.EMAIL, controlValues: { subject: 'Test subject', body: 'Test body', }, }, ], }); workflowId = workflow.workflowId; }); it('should upload master JSON file successfully', async () => { const masterJson = { workflows: { [workflowId]: { 'test.key': 'Test value', 'another.key': 'Another value', }, }, }; const response = await novuClient.translations.master.upload({ file: { fileName: 'en_US.json', content: Buffer.from(JSON.stringify(masterJson)), }, }); expect(response.success).to.be.true; expect(response.message).to.include('1 resource'); // Test new response structure expect(response.successful).to.be.an('array'); expect(response.successful).to.have.lengthOf(1); expect(response.successful).to.include(workflowId); expect(response.failed).to.be.undefined; // No failures // Verify translation was created (basic integration test) const translation = await novuClient.translations.retrieve({ resourceType: LocalizationResourceEnum.WORKFLOW, resourceId: workflowId, locale: 'en_US', }); expect(translation.content).to.deep.equal(masterJson.workflows[workflowId]); }); it('should handle mixed success and failure in uploaded file', async () => { const nonExistentWorkflowId = '507f1f77bcf86cd799439011'; const masterJson = { workflows: { [workflowId]: { 'valid.key': 'Valid content', }, [nonExistentWorkflowId]: { 'invalid.key': 'Content for non-existent workflow', }, }, }; const response = await novuClient.translations.master.upload({ file: { fileName: 'en_US.json', content: Buffer.from(JSON.stringify(masterJson)), }, }); expect(response.success).to.be.true; expect(response.message).to.include('Partial import completed'); // Test enhanced response structure for mixed results expect(response.successful).to.be.an('array'); expect(response.successful).to.have.lengthOf(1); expect(response.successful).to.include(workflowId); expect(response.failed).to.be.an('array'); expect(response.failed).to.have.lengthOf(1); expect(response.failed).to.include(nonExistentWorkflowId); }); it('should validate file requirements', async () => { const masterJson = { workflows: { [workflowId]: { 'test.key': 'Test value', }, }, }; // Test missing file await session.testAgent.post('/v2/translations/master-json/upload').expect(400); // Test multiple files (should only allow one) await session.testAgent .post('/v2/translations/master-json/upload') .attach('file', Buffer.from(JSON.stringify(masterJson)), 'en_US.json') .attach('file', Buffer.from(JSON.stringify(masterJson)), 'fr_FR.json') .expect(400); }); it('should validate filename format', async () => { const masterJson = { workflows: { [workflowId]: { 'test.key': 'Test value', }, }, }; // Test invalid filename patterns const invalidFilenames = ['invalid-filename.json', 'en_US-master.json', 'en_US.txt', 'notlocale.json', 'en.json']; for (const filename of invalidFilenames) { try { await novuClient.translations.master.upload({ file: { fileName: filename, content: Buffer.from(JSON.stringify(masterJson)), }, }); expect.fail(`Should have thrown an error for filename: ${filename}`); } catch (error: any) { expect(error.statusCode).to.equal(400); } } // Test valid filename patterns const validFilenames = ['en_US.json', 'fr_FR.json', 'zh_CN.json']; for (const filename of validFilenames) { const response = await novuClient.translations.master.upload({ file: { fileName: filename, content: Buffer.from(JSON.stringify(masterJson)), }, }); // Verify response structure for valid uploads expect(response.success).to.be.true; expect(response.successful).to.be.an('array'); expect(response.successful).to.include(workflowId); } }); it('should handle file processing correctly', async () => { const masterJson = { workflows: { [workflowId]: { 'unicode.test': 'Hello 👋 世界 🌍', 'liquid.test': 'Hello {{payload.name | upcase}}', }, }, }; // Test formatted JSON (with indentation) const formattedJson = JSON.stringify(masterJson, null, 2); const formattedResponse = await novuClient.translations.master.upload({ file: { fileName: 'en_US.json', content: Buffer.from(formattedJson, 'utf8'), }, }); expect(formattedResponse.success).to.be.true; expect(formattedResponse.successful).to.include(workflowId); // Test compressed JSON const compressedJson = JSON.stringify(masterJson); const compressedResponse = await novuClient.translations.master.upload({ file: { fileName: 'fr_FR.json', content: Buffer.from(compressedJson, 'utf8'), }, }); expect(compressedResponse.success).to.be.true; expect(compressedResponse.successful).to.include(workflowId); // Verify Unicode and liquid variables are preserved const translation = await novuClient.translations.retrieve({ resourceType: LocalizationResourceEnum.WORKFLOW, resourceId: workflowId, locale: 'en_US', }); expect(translation.content['unicode.test']).to.equal('Hello 👋 世界 🌍'); expect(translation.content['liquid.test']).to.equal('Hello {{payload.name | upcase}}'); }); it('should reject invalid JSON files', async () => { // Test invalid JSON content try { await novuClient.translations.master.upload({ file: { fileName: 'en_US.json', content: Buffer.from('invalid json content'), }, }); expect.fail('Should have thrown an error for invalid JSON'); } catch (error: any) { expect(error.statusCode).to.equal(400); } // Test empty file try { await novuClient.translations.master.upload({ file: { fileName: 'en_US.json', content: Buffer.from(''), }, }); expect.fail('Should have thrown an error for empty file'); } catch (error: any) { expect(error.statusCode).to.equal(400); } // Test non-JSON file try { await novuClient.translations.master.upload({ file: { fileName: 'en_US.json', content: Buffer.from('not json'), }, }); expect.fail('Should have thrown an error for non-JSON file'); } catch (error: any) { expect(error.statusCode).to.equal(400); } }); it('should handle empty workflows object in uploaded file', async () => { const masterJson = { workflows: {}, }; const response = await novuClient.translations.master.upload({ file: { fileName: 'en_US.json', content: Buffer.from(JSON.stringify(masterJson)), }, }); expect(response.success).to.be.false; expect(response.message).to.include('No supported resources found'); expect(response.successful).to.be.undefined; expect(response.failed).to.be.undefined; }); }); ================================================ FILE: apps/api/src/app/translations/e2e/v2/upload-translations.e2e-ee.ts ================================================ import { Novu } from '@novu/api'; import { LocalizationResourceEnum } from '@novu/dal'; import { ApiServiceLevelEnum, StepTypeEnum, WorkflowCreationSourceEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { initNovuClassSdkInternalAuth } from '../../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('Upload translation files - /v2/translations/upload (POST) #novu-v2', async () => { let session: UserSession; let novuClient: Novu; let workflowId: string; beforeEach(async () => { session = new UserSession(); await session.initialize(); // Set organization service level to business to avoid payment required errors await session.updateOrganizationServiceLevel(ApiServiceLevelEnum.BUSINESS); /* * Configure organization locales with a more minimal set * Only configure locales that are commonly used across tests */ await session.testAgent .patch('/v1/organizations/settings') .send({ defaultLocale: 'en_US', targetLocales: ['es_ES', 'fr_FR', 'de_DE', 'it_IT'], // Include all locales that might be used in tests }) .expect(200); novuClient = initNovuClassSdkInternalAuth(session); const { result: workflow } = await novuClient.workflows.create({ name: 'Test Workflow for Translations', workflowId: `test-workflow-${Date.now()}`, source: WorkflowCreationSourceEnum.EDITOR, active: true, isTranslationEnabled: true, steps: [ { name: 'In-App Step', type: StepTypeEnum.IN_APP, controlValues: { body: 'Test content', }, }, ], }); workflowId = workflow.workflowId; }); it('should upload single translation file', async () => { const translationContent = { 'welcome.title': 'Welcome', 'welcome.message': 'Hello there!', 'button.submit': 'Submit', }; const response = await novuClient.translations.upload({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, files: [ { fileName: 'en_US.json', content: Buffer.from(JSON.stringify(translationContent)), }, ], }); expect(response.totalFiles).to.equal(1); expect(response.successfulUploads).to.equal(1); expect(response.failedUploads).to.equal(0); expect(response.errors).to.be.an('array').that.is.empty; // Verify the translation was created const translation = await novuClient.translations.retrieve({ resourceType: LocalizationResourceEnum.WORKFLOW, resourceId: workflowId, locale: 'en_US', }); expect(translation.content).to.deep.equal(translationContent); }); it('should upload multiple translation files', async () => { const enContent = { 'welcome.title': 'Welcome', 'welcome.message': 'Hello there!', }; const esContent = { 'welcome.title': 'Bienvenido', 'welcome.message': '¡Hola!', }; const response = await novuClient.translations.upload({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, files: [ { fileName: 'en_US.json', content: Buffer.from(JSON.stringify(enContent)), }, { fileName: 'es_ES.json', content: Buffer.from(JSON.stringify(esContent)), }, ], }); expect(response.totalFiles).to.equal(2); expect(response.successfulUploads).to.equal(2); expect(response.failedUploads).to.equal(0); expect(response.errors).to.be.an('array').that.is.empty; // Verify both translations were created const translationGroup = await novuClient.translations.groups.retrieve( LocalizationResourceEnum.WORKFLOW, workflowId ); /* * The locales should include configured locales plus any uploaded locales * Configured: en_US (default), es_ES, fr_FR, de_DE, it_IT (targets) */ expect(translationGroup.locales).to.have.lengthOf(5); expect(translationGroup.locales).to.include('en_US'); expect(translationGroup.locales).to.include('es_ES'); expect(translationGroup.locales).to.include('fr_FR'); expect(translationGroup.locales).to.include('de_DE'); expect(translationGroup.locales).to.include('it_IT'); }); it('should update existing translation when uploading same locale', async () => { const originalContent = { key1: 'original value' }; const updatedContent = { key1: 'updated value', key2: 'new value' }; // Upload initial translation await novuClient.translations.upload({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, files: [ { fileName: 'en_US.json', content: Buffer.from(JSON.stringify(originalContent)), }, ], }); // Upload updated translation const response = await novuClient.translations.upload({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, files: [ { fileName: 'en_US.json', content: Buffer.from(JSON.stringify(updatedContent)), }, ], }); expect(response.successfulUploads).to.equal(1); // Verify the content was updated const translation = await novuClient.translations.retrieve({ resourceType: LocalizationResourceEnum.WORKFLOW, resourceId: workflowId, locale: 'en_US', }); expect(translation.content).to.deep.equal(updatedContent); }); it('should handle different filename patterns', async () => { const content = { key: 'value' }; const testCases = [ { filename: 'en_US.json', expectedLocale: 'en_US' }, { filename: 'fr_FR.json', expectedLocale: 'fr_FR' }, { filename: 'de_DE.json', expectedLocale: 'de_DE' }, { filename: 'it_IT.json', expectedLocale: 'it_IT' }, ]; for (const testCase of testCases) { const response = await novuClient.translations.upload({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, files: [ { fileName: testCase.filename, content: Buffer.from(JSON.stringify(content)), }, ], }); expect(response.successfulUploads).to.equal(1); // Verify the locale was extracted correctly const translation = await novuClient.translations.retrieve({ resourceType: LocalizationResourceEnum.WORKFLOW, resourceId: workflowId, locale: testCase.expectedLocale, }); expect(translation.locale).to.equal(testCase.expectedLocale); } }); it('should reject invalid JSON files', async () => { try { await novuClient.translations.upload({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, files: [ { fileName: 'en_US.json', content: Buffer.from('invalid json content'), }, ], }); expect.fail('Should have thrown an error'); } catch (error: any) { expect(error.statusCode).to.equal(400); expect(error.message).to.include('No valid translation files were found'); } }); it('should reject files with invalid locale patterns', async () => { const content = { key: 'value' }; try { await novuClient.translations.upload({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, files: [ { fileName: 'invalid-filename.json', content: Buffer.from(JSON.stringify(content)), }, ], }); expect.fail('Should have thrown an error'); } catch (error: any) { expect(error.statusCode).to.equal(400); expect(error.message).to.include('invalid names or formats'); const errorBody = typeof error.body === 'string' ? JSON.parse(error.body) : error.body; expect(errorBody.errors).to.be.an('array').that.is.not.empty; expect(errorBody.errors[0]).to.include('invalid-filename.json'); } }); it('should reject uploads with invalid filename patterns', async () => { const validContent = { key: 'value' }; // This test should fail at validation level because invalid-name.json has invalid locale pattern try { await novuClient.translations.upload({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, files: [ { fileName: 'en_US.json', content: Buffer.from(JSON.stringify(validContent)), }, { fileName: 'es_ES.json', content: Buffer.from('invalid json'), }, { fileName: 'invalid-name.json', content: Buffer.from(JSON.stringify(validContent)), }, ], }); expect.fail('Should have thrown an error'); } catch (error: any) { expect(error.statusCode).to.equal(400); expect(error.message).to.include('invalid names or formats'); const errorBody = typeof error.body === 'string' ? JSON.parse(error.body) : error.body; expect(errorBody.errors).to.be.an('array').that.is.not.empty; expect(errorBody.errors[0]).to.include('invalid-name.json'); } }); it('should handle mixed success and failure uploads with valid filenames', async () => { const validContent = { key: 'value' }; const response = await novuClient.translations.upload({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, files: [ { fileName: 'en_US.json', content: Buffer.from(JSON.stringify(validContent)), }, { fileName: 'es_ES.json', content: Buffer.from('invalid json'), }, { fileName: 'fr_FR.json', content: Buffer.from(JSON.stringify(validContent)), }, ], }); expect(response.totalFiles).to.equal(3); expect(response.successfulUploads).to.equal(2); expect(response.failedUploads).to.equal(1); expect(response.errors).to.have.lengthOf(1); expect(response.errors[0]).to.include("Failed to process file 'es_ES.json'"); }); it('should reject uploads for locales not configured in organization settings', async () => { const validContent = { key: 'value' }; /* * Try to upload a locale that is not in the configured locales * Configured locales are: en_US (default), es_ES, fr_FR, de_DE, it_IT */ try { await novuClient.translations.upload({ resourceId: workflowId, resourceType: LocalizationResourceEnum.WORKFLOW, files: [ { fileName: 'ja_JP.json', // Japanese not configured content: Buffer.from(JSON.stringify(validContent)), }, ], }); expect.fail('Should have thrown an error'); } catch (error: any) { expect(error.statusCode).to.equal(400); expect(error.message).to.include('The following locales are not configured for your organization: ja_JP'); expect(error.message).to.include('Please add these locales in your translation settings'); expect(error.message).to.include('configured locales: en_US, es_ES, fr_FR, de_DE, it_IT'); } }); }); ================================================ FILE: apps/api/src/app/user/dtos/change-profile-email.dto.ts ================================================ import { IsDefined, IsEmail } from 'class-validator'; export class ChangeProfileEmailDto { @IsDefined() @IsEmail() email: string; } ================================================ FILE: apps/api/src/app/user/dtos/update-profile-request.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import type { IUpdateUserProfile } from '@novu/shared'; import { IsOptional, IsUrl } from 'class-validator'; import { IsImageUrl } from '../../shared/validators/image.validator'; const protocols = process.env.NODE_ENV === 'production' ? ['https'] : ['http', 'https']; export class UpdateProfileRequestDto implements IUpdateUserProfile { @ApiProperty() firstName: string; @ApiProperty() lastName: string; @ApiProperty() @IsUrl({ require_protocol: true, protocols, require_tld: false, }) @IsImageUrl({ message: 'Logo must be a valid image URL with one of the following extensions: jpg, jpeg, png, gif, svg', }) @IsOptional() profilePicture?: string; } ================================================ FILE: apps/api/src/app/user/dtos/user-onboarding-request.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; export class UserOnboardingRequestDto { @ApiProperty() showOnBoarding: boolean; } ================================================ FILE: apps/api/src/app/user/dtos/user-onboarding-tour-request.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; export class UserOnboardingTourRequestDto { @ApiProperty() showOnBoardingTour: number; } ================================================ FILE: apps/api/src/app/user/dtos/user-response.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IUserEntity, JobTitleEnum } from '@novu/shared'; export class ServicesHashesDto { @ApiProperty() plain?: string; } export class UserResponseDto implements IUserEntity { @ApiProperty() _id: string; @ApiPropertyOptional() resetToken?: string; @ApiPropertyOptional() resetTokenDate?: string; @ApiProperty() firstName?: string | null; @ApiProperty() lastName?: string | null; @ApiProperty() email?: string | null; @ApiProperty() profilePicture?: string | null; @ApiProperty() createdAt: string; @ApiPropertyOptional() showOnBoarding?: boolean; @ApiProperty() servicesHashes?: ServicesHashesDto; @ApiPropertyOptional({ enum: JobTitleEnum, }) jobTitle?: JobTitleEnum; @ApiProperty() hasPassword: boolean; } ================================================ FILE: apps/api/src/app/user/e2e/email-change.e2e.ts ================================================ import { UserSession } from '@novu/testing'; import { expect } from 'chai'; describe('Change Profile Email - /users/profile/email (PUT) #novu-v0-os', async () => { let session: UserSession; let existingSession: UserSession; before(async () => { session = new UserSession(); await session.initialize(); existingSession = new UserSession(); await existingSession.initialize(); }); it('should throw when existing email provided', async () => { const { body } = await session.testAgent.put('/v1/users/profile/email').send({ email: existingSession.user.email, }); expect(body.statusCode).to.equal(400); expect(body.message).to.equal('E-mail is invalid or taken'); }); it('should update the e-mail address', async () => { const { body } = await session.testAgent.put('/v1/users/profile/email').send({ email: 'another-email@gmail.com', }); expect(body.data.email).to.equal('another-email@gmail.com'); }); it('should normalize the updated the e-mail address', async () => { const { body } = await session.testAgent.put('/v1/users/profile/email').send({ email: 'another-email-12+123@gmail.com', }); expect(body.data.email).to.equal('another-email-12@gmail.com'); }); }); ================================================ FILE: apps/api/src/app/user/e2e/get-me.e2e.ts ================================================ import { UserSession } from '@novu/testing'; import { expect } from 'chai'; describe('User Profile #novu-v0-os', async () => { let session: UserSession; before(async () => { session = new UserSession(); await session.initialize(); }); it('should return a correct user profile', async () => { const { body } = await session.testAgent.get('/v1/users/me').expect(200); const me = body.data; expect(me._id).to.equal(session.user._id); expect(me.firstName).to.equal(session.user.firstName); expect(me.lastName).to.equal(session.user.lastName); expect(me.email).to.equal(session.user.email); }); }); ================================================ FILE: apps/api/src/app/user/e2e/update-name-and-profile-picture.e2e.ts ================================================ import { processTestAgentExpectedStatusCode, UserSession } from '@novu/testing'; import { expect } from 'chai'; describe('Update user name and profile picture - /users/profile (PUT) #novu-v0-os', async () => { let session: UserSession; before(async () => { session = new UserSession(); await session.initialize(); }); it('should update the user name and profile picture', async () => { const profilePicture = 'https://example.com/profile-picture.jpg'; const { body: { data }, } = await session.testAgent .put('/v1/users/profile') .send({ firstName: 'John', lastName: 'Doe', profilePicture, }) .expect(processTestAgentExpectedStatusCode(200)); expect(data.firstName).to.equal('John'); expect(data.lastName).to.equal('Doe'); expect(data.profilePicture).to.equal(profilePicture); }); it('should update the user name', async () => { const { body: { data }, statusCode, } = await session.testAgent.put('/v1/users/profile').send({ firstName: 'John', lastName: 'Doe', }); expect(statusCode).to.equal(200); expect(data.firstName).to.equal('John'); expect(data.lastName).to.equal('Doe'); }); it('should throw when invalid first name or last name provided', async () => { const { body } = await session.testAgent.put('/v1/users/profile').send({ firstName: '', lastName: 'Doe', }); expect(body.statusCode).to.equal(400); expect(body.message).to.equal('First name and last name are required'); const { body: body2 } = await session.testAgent.put('/v1/users/profile').send({ firstName: 'John', lastName: '', }); expect(body2.statusCode).to.equal(400); expect(body2.message).to.equal('First name and last name are required'); }); }); ================================================ FILE: apps/api/src/app/user/usecases/base-user-profile.usecase.ts ================================================ import type { UserEntity } from '@novu/dal'; import { UserResponseDto } from '../dtos/user-response.dto'; export class BaseUserProfileUsecase { protected mapToDto(user: UserEntity): UserResponseDto { const { _id, resetToken, resetTokenDate, firstName, lastName, email, profilePicture, createdAt, showOnBoarding, servicesHashes, jobTitle, password, } = user; return { _id, resetToken, resetTokenDate, firstName, lastName, email, profilePicture, createdAt, showOnBoarding, servicesHashes, jobTitle, hasPassword: !!password, }; } } ================================================ FILE: apps/api/src/app/user/usecases/create-user/create-user.command.ts ================================================ import { BaseCommand } from '@novu/application-generic'; import { AuthProviderEnum } from '@novu/shared'; export class CreateUserCommand extends BaseCommand { email: string; firstName?: string | null; lastName?: string | null; picture?: string; auth: { username?: string; profileId: string; provider: AuthProviderEnum; accessToken: string; refreshToken: string; }; } ================================================ FILE: apps/api/src/app/user/usecases/create-user/create-user.usecase.ts ================================================ import { Injectable } from '@nestjs/common'; import { UserEntity, UserRepository } from '@novu/dal'; import { CreateUserCommand } from './create-user.command'; @Injectable() export class CreateUser { constructor(private readonly userRepository: UserRepository) {} async execute(data: CreateUserCommand): Promise { const user = new UserEntity(); user.email = data.email ? data.email.toLowerCase() : ''; user.firstName = data.firstName ? data.firstName.toLowerCase() : ''; user.lastName = data.lastName ? data.lastName.toLowerCase() : data.lastName; user.profilePicture = data.picture; user.showOnBoarding = true; user.tokens = [ { username: data.auth.username, providerId: data.auth.profileId, provider: data.auth.provider, accessToken: data.auth.accessToken, refreshToken: data.auth.refreshToken, valid: true, }, ]; return await this.userRepository.create(user); } } ================================================ FILE: apps/api/src/app/user/usecases/create-user/index.ts ================================================ export * from './create-user.command'; export * from './create-user.usecase'; ================================================ FILE: apps/api/src/app/user/usecases/get-my-profile/get-my-profile.dto.ts ================================================ import { AuthenticatedCommand } from '../../../shared/commands/authenticated.command'; export class GetMyProfileCommand extends AuthenticatedCommand {} ================================================ FILE: apps/api/src/app/user/usecases/get-my-profile/get-my-profile.usecase.ts ================================================ import { Injectable, NotFoundException } from '@nestjs/common'; import { PinoLogger } from '@novu/application-generic'; import { UserRepository } from '@novu/dal'; import type { UserResponseDto } from '../../dtos/user-response.dto'; import { BaseUserProfileUsecase } from '../base-user-profile.usecase'; import { GetMyProfileCommand } from './get-my-profile.dto'; @Injectable() export class GetMyProfileUsecase extends BaseUserProfileUsecase { constructor( private readonly userRepository: UserRepository, private readonly logger: PinoLogger ) { super(); this.logger.setContext(this.constructor.name); } async execute(command: GetMyProfileCommand): Promise { this.logger.trace('Getting User from user repository in Command'); this.logger.debug(`Getting user data for ${command.userId}`); const profile = await this.userRepository.findById(command.userId); if (!profile) { throw new NotFoundException('User not found'); } this.logger.trace('Found User'); return this.mapToDto(profile); } } ================================================ FILE: apps/api/src/app/user/usecases/index.ts ================================================ import { CreateUser } from './create-user/create-user.usecase'; import { GetMyProfileUsecase } from './get-my-profile/get-my-profile.usecase'; import { UpdateNameAndProfilePicture } from './update-name-and-profile-picture/update-name-and-profile-picture.usecase'; import { UpdateOnBoardingUsecase } from './update-on-boarding/update-on-boarding.usecase'; import { UpdateOnBoardingTourUsecase } from './update-on-boarding-tour/update-on-boarding-tour.usecase'; import { UpdateProfileEmail } from './update-profile-email/update-profile-email.usecase'; export const USE_CASES = [ CreateUser, GetMyProfileUsecase, UpdateOnBoardingUsecase, UpdateProfileEmail, UpdateOnBoardingTourUsecase, UpdateNameAndProfilePicture, ]; ================================================ FILE: apps/api/src/app/user/usecases/update-name-and-profile-picture/update-name-and-profile-picture.command.ts ================================================ import { IsDefined, IsOptional, IsString, IsUrl } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; export class UpdateNameAndProfilePictureCommand extends EnvironmentWithUserCommand { @IsUrl({ require_tld: false }) @IsOptional() profilePicture?: string; @IsDefined() @IsString() firstName: string; @IsString() @IsDefined() lastName: string; } ================================================ FILE: apps/api/src/app/user/usecases/update-name-and-profile-picture/update-name-and-profile-picture.usecase.ts ================================================ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { buildUserKey, InvalidateCacheService } from '@novu/application-generic'; import { UserEntity, UserRepository } from '@novu/dal'; import { BaseUserProfileUsecase } from '../base-user-profile.usecase'; import { UpdateNameAndProfilePictureCommand } from './update-name-and-profile-picture.command'; @Injectable() export class UpdateNameAndProfilePicture extends BaseUserProfileUsecase { constructor( private invalidateCache: InvalidateCacheService, private readonly userRepository: UserRepository ) { super(); } async execute(command: UpdateNameAndProfilePictureCommand) { if (!command.firstName || !command.lastName) throw new BadRequestException('First name and last name are required'); let user = await this.userRepository.findById(command.userId); if (!user) throw new NotFoundException('User not found'); const updatePayload: Partial = { firstName: command.firstName, lastName: command.lastName, }; const unsetPayload: Partial> = {}; if (command.profilePicture) { updatePayload.profilePicture = command.profilePicture; } await this.userRepository.update( { _id: command.userId, }, { $set: updatePayload, $unset: unsetPayload, } ); await this.invalidateCache.invalidateByKey({ key: buildUserKey({ _id: command.userId, }), }); user = await this.userRepository.findById(command.userId); if (!user) throw new NotFoundException('User not found'); return this.mapToDto(user); } } ================================================ FILE: apps/api/src/app/user/usecases/update-on-boarding/update-on-boarding.command.ts ================================================ import { IsBoolean, IsOptional } from 'class-validator'; import { AuthenticatedCommand } from '../../../shared/commands/authenticated.command'; export class UpdateOnBoardingCommand extends AuthenticatedCommand { @IsBoolean() @IsOptional() showOnBoarding?: boolean; } ================================================ FILE: apps/api/src/app/user/usecases/update-on-boarding/update-on-boarding.usecase.ts ================================================ import { Injectable, NotFoundException } from '@nestjs/common'; import { buildUserKey, InvalidateCacheService } from '@novu/application-generic'; import { UserRepository } from '@novu/dal'; import type { UserResponseDto } from '../../dtos/user-response.dto'; import { BaseUserProfileUsecase } from '../base-user-profile.usecase'; import { UpdateOnBoardingCommand } from './update-on-boarding.command'; @Injectable() export class UpdateOnBoardingUsecase extends BaseUserProfileUsecase { constructor( private invalidateCache: InvalidateCacheService, private readonly userRepository: UserRepository ) { super(); } async execute(command: UpdateOnBoardingCommand): Promise { await this.invalidateCache.invalidateByKey({ key: buildUserKey({ _id: command.userId, }), }); await this.userRepository.update( { _id: command.userId, }, { $set: { showOnBoarding: command.showOnBoarding, }, } ); const user = await this.userRepository.findById(command.userId); if (!user) throw new NotFoundException('User not found'); return this.mapToDto(user); } } ================================================ FILE: apps/api/src/app/user/usecases/update-on-boarding-tour/update-on-boarding-tour.command.ts ================================================ import { IsNumber, IsOptional } from 'class-validator'; import { AuthenticatedCommand } from '../../../shared/commands/authenticated.command'; export class UpdateOnBoardingTourCommand extends AuthenticatedCommand { @IsNumber() @IsOptional() showOnBoardingTour: number; } ================================================ FILE: apps/api/src/app/user/usecases/update-on-boarding-tour/update-on-boarding-tour.usecase.ts ================================================ import { Injectable, NotFoundException } from '@nestjs/common'; import { buildUserKey, InvalidateCacheService } from '@novu/application-generic'; import { UserRepository } from '@novu/dal'; import type { UserResponseDto } from '../../dtos/user-response.dto'; import { BaseUserProfileUsecase } from '../base-user-profile.usecase'; import { UpdateOnBoardingTourCommand } from './update-on-boarding-tour.command'; @Injectable() export class UpdateOnBoardingTourUsecase extends BaseUserProfileUsecase { constructor( private invalidateCache: InvalidateCacheService, private readonly userRepository: UserRepository ) { super(); } async execute(command: UpdateOnBoardingTourCommand): Promise { const user = await this.userRepository.findById(command.userId); if (!user) throw new NotFoundException('User not found'); await this.userRepository.update( { _id: command.userId, }, { $set: { showOnBoardingTour: command.showOnBoardingTour, }, } ); await this.invalidateCache.invalidateByKey({ key: buildUserKey({ _id: command.userId, }), }); const updatedUser = await this.userRepository.findById(command.userId); if (!updatedUser) throw new NotFoundException('User not found'); return this.mapToDto(updatedUser); } } ================================================ FILE: apps/api/src/app/user/usecases/update-profile-email/update-profile-email.command.ts ================================================ import { EnvironmentId } from '@novu/shared'; import { IsDefined, IsEmail, IsMongoId, IsNotEmpty } from 'class-validator'; import { AuthenticatedCommand } from '../../../shared/commands/authenticated.command'; export class UpdateProfileEmailCommand extends AuthenticatedCommand { @IsEmail() @IsDefined() email: string; @IsMongoId() @IsNotEmpty() environmentId: EnvironmentId; } ================================================ FILE: apps/api/src/app/user/usecases/update-profile-email/update-profile-email.usecase.ts ================================================ import { BadRequestException, forwardRef, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { AnalyticsService, buildAuthServiceKey, buildUserKey, decryptApiKey, InvalidateCacheService, } from '@novu/application-generic'; import { EnvironmentRepository, UserRepository } from '@novu/dal'; import { normalizeEmail } from '@novu/shared'; import type { UserResponseDto } from '../../dtos/user-response.dto'; import { BaseUserProfileUsecase } from '../base-user-profile.usecase'; import { UpdateProfileEmailCommand } from './update-profile-email.command'; @Injectable() export class UpdateProfileEmail extends BaseUserProfileUsecase { constructor( private invalidateCache: InvalidateCacheService, private readonly userRepository: UserRepository, private readonly environmentRepository: EnvironmentRepository, @Inject(forwardRef(() => AnalyticsService)) private analyticsService: AnalyticsService ) { super(); } async execute(command: UpdateProfileEmailCommand): Promise { const email = normalizeEmail(command.email); const user = await this.userRepository.findByEmail(email); if (user) throw new BadRequestException('E-mail is invalid or taken'); await this.userRepository.update( { _id: command.userId, }, { $set: { email, }, } ); await this.invalidateCache.invalidateByKey({ key: buildUserKey({ _id: command.userId, }), }); const apiKeys = await this.environmentRepository.getApiKeys(command.environmentId); const decryptedApiKey = decryptApiKey(apiKeys[0].key); await this.invalidateCache.invalidateByKey({ key: buildAuthServiceKey({ apiKey: decryptedApiKey, }), }); const updatedUser = await this.userRepository.findById(command.userId); if (!updatedUser) throw new NotFoundException('User not found'); this.analyticsService.setValue(updatedUser._id, 'email', email); return this.mapToDto(updatedUser); } } ================================================ FILE: apps/api/src/app/user/user.controller.ts ================================================ import { Body, ClassSerializerInterceptor, Controller, Get, Put, UseInterceptors } from '@nestjs/common'; import { ApiExcludeController, ApiOperation, ApiTags } from '@nestjs/swagger'; import { PinoLogger } from '@novu/application-generic'; import { UserSessionData } from '@novu/shared'; import { RequireAuthentication } from '../auth/framework/auth.decorator'; import { ExternalApiAccessible } from '../auth/framework/external-api.decorator'; import { ApiCommonResponses, ApiResponse } from '../shared/framework/response.decorator'; import { UserSession } from '../shared/framework/user.decorator'; import { ChangeProfileEmailDto } from './dtos/change-profile-email.dto'; import { UpdateProfileRequestDto } from './dtos/update-profile-request.dto'; import { UserOnboardingRequestDto } from './dtos/user-onboarding-request.dto'; import { UserOnboardingTourRequestDto } from './dtos/user-onboarding-tour-request.dto'; import { UserResponseDto } from './dtos/user-response.dto'; import { GetMyProfileCommand } from './usecases/get-my-profile/get-my-profile.dto'; import { GetMyProfileUsecase } from './usecases/get-my-profile/get-my-profile.usecase'; import { UpdateNameAndProfilePictureCommand } from './usecases/update-name-and-profile-picture/update-name-and-profile-picture.command'; import { UpdateNameAndProfilePicture } from './usecases/update-name-and-profile-picture/update-name-and-profile-picture.usecase'; import { UpdateOnBoardingCommand } from './usecases/update-on-boarding/update-on-boarding.command'; import { UpdateOnBoardingUsecase } from './usecases/update-on-boarding/update-on-boarding.usecase'; import { UpdateOnBoardingTourCommand } from './usecases/update-on-boarding-tour/update-on-boarding-tour.command'; import { UpdateOnBoardingTourUsecase } from './usecases/update-on-boarding-tour/update-on-boarding-tour.usecase'; import { UpdateProfileEmailCommand } from './usecases/update-profile-email/update-profile-email.command'; import { UpdateProfileEmail } from './usecases/update-profile-email/update-profile-email.usecase'; @ApiCommonResponses() @Controller('/users') @ApiTags('Users') @UseInterceptors(ClassSerializerInterceptor) @RequireAuthentication() @ApiExcludeController() export class UsersController { constructor( private getMyProfileUsecase: GetMyProfileUsecase, private updateOnBoardingUsecase: UpdateOnBoardingUsecase, private updateOnBoardingTourUsecase: UpdateOnBoardingTourUsecase, private updateProfileEmailUsecase: UpdateProfileEmail, private updateNameAndProfilePictureUsecase: UpdateNameAndProfilePicture, private logger: PinoLogger ) { this.logger.setContext(this.constructor.name); } @Get('/me') @ApiResponse(UserResponseDto) @ApiOperation({ summary: 'Get User', }) @ExternalApiAccessible() async getMyProfile(@UserSession() user: UserSessionData): Promise { this.logger.trace('Getting User'); this.logger.debug(`User id: ${user._id}`); this.logger.trace('Creating GetMyProfileCommand'); const command = GetMyProfileCommand.create({ userId: user._id, }); return await this.getMyProfileUsecase.execute(command); } @Put('/profile/email') async updateProfileEmail( @UserSession() user: UserSessionData, @Body() body: ChangeProfileEmailDto ): Promise { return await this.updateProfileEmailUsecase.execute( UpdateProfileEmailCommand.create({ userId: user._id, email: body.email, environmentId: user.environmentId, }) ); } @Put('/onboarding') @ApiResponse(UserResponseDto) @ApiOperation({ summary: 'Update onboarding', }) @ExternalApiAccessible() async updateOnBoarding( @UserSession() user: UserSessionData, @Body() body: UserOnboardingRequestDto ): Promise { return await this.updateOnBoardingUsecase.execute( UpdateOnBoardingCommand.create({ userId: user._id, showOnBoarding: body.showOnBoarding, }) ); } @Put('/onboarding-tour') async updateOnBoardingTour( @UserSession() user: UserSessionData, @Body() body: UserOnboardingTourRequestDto ): Promise { return await this.updateOnBoardingTourUsecase.execute( UpdateOnBoardingTourCommand.create({ userId: user._id, showOnBoardingTour: body.showOnBoardingTour, }) ); } @Put('/profile') @ApiOperation({ summary: 'Update user name and profile picture', }) @ExternalApiAccessible() async updateProfile( @UserSession() user: UserSessionData, @Body() body: UpdateProfileRequestDto ): Promise { return await this.updateNameAndProfilePictureUsecase.execute( UpdateNameAndProfilePictureCommand.create({ userId: user._id, environmentId: user.environmentId, firstName: body.firstName, lastName: body.lastName, profilePicture: body.profilePicture, organizationId: user.organizationId, }) ); } } ================================================ FILE: apps/api/src/app/user/user.module.ts ================================================ import { Module } from '@nestjs/common'; import { SharedModule } from '../shared/shared.module'; import { USE_CASES } from './usecases'; import { UsersController } from './user.controller'; @Module({ imports: [SharedModule], controllers: [UsersController], providers: [...USE_CASES], exports: [...USE_CASES], }) export class UserModule {} ================================================ FILE: apps/api/src/app/widgets/dtos/feeds-response.dto.ts ================================================ import { ApiExtraModels, ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ActorTypeEnum, ChannelTypeEnum, IActor, INotificationDto } from '@novu/shared'; import { SubscriberFeedResponseDto } from '../../subscribers/dtos'; import { EmailBlock, MessageCTA } from './message-response.dto'; class ActorFeedItemDto implements IActor { @ApiProperty({ description: 'The data associated with the actor, can be null if not applicable.', nullable: true, example: null, type: String, }) data: string | null; @ApiProperty({ description: 'The type of the actor, indicating the role in the notification process.', enum: [...Object.values(ActorTypeEnum)], enumName: 'ActorTypeEnum', type: ActorTypeEnum, }) type: ActorTypeEnum; } @ApiExtraModels(EmailBlock, MessageCTA) export class NotificationFeedItemDto implements INotificationDto { @ApiProperty({ description: 'Unique identifier for the notification.', example: '615c1f2f9b0c5b001f8e4e3b', type: String, }) _id: string; @ApiProperty({ description: 'Identifier for the template used to generate the notification.', example: 'template_12345', type: String, }) _templateId: string; @ApiProperty({ description: 'Identifier for the environment where the notification is sent.', example: 'env_67890', type: String, }) _environmentId: string; @ApiPropertyOptional({ description: 'Identifier for the message template used.', example: 'message_template_54321', type: String, }) _messageTemplateId: string; @ApiProperty({ description: 'Identifier for the organization sending the notification.', example: 'org_98765', type: String, }) _organizationId: string; @ApiProperty({ description: 'Unique identifier for the notification instance.', example: 'notification_123456', type: String, }) _notificationId: string; @ApiProperty({ description: 'Unique identifier for the subscriber receiving the notification.', example: 'subscriber_112233', type: String, }) _subscriberId: string; @ApiPropertyOptional({ description: 'Identifier for the feed associated with the notification.', example: 'feed_445566', type: String, nullable: true, }) _feedId?: string | null; @ApiProperty({ description: 'Identifier for the job that triggered the notification.', example: 'job_778899', type: String, }) _jobId: string; @ApiPropertyOptional({ description: 'Timestamp indicating when the notification was created.', type: String, format: 'date-time', nullable: true, example: '2024-12-10T10:10:59.639Z', }) createdAt: string; @ApiPropertyOptional({ description: 'Timestamp indicating when the notification was last updated.', type: String, format: 'date-time', nullable: true, example: '2024-12-10T10:10:59.639Z', }) updatedAt?: string | null; @ApiPropertyOptional({ description: 'Actor details related to the notification, if applicable.', type: ActorFeedItemDto, }) actor?: ActorFeedItemDto; @ApiPropertyOptional({ description: 'Subscriber details associated with this notification.', type: SubscriberFeedResponseDto, }) subscriber?: SubscriberFeedResponseDto; @ApiProperty({ description: 'Unique identifier for the transaction associated with the notification.', example: 'transaction_123456', type: String, }) transactionId: string; @ApiPropertyOptional({ description: 'Identifier for the template used, if applicable.', nullable: true, example: 'template_abcdef', type: String, }) templateIdentifier?: string | null; @ApiPropertyOptional({ description: 'Identifier for the provider that sends the notification.', nullable: true, example: 'provider_xyz', type: String, }) providerId?: string | null; @ApiProperty({ description: 'The main content of the notification.', example: 'This is a test notification content.', type: String, }) content: string; @ApiPropertyOptional({ description: 'The subject line for email notifications, if applicable.', nullable: true, example: 'Test Notification Subject', type: String, }) subject?: string | null; @ApiProperty({ description: 'The channel through which the notification is sent.', enum: [...Object.values(ChannelTypeEnum)], enumName: 'ChannelTypeEnum', type: ChannelTypeEnum, }) channel: ChannelTypeEnum; @ApiProperty({ description: 'Indicates whether the notification has been read by the subscriber.', example: false, type: Boolean, }) read: boolean; @ApiProperty({ description: 'Indicates whether the notification has been seen by the subscriber.', example: true, type: Boolean, }) seen: boolean; @ApiProperty({ description: 'Indicates whether the notification has been archived by the subscriber.', example: false, type: Boolean, }) archived: boolean; @ApiPropertyOptional({ description: 'Device tokens for push notifications, if applicable.', type: [String], nullable: true, example: ['token1', 'token2'], }) deviceTokens?: string[] | null; @ApiProperty({ description: 'Call-to-action information associated with the notification.', type: MessageCTA, }) cta: MessageCTA; @ApiProperty({ description: 'Current status of the notification.', enum: ['sent', 'error', 'warning'], example: 'sent', type: String, }) status: 'sent' | 'error' | 'warning'; @ApiProperty({ description: 'The payload that was used to send the notification trigger.', type: 'object', additionalProperties: true, required: false, example: { key: 'value' }, }) payload?: Record; @ApiPropertyOptional({ description: 'The data sent with the notification.', type: 'object', nullable: true, example: { key: 'value' }, additionalProperties: true, }) data?: Record | null; @ApiProperty({ description: 'Provider-specific overrides used when triggering the notification.', type: 'object', additionalProperties: true, required: false, example: { overrideKey: 'overrideValue' }, }) overrides?: Record; @ApiPropertyOptional({ description: 'Tags associated with the workflow that triggered the notification.', type: [String], nullable: true, example: ['tag1', 'tag2'], }) tags?: string[] | null; } export class FeedResponseDto { @ApiPropertyOptional({ description: 'Total number of notifications available.', example: 5, type: Number, }) totalCount?: number; @ApiProperty({ description: 'Indicates if there are more notifications to load.', example: true, type: Boolean, }) hasMore: boolean; @ApiProperty({ description: 'Array of notifications returned in the response.', type: [NotificationFeedItemDto], }) data: NotificationFeedItemDto[]; @ApiProperty({ description: 'The number of notifications returned in this response.', example: 2, type: Number, }) pageSize: number; @ApiProperty({ description: 'The current page number of the notifications.', example: 1, type: Number, }) page: number; } ================================================ FILE: apps/api/src/app/widgets/dtos/get-notifications-feed-request.dto.ts ================================================ import { GetInAppNotificationsFeedForSubscriberDto } from '../../subscribers/dtos/get-in-app-notification-feed-for-subscriber.dto'; export class GetNotificationsFeedDto extends GetInAppNotificationsFeedForSubscriberDto {} ================================================ FILE: apps/api/src/app/widgets/dtos/log-usage-request.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; export class LogUsageRequestDto { @ApiProperty({ example: '[Widget] - Notification Click', }) name: string; @ApiProperty({ example: { notificationId: '507f191e810c19729de860ea', hasCta: true, }, }) payload: any; } ================================================ FILE: apps/api/src/app/widgets/dtos/log-usage-response.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; export class LogUsageResponseDto { @ApiProperty() success: boolean; } ================================================ FILE: apps/api/src/app/widgets/dtos/mark-as-request.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { MessagesStatusEnum } from '@novu/shared'; import { IsDefined, IsEnum } from 'class-validator'; import { IsMongoIdOrArrayOfMongoIds } from '../../shared/validators/is-mongo-id-or-array-of-ids.validator'; export class MessageMarkAsRequestDto { @ApiProperty({ oneOf: [ { type: 'string' }, { type: 'array', items: { type: 'string', }, }, ], }) @IsDefined() @IsMongoIdOrArrayOfMongoIds({ fieldName: 'messageId' }) messageId: string | string[]; @ApiProperty({ enum: MessagesStatusEnum, }) @IsDefined() @IsEnum(MessagesStatusEnum) markAs: MessagesStatusEnum; } ================================================ FILE: apps/api/src/app/widgets/dtos/mark-message-action-as-seen.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { MessageActionStatusEnum } from '@novu/shared'; import { IsDefined, IsOptional, IsString } from 'class-validator'; export class MarkMessageActionAsSeenDto { @ApiProperty({ enum: MessageActionStatusEnum, description: 'Message action status', }) @IsString() @IsDefined() status: MessageActionStatusEnum; @ApiPropertyOptional({ description: 'Message action payload', }) @IsOptional() payload: Record; } ================================================ FILE: apps/api/src/app/widgets/dtos/mark-message-as-request.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsBoolean, IsDefined, IsObject, IsOptional, ValidateNested } from 'class-validator'; import { IsMongoIdOrArrayOfMongoIds } from '../../shared/validators/is-mongo-id-or-array-of-ids.validator'; class MarkMessageFields { @ApiPropertyOptional({ type: Boolean, }) @IsOptional() @IsBoolean() seen?: boolean; @ApiPropertyOptional({ type: Boolean, }) @IsOptional() @IsBoolean() read?: boolean; } export class MarkMessageAsRequestDto { @ApiProperty({ oneOf: [ { type: 'string' }, { type: 'array', items: { type: 'string', }, }, ], }) @IsDefined() @IsMongoIdOrArrayOfMongoIds({ fieldName: 'messageId' }) messageId: string | string[]; @ApiProperty({ type: MarkMessageFields, }) @IsDefined() @IsObject() @ValidateNested() @Type(() => MarkMessageFields) mark: MarkMessageFields; } ================================================ FILE: apps/api/src/app/widgets/dtos/message-response.dto.ts ================================================ import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger'; import { SubscriberResponseDto } from '@novu/application-generic'; import { ButtonTypeEnum, ChannelCTATypeEnum, ChannelTypeEnum, EmailBlockTypeEnum, IMessage, IMessageAction, IMessageCTA, MessageActionStatusEnum, TextAlignEnum, } from '@novu/shared'; import { WorkflowResponse } from '../../workflows-v1/dtos/workflow-response.dto'; class EmailBlockStyles { @ApiProperty({ enum: [...Object.values(TextAlignEnum)], enumName: 'TextAlignEnum', description: 'Text alignment for the email block', }) textAlign?: TextAlignEnum; } export class EmailBlock { @ApiProperty({ enum: [...Object.values(EmailBlockTypeEnum)], enumName: 'EmailBlockTypeEnum', description: 'Type of the email block', }) type: EmailBlockTypeEnum; @ApiProperty({ type: String, description: 'Content of the email block', }) content: string; @ApiPropertyOptional({ type: String, description: 'URL associated with the email block, if any', }) url?: string; @ApiPropertyOptional({ type: EmailBlockStyles, description: 'Styles applied to the email block', }) styles?: EmailBlockStyles; } class MessageActionResult { @ApiPropertyOptional({ description: 'Payload of the action result', type: 'object', additionalProperties: true, }) payload?: Record; @ApiPropertyOptional({ enum: [...Object.values(ButtonTypeEnum)], enumName: 'ButtonTypeEnum', description: 'Type of button for the action result', }) type?: ButtonTypeEnum; } class MessageButton { @ApiProperty({ enum: [...Object.values(ButtonTypeEnum)], enumName: 'ButtonTypeEnum', description: 'Type of the button', }) type: ButtonTypeEnum; @ApiProperty({ type: String, description: 'Content of the button', }) content: string; @ApiPropertyOptional({ type: String, description: 'Content of the result when the button is clicked', }) resultContent?: string; } class MessageAction implements IMessageAction { @ApiPropertyOptional({ enum: [...Object.values(MessageActionStatusEnum)], enumName: 'MessageActionStatusEnum', description: 'Status of the message action', }) status?: MessageActionStatusEnum; @ApiPropertyOptional({ type: MessageButton, isArray: true, description: 'List of buttons associated with the message action', }) buttons?: MessageButton[]; @ApiPropertyOptional({ type: MessageActionResult, description: 'Result of the message action', }) result: MessageActionResult; } class MessageCTAData { @ApiPropertyOptional({ type: String, description: 'URL for the call to action', }) url?: string; } export class MessageCTA implements IMessageCTA { @ApiPropertyOptional({ enum: [...Object.values(ChannelCTATypeEnum)], enumName: 'ChannelCTATypeEnum', description: 'Type of call to action', }) type: ChannelCTATypeEnum; @ApiPropertyOptional({ description: 'Data associated with the call to action', type: MessageCTAData, }) data: MessageCTAData; @ApiPropertyOptional({ description: 'Action associated with the call to action', type: MessageAction, }) action?: MessageAction; } @ApiExtraModels(EmailBlock, MessageCTA) export class MessageResponseDto implements IMessage { @ApiPropertyOptional({ type: String, description: 'Unique identifier for the message', }) _id: string; @ApiPropertyOptional({ nullable: true, type: String, description: 'Template ID associated with the message', }) _templateId: string; @ApiProperty({ type: String, description: 'Environment ID where the message is sent', }) _environmentId: string; @ApiPropertyOptional({ nullable: true, type: String, description: 'Message template ID', }) _messageTemplateId: string; @ApiProperty({ type: String, description: 'Organization ID associated with the message', }) _organizationId: string; @ApiProperty({ type: String, description: 'Notification ID associated with the message', }) _notificationId: string; @ApiProperty({ type: String, description: 'Subscriber ID associated with the message', }) _subscriberId: string; @ApiPropertyOptional({ type: SubscriberResponseDto, description: 'Subscriber details, if available', }) subscriber?: SubscriberResponseDto; @ApiPropertyOptional({ type: WorkflowResponse, description: 'Workflow template associated with the message', }) template?: WorkflowResponse; @ApiPropertyOptional({ type: String, description: 'Identifier for the message template', }) templateIdentifier?: string; @ApiProperty({ type: String, description: 'Creation date of the message', }) createdAt: string; @ApiPropertyOptional({ type: [String], description: 'Array of delivery dates for the message, if the message has multiple delivery dates, for example after being snoozed', }) deliveredAt?: string[]; @ApiPropertyOptional({ type: String, description: 'Last seen date of the message, if available', }) lastSeenDate?: string; @ApiPropertyOptional({ type: String, description: 'Last read date of the message, if available', }) lastReadDate?: string; @ApiPropertyOptional({ nullable: true, oneOf: [ { type: 'array', items: { $ref: getSchemaPath(EmailBlock), }, }, { type: 'string', description: 'String representation of the content', }, ], description: 'Content of the message, can be an email block or a string', }) content: string | EmailBlock[]; @ApiProperty({ type: String, description: 'Transaction ID associated with the message', }) transactionId: string; @ApiPropertyOptional({ type: String, description: 'Subject of the message, if applicable', }) subject?: string; @ApiProperty({ enum: [...Object.values(ChannelTypeEnum)], enumName: 'ChannelTypeEnum', description: 'Channel type through which the message is sent', }) channel: ChannelTypeEnum; @ApiProperty({ type: Boolean, description: 'Indicates if the message has been read', }) read: boolean; @ApiProperty({ type: Boolean, description: 'Indicates if the message has been seen', }) seen: boolean; @ApiPropertyOptional({ type: String, description: 'Date when the message will be unsnoozed', }) snoozedUntil?: string; @ApiPropertyOptional({ type: String, description: 'Email address associated with the message, if applicable', }) email?: string; @ApiPropertyOptional({ type: String, description: 'Phone number associated with the message, if applicable', }) phone?: string; @ApiPropertyOptional({ type: String, description: 'Direct webhook URL for the message, if applicable', }) directWebhookUrl?: string; @ApiPropertyOptional({ type: String, description: 'Provider ID associated with the message, if applicable', }) providerId?: string; @ApiPropertyOptional({ type: [String], description: 'Device tokens associated with the message, if applicable', }) deviceTokens?: string[]; @ApiPropertyOptional({ type: String, description: 'Title of the message, if applicable', }) title?: string; @ApiProperty({ type: MessageCTA, description: 'Call to action associated with the message', }) cta: MessageCTA; @ApiPropertyOptional({ type: String, description: 'Feed ID associated with the message, if applicable', }) _feedId?: string | null; @ApiProperty({ enum: ['sent', 'error', 'warning'], enumName: 'MessageStatusEnum', description: 'Status of the message', }) status: 'sent' | 'error' | 'warning'; @ApiPropertyOptional({ type: String, description: 'Error ID if the message has an error', }) errorId?: string; @ApiPropertyOptional({ type: String, description: 'Error text if the message has an error', }) errorText?: string; @ApiPropertyOptional({ description: 'The payload that was used to send the notification trigger', type: 'object', additionalProperties: true, }) payload: Record; @ApiPropertyOptional({ description: 'Provider specific overrides used when triggering the notification', type: 'object', additionalProperties: true, }) overrides?: Record; @ApiPropertyOptional({ type: [String], description: 'Context (single or multi) in which the message was sent', example: ['tenant:org-123', 'region:us-east-1'], }) contextKeys?: string[]; } export class MessagesResponseDto { @ApiPropertyOptional({ type: Number, description: 'Total number of messages available', }) totalCount?: number; @ApiProperty({ type: Boolean, description: 'Indicates if there are more messages available', }) hasMore: boolean; @ApiProperty({ type: [MessageResponseDto], description: 'List of messages', }) data: MessageResponseDto[]; @ApiProperty({ type: Number, description: 'Number of messages per page', }) pageSize: number; @ApiProperty({ type: Number, description: 'Current page number', }) page: number; } ================================================ FILE: apps/api/src/app/widgets/dtos/organization-response.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; class Branding { @ApiPropertyOptional() fontFamily?: string; @ApiPropertyOptional() fontColor?: string; @ApiPropertyOptional() contentBackground?: string; @ApiProperty() logo: string; @ApiProperty() color: string; @ApiPropertyOptional({ enum: ['ltr', 'rtl'], }) direction?: 'ltr' | 'rtl'; } export class OrganizationResponseDto { @ApiProperty() _id: string; @ApiProperty() name: string; @ApiPropertyOptional() branding?: Branding; } ================================================ FILE: apps/api/src/app/widgets/dtos/remove-all-messages.dto.ts ================================================ import { ApiPropertyOptional } from '@nestjs/swagger'; import { IsMongoId, IsOptional } from 'class-validator'; export class RemoveAllMessagesDto { @ApiPropertyOptional({ description: 'FeedId to remove messages from', }) @IsMongoId({ message: 'FeedId must be a valid MongoDB ObjectId' }) @IsOptional() feedId: string; } ================================================ FILE: apps/api/src/app/widgets/dtos/remove-messages-bulk-request.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { ArrayMaxSize, ArrayNotEmpty, IsArray, IsMongoId } from 'class-validator'; export class RemoveMessagesBulkRequestDto { @ApiProperty({ isArray: true, }) @IsArray() @ArrayNotEmpty() @IsMongoId({ each: true }) @ArrayMaxSize(100) messageIds: string[]; } ================================================ FILE: apps/api/src/app/widgets/dtos/session-initialize-request.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsDefined, IsEmail, IsOptional, IsString } from 'class-validator'; export class SessionInitializeRequestDto { @ApiProperty({ description: 'Your internal identifier for subscriber', }) @IsString() @IsDefined() subscriberId: string; @ApiProperty({ description: 'Identifier for your application can be found in settings for Novu', }) @IsString() @IsDefined() applicationIdentifier: string; @ApiPropertyOptional() @IsString() @IsOptional() firstName?: string; @ApiPropertyOptional() @IsString() @IsOptional() lastName?: string; @ApiPropertyOptional() @IsEmail() @IsOptional() email?: string; @ApiPropertyOptional() @IsString() @IsOptional() phone?: string; @ApiPropertyOptional() @IsString() @IsOptional() hmacHash?: string; } ================================================ FILE: apps/api/src/app/widgets/dtos/session-initialize-response.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; class Profile { @ApiProperty() _id: string; @ApiPropertyOptional() firstName?: string; @ApiPropertyOptional() lastName?: string; @ApiPropertyOptional() phone?: string; } export class SessionInitializeResponseDto { @ApiProperty() token: string; @ApiProperty() profile: Profile; } ================================================ FILE: apps/api/src/app/widgets/dtos/unseen-count-response.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; export class UnseenCountResponse { @ApiProperty() count: number; } ================================================ FILE: apps/api/src/app/widgets/dtos/update-subscriber-preference-request.dto.ts ================================================ import { ApiExtraModels, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsBoolean, IsOptional, ValidateNested } from 'class-validator'; import { ChannelPreference } from '../../shared/dtos/channel-preference'; @ApiExtraModels(ChannelPreference) export class UpdateSubscriberPreferenceRequestDto { @ApiPropertyOptional({ type: ChannelPreference, description: 'Optional preferences for each channel type in the assigned workflow.', }) @ValidateNested() @Type(() => ChannelPreference) @IsOptional() channel?: ChannelPreference; @ApiPropertyOptional({ description: 'Indicates whether the workflow is fully enabled for all channels for the subscriber.', type: Boolean, }) @IsBoolean() @IsOptional() enabled?: boolean; } ================================================ FILE: apps/api/src/app/widgets/dtos/update-subscriber-preference-response.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { CustomDataType, INotificationTrigger, INotificationTriggerVariable, ITemplateConfiguration, ITriggerReservedVariable, TemplateVariableTypeEnum, TriggerContextTypeEnum, TriggerTypeEnum, } from '@novu/shared'; import { SubscriberPreferenceChannels } from '../../shared/dtos/preference-channels'; class Preference { @ApiProperty({ description: 'Sets if the workflow is fully enabled for all channels or not for the subscriber.', type: Boolean, }) enabled: boolean; @ApiProperty({ type: SubscriberPreferenceChannels, description: 'Subscriber preferences for the different channels regarding this workflow', }) channels: SubscriberPreferenceChannels; } export class NotificationTriggerVariableResponse implements INotificationTriggerVariable { @ApiProperty({ type: String, description: 'The name of the variable', }) name: string; @ApiPropertyOptional() @ApiProperty({ description: 'The value of the variable', }) value?: any; @ApiPropertyOptional() @ApiProperty({ enum: TemplateVariableTypeEnum, description: 'The type of the variable', }) type?: TemplateVariableTypeEnum; } export class TriggerReservedVariableResponse implements ITriggerReservedVariable { @ApiProperty({ enum: TriggerContextTypeEnum, description: 'The type of the reserved variable', }) type: TriggerContextTypeEnum; @ApiProperty({ type: Array, description: 'The reserved variables of the trigger', }) variables: NotificationTriggerVariableResponse[]; } export class NotificationTriggerResponse implements INotificationTrigger { @ApiProperty({ enum: [...Object.values(TriggerTypeEnum)], enumName: 'TriggerTypeEnum', description: 'The type of the trigger', }) type: TriggerTypeEnum; @ApiProperty({ type: String, description: 'The identifier of the trigger', }) identifier: string; @ApiProperty({ type: [NotificationTriggerVariableResponse], description: 'The variables of the trigger', }) variables: NotificationTriggerVariableResponse[]; @ApiPropertyOptional() @ApiProperty({ type: [NotificationTriggerVariableResponse], description: 'The subscriber variables of the trigger', }) subscriberVariables?: NotificationTriggerVariableResponse[]; @ApiPropertyOptional() @ApiProperty({ type: [TriggerReservedVariableResponse], description: 'The reserved variables of the trigger', }) reservedVariables?: TriggerReservedVariableResponse[]; } class TemplateResponse implements ITemplateConfiguration { @ApiProperty({ description: 'Unique identifier of the workflow', type: String, }) _id: string; @ApiProperty({ description: 'Name of the workflow', type: String, }) name: string; @ApiProperty({ description: 'Critical templates will always be delivered to the end user and should be hidden from the subscriber preferences screen', type: Boolean, }) critical: boolean; @ApiProperty({ description: 'Triggers are the events that will trigger the workflow.', type: [NotificationTriggerResponse], // Use an array syntax }) triggers: NotificationTriggerResponse[]; @ApiProperty({ description: 'Tags applied to the workflow.', type: [String], }) tags?: string[]; @ApiProperty({ description: 'The custom data of the workflow.', type: Object, }) data?: CustomDataType; @ApiPropertyOptional({ description: "The date and time the workflow was last updated. It's in ISO 8601 format.", type: String, }) updatedAt?: string; } export class UpdateSubscriberPreferenceResponseDto { @ApiProperty({ type: TemplateResponse, description: 'The workflow information and if it is critical or not', }) template: TemplateResponse; @ApiProperty({ type: Preference, description: 'The preferences of the subscriber regarding the related workflow', }) preference: Preference; } export class UpdateSubscriberPreferenceGlobalResponseDto { @ApiProperty({ type: Preference, description: 'The preferences of the subscriber regarding the related workflow', }) preference: Preference; } ================================================ FILE: apps/api/src/app/widgets/e2e/get-count.e2e.ts ================================================ import { Novu } from '@novu/api'; import { buildFeedKey, buildMessageCountKey, CacheInMemoryProviderService, CacheService, InvalidateCacheService, } from '@novu/application-generic'; import { MessageRepository, NotificationTemplateEntity, SubscriberRepository } from '@novu/dal'; import { ChannelTypeEnum, InAppProviderIdEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import axios from 'axios'; import { expect } from 'chai'; import { initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('Count - GET /widget/notifications/count #novu-v0', () => { const messageRepository = new MessageRepository(); let session: UserSession; let template: NotificationTemplateEntity; let subscriberId: string; let subscriberToken: string; let subscriberProfile: { _id: string; } | null = null; let invalidateCache: InvalidateCacheService; let cacheInMemoryProviderService: CacheInMemoryProviderService; let novuClient: Novu; before(async () => { cacheInMemoryProviderService = new CacheInMemoryProviderService(); const cacheService = new CacheService(cacheInMemoryProviderService); await cacheService.initialize(); invalidateCache = new InvalidateCacheService(cacheService); }); beforeEach(async () => { session = new UserSession(); await session.initialize(); novuClient = initNovuClassSdk(session); subscriberId = SubscriberRepository.createObjectId(); template = await session.createTemplate({ noFeedId: true, }); const { body } = await session.testAgent .post('/v1/widgets/session/initialize') .send({ applicationIdentifier: session.environment.identifier, subscriberId, firstName: 'Test', lastName: 'User', email: 'test@example.com', }) .expect(201); const { token, profile } = body.data; subscriberToken = token; subscriberProfile = profile; }); it('should return unseen count', async () => { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); const messages = await messageRepository.findBySubscriberChannel( session.environment._id, String(subscriberProfile?._id), ChannelTypeEnum.IN_APP ); const seenCount = (await getFeedCount()).data.count; expect(seenCount).to.equal(3); }); it('should return unseen count after on message was seen', async () => { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); const messages = await messageRepository.findBySubscriberChannel( session.environment._id, String(subscriberProfile?._id), ChannelTypeEnum.IN_APP ); const messageId = messages[0]._id; await messageRepository.update( { _environmentId: session.environment._id, _id: messageId }, { $set: { seen: true, }, } ); await invalidateSeenFeed(invalidateCache, subscriberId, session); const seenCount = (await getFeedCount()).data.count; expect(seenCount).to.equal(2); }); it('should return unseen count after on message was read', async () => { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); const messages = await messageRepository.findBySubscriberChannel( session.environment._id, String(subscriberProfile?._id), ChannelTypeEnum.IN_APP ); const messageId = messages[0]._id; await messageRepository.update( { _environmentId: session.environment._id, _id: messageId }, { $set: { read: true, }, } ); await invalidateSeenFeed(invalidateCache, subscriberId, session); const seenCount = (await getFeedCount()).data.count; expect(seenCount).to.equal(3); const unReadCount = (await getFeedCount({ read: false })).data.count; expect(unReadCount).to.equal(2); }); it('should return unseen count by limit', async () => { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); try { await getFeedCount({ seen: false, limit: 0 }); throw new Error('Exception should have been thrown'); } catch (e) { const message = Array.isArray(e.response.data.message) ? e.response.data.message[0] : e.response.data.message; expect(message).to.equal('limit must not be less than 1'); } let unseenCount = (await getFeedCount({ seen: false, limit: 1 })).data.count; expect(unseenCount).to.equal(1); unseenCount = (await getFeedCount({ seen: false, limit: 2 })).data.count; expect(unseenCount).to.equal(2); unseenCount = (await getFeedCount({ seen: false, limit: 4 })).data.count; expect(unseenCount).to.equal(3); unseenCount = (await getFeedCount({ seen: false, limit: 99 })).data.count; expect(unseenCount).to.equal(3); unseenCount = (await getFeedCount({ seen: false, limit: 100 })).data.count; expect(unseenCount).to.equal(3); try { await getFeedCount({ seen: false, limit: 101 }); throw new Error('Exception should have been thrown'); } catch (e) { const message = Array.isArray(e.response.data.message) ? e.response.data.message[0] : e.response.data.message; expect(message).to.equal('limit must not be greater than 100'); } }); it('should return unseen count by default limit 100', async () => { for (let i = 0; i < 102; i += 1) { await messageRepository.create({ _notificationId: MessageRepository.createObjectId(), _environmentId: session.environment._id, _organizationId: session.organization._id, _subscriberId: subscriberProfile?._id, _templateId: template._id, _messageTemplateId: template.steps[0]._templateId, channel: ChannelTypeEnum.IN_APP, cta: {}, transactionId: MessageRepository.createObjectId(), content: template.steps, payload: {}, providerId: InAppProviderIdEnum.Novu, templateIdentifier: template.triggers[0].identifier, seen: false, }); } const unseenCount = (await getFeedCount({ seen: false })).data.count; expect(unseenCount).to.equal(100); }); it('should return default on string non numeric(NaN) value', async () => { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); const unseenCount = (await getFeedCount({ seen: false, limit: 'what what' })).data.count; expect(unseenCount).to.equal(2); }); it('should return parse numeric string to number', async () => { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); try { await getFeedCount({ seen: false, limit: '0' }); throw new Error('Exception should have been thrown'); } catch (e) { const message = Array.isArray(e.response.data.message) ? e.response.data.message[0] : e.response.data.message; expect(message).to.equal('limit must not be less than 1'); } let unseenCount = (await getFeedCount({ seen: false, limit: '1' })).data.count; expect(unseenCount).to.equal(1); unseenCount = (await getFeedCount({ seen: false, limit: '2' })).data.count; expect(unseenCount).to.equal(2); unseenCount = (await getFeedCount({ seen: false, limit: '99' })).data.count; expect(unseenCount).to.equal(2); unseenCount = (await getFeedCount({ seen: false, limit: '100' })).data.count; expect(unseenCount).to.equal(2); try { await getFeedCount({ seen: false, limit: '101' }); throw new Error('Exception should have been thrown'); } catch (e) { const message = Array.isArray(e.response.data.message) ? e.response.data.message[0] : e.response.data.message; expect(message).to.equal('limit must not be greater than 100'); } }); it('should return unseen count with a seen filter', async () => { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); const messages = await messageRepository.findBySubscriberChannel( session.environment._id, String(subscriberProfile?._id), ChannelTypeEnum.IN_APP ); const messageId = messages[0]._id; expect(messages[0].seen).to.equal(false); await axios.post( `http://127.0.0.1:${process.env.PORT}/v1/widgets/messages/markAs`, { messageId, mark: { seen: true } }, { headers: { Authorization: `Bearer ${subscriberToken}`, }, } ); const unseenFeed = await getFeedCount({ seen: false }); expect(unseenFeed.data.count).to.equal(2); }); it('should return unread count with a read filter', async () => { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); if (!subscriberProfile) throw new Error('Subscriber profile is null'); const messages = await messageRepository.findBySubscriberChannel( session.environment._id, subscriberProfile._id, ChannelTypeEnum.IN_APP ); const messageId = messages[0]._id; expect(messages[0].read).to.equal(false); await axios.post( `http://127.0.0.1:${process.env.PORT}/v1/widgets/messages/markAs`, { messageId, mark: { seen: true, read: true } }, { headers: { Authorization: `Bearer ${subscriberToken}`, }, } ); const readFeed = await getFeedCount({ read: true }); expect(readFeed.data.count).to.equal(1); const unreadFeed = await getFeedCount({ read: false }); expect(unreadFeed.data.count).to.equal(2); }); it('should return unseen count after mark as request', async () => { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); const messages = await messageRepository.findBySubscriberChannel( session.environment._id, String(subscriberProfile?._id), ChannelTypeEnum.IN_APP ); const messageId = messages[0]._id; let seenCount = (await getFeedCount({ seen: false })).data.count; expect(seenCount).to.equal(3); await invalidateCache.invalidateQuery({ key: buildMessageCountKey().invalidate({ subscriberId, _environmentId: session.environment._id, }), }); await axios.post( `http://127.0.0.1:${process.env.PORT}/v1/widgets/messages/markAs`, { messageId, mark: { seen: true } }, { headers: { Authorization: `Bearer ${subscriberToken}`, }, } ); seenCount = (await getFeedCount({ seen: false })).data.count; expect(seenCount).to.equal(2); }); async function getFeedCount(query = {}) { const response = await axios.get(`http://127.0.0.1:${process.env.PORT}/v1/widgets/notifications/count`, { params: { ...query, }, headers: { Authorization: `Bearer ${subscriberToken}`, }, }); return response.data; } }); async function invalidateSeenFeed(invalidateCache: InvalidateCacheService, subscriberId: string, session) { await invalidateCache.invalidateQuery({ key: buildMessageCountKey().invalidate({ subscriberId, _environmentId: session.environment._id, }), }); } ================================================ FILE: apps/api/src/app/widgets/e2e/get-notification-feed.e2e.ts ================================================ import { Novu } from '@novu/api'; import { MessageRepository, NotificationTemplateEntity, SubscriberRepository } from '@novu/dal'; import { ChannelTypeEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import axios from 'axios'; import { expect } from 'chai'; import { initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('GET /widget/notifications/feed #novu-v0', () => { const messageRepository = new MessageRepository(); let session: UserSession; let template: NotificationTemplateEntity; let subscriberId: string; let subscriberToken: string; let subscriberProfile: { _id: string; } | null = null; let novuClient: Novu; beforeEach(async () => { session = new UserSession(); await session.initialize(); subscriberId = SubscriberRepository.createObjectId(); template = await session.createTemplate({ noFeedId: true, }); const { body } = await session.testAgent.post('/v1/widgets/session/initialize').send({ applicationIdentifier: session.environment.identifier, subscriberId, firstName: 'Test', lastName: 'User', email: 'test@example.com', }); expect(body).to.be.ok; expect(body.data).to.be.ok; const { token, profile } = body.data; subscriberToken = token; subscriberProfile = profile; novuClient = initNovuClassSdk(session); }); it('should fetch a feed without filters and with feed id', async () => { /** * This test help preventing accidental passing `null` as a feed id which causes * the feed to be fetched with explicit null as a property of feedId. * * This test will fail if the feedId is not passed as a query parameter, * but the null query still was applied mistakenly */ template = await session.createTemplate(); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); const response = await getSubscriberFeed(); expect(response.data.length).to.equal(2); }); it('should fetch a feed without filters', async () => { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); const response = await getSubscriberFeed(); expect(response.data.length).to.equal(2); }); it('should filter only unseen messages', async () => { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); const messages = await messageRepository.findBySubscriberChannel( session.environment._id, subscriberProfile?._id as string, ChannelTypeEnum.IN_APP ); const messageId = messages[0]._id; expect(messages[0].seen).to.equal(false); await markMessageAsSeen(messageId); const seenFeed = await getSubscriberFeed({ seen: true }); expect(seenFeed.data.length).to.equal(1); expect(seenFeed.data[0]._id).to.equal(messageId); const unseenFeed = await getSubscriberFeed({ seen: false }); expect(unseenFeed.data.length).to.equal(1); expect(unseenFeed.data[0]._id).to.not.equal(messageId); }); it('should return seen and unseen', async () => { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); const messages = await messageRepository.findBySubscriberChannel( session.environment._id, subscriberProfile?._id as string, ChannelTypeEnum.IN_APP ); const messageId = messages[0]._id; expect(messages[0].seen).to.equal(false); await markMessageAsSeen(messageId); const seenFeed = await getSubscriberFeed({ seen: true }); expect(seenFeed.data.length).to.equal(1); expect(seenFeed.data[0]._id).to.equal(messageId); const unseenFeed = await getSubscriberFeed({ seen: false }); expect(unseenFeed.data.length).to.equal(1); expect(unseenFeed.data[0]._id).to.not.equal(messageId); const seenUnseenFeed = await getSubscriberFeed(); expect(seenUnseenFeed.data.length).to.equal(2); }); it('should include subscriber object', async () => { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); const feed = await getSubscriberFeed(); expect(feed.data[0]).to.be.an('object').that.has.any.keys('subscriber'); }); it('should include hasMore when there is more notification', async () => { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); let feed = await getSubscriberFeed(); expect(feed.data.length).to.be.equal(1); expect(feed.totalCount).to.be.equal(1); expect(feed.hasMore).to.be.equal(false); for (let i = 0; i < 10; i += 1) { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); } await session.waitForJobCompletion(template._id); feed = await getSubscriberFeed(); expect(feed.data.length).to.be.equal(10); expect(feed.totalCount).to.be.equal(10); expect(feed.hasMore).to.be.equal(true); }); it('should throw exception when invalid payload query param is passed', async () => { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); try { await getSubscriberFeed({ payload: 'invalid' }); } catch (err) { expect(err.response.status).to.equals(400); expect(err.response.data.message).to.eq(`Invalid payload, the JSON object should be encoded to base64 string.`); return; } expect.fail('Should have thrown an bad request exception'); }); it('should allow filtering by custom data from the payload', async () => { const partialPayload = { foo: 123 }; const payload = { ...partialPayload, bar: 'bar' }; await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId, payload }); await session.waitForJobCompletion(template._id); const payloadQueryValue = Buffer.from(JSON.stringify(partialPayload)).toString('base64'); const { data } = await getSubscriberFeed({ payload: payloadQueryValue }); expect(data.length).to.equal(1); expect(data[0].payload).to.deep.equal(payload); }); it('should allow filtering by custom nested data from the payload', async () => { const partialPayload = { foo: { bar: 123 } }; const payload = { ...partialPayload, baz: 'baz' }; await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId, payload }); await session.waitForJobCompletion(template._id); const payloadQueryValue = Buffer.from(JSON.stringify(partialPayload)).toString('base64'); const { data } = await getSubscriberFeed({ payload: payloadQueryValue }); expect(data.length).to.equal(1); expect(data[0].payload).to.deep.equal(payload); }); async function getSubscriberFeed(query = {}) { const response = await axios.get(`http://127.0.0.1:${process.env.PORT}/v1/widgets/notifications/feed`, { params: { page: 0, ...query, }, headers: { Authorization: `Bearer ${subscriberToken}`, }, }); return response.data; } async function markMessageAsSeen(messageId: string) { return await axios.post( `http://127.0.0.1:${process.env.PORT}/v1/widgets/messages/markAs`, { messageId, mark: { seen: true } }, { headers: { Authorization: `Bearer ${subscriberToken}`, }, } ); } }); ================================================ FILE: apps/api/src/app/widgets/e2e/get-subscriber-preference.e2e.ts ================================================ import { NotificationTemplateEntity } from '@novu/dal'; import { ChannelTypeEnum } from '@novu/stateless'; import { UserSession } from '@novu/testing'; import axios from 'axios'; import { expect } from 'chai'; import { updateSubscriberPreference } from './update-subscriber-preference.e2e'; describe('GET /widget/preferences #novu-v0', () => { let template: NotificationTemplateEntity; let session: UserSession; beforeEach(async () => { session = new UserSession(); await session.initialize(); template = await session.createTemplate({ noFeedId: true, }); }); it('should fetch a default user preference', async () => { const response = await getSubscriberPreference(session.subscriberToken); const data = response.data.data[0]; expect(data.template.name).to.exist; expect(data.template.tags[0]).to.equal('test-tag'); expect(data.template.critical).to.equal(false); expect(data.template.triggers[0].identifier).to.contains('test-event-'); expect(data.preference.channels.email).to.equal(true); expect(data.preference.channels.in_app).to.equal(true); expect(data.preference.overrides.find((sources) => sources.channel === 'email').source).to.equal('subscriber'); }); it('should fetch according to template preferences defaults ', async () => { const templateDefaultSettings = await session.createTemplate({ preferenceSettingsOverride: { email: true, chat: true, push: true, sms: true, in_app: false }, noFeedId: true, }); const response = await getSubscriberPreference(session.subscriberToken); const data = response.data.data.find((pref) => pref.template._id === templateDefaultSettings._id); expect(data.preference.channels.email).to.equal(true); expect(data.preference.channels.in_app).to.equal(false); expect(data.preference.overrides.find((sources) => sources.channel === 'email').source).to.equal('subscriber'); }); // `enabled` flag is not used anymore. The presence of a preference object means that the subscriber has enabled notifications. it.skip('should fetch according to merged subscriber and template preferences ', async () => { const templateDefaultSettings = await session.createTemplate({ preferenceSettingsOverride: { email: true, chat: true, push: true, sms: true, in_app: false }, noFeedId: true, }); const updateDataEmailFalse = { channel: { type: ChannelTypeEnum.EMAIL, enabled: false, }, }; await updateSubscriberPreference(updateDataEmailFalse, session.subscriberToken, templateDefaultSettings._id); const response = await getSubscriberPreference(session.subscriberToken); const data = response.data.data.find((pref) => pref.template._id === templateDefaultSettings._id); expect(data.preference.channels.email).to.equal(false); expect(data.preference.channels.in_app).to.equal(false); expect(data.preference.overrides.find((sources) => sources.channel === 'email').source).to.equal('subscriber'); expect(data.preference.overrides.find((sources) => sources.channel === 'in_app').source).to.equal('template'); }); it('should filter not active channels and sources', async () => { const response = await getSubscriberPreference(session.subscriberToken); const data = response.data.data[0]; expect(Object.keys(data.preference.channels).length).to.equal(2); expect(data.preference.overrides.length).to.equal(2); }); }); export async function getSubscriberPreference(subscriberToken: string) { return await axios.get(`http://127.0.0.1:${process.env.PORT}/v1/widgets/preferences`, { headers: { Authorization: `Bearer ${subscriberToken}`, }, }); } ================================================ FILE: apps/api/src/app/widgets/e2e/get-unread-count.e2e.ts ================================================ import { Novu } from '@novu/api'; import { MessageRepository, NotificationTemplateEntity, SubscriberRepository } from '@novu/dal'; import { ChannelTypeEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import axios from 'axios'; import { expect } from 'chai'; import { initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('Unread Count - GET /widget/notifications/unread #novu-v0', () => { const messageRepository = new MessageRepository(); let session: UserSession; let template: NotificationTemplateEntity; let subscriberId: string; let subscriberToken: string; let subscriberProfile: { _id: string; } | null = null; let novuClient: Novu; beforeEach(async () => { session = new UserSession(); await session.initialize(); subscriberId = SubscriberRepository.createObjectId(); novuClient = initNovuClassSdk(session); template = await session.createTemplate({ noFeedId: true, }); const { body } = await session.testAgent .post('/v1/widgets/session/initialize') .send({ applicationIdentifier: session.environment.identifier, subscriberId, firstName: 'Test', lastName: 'User', email: 'test@example.com', }) .expect(201); const { token, profile } = body.data; subscriberToken = token; subscriberProfile = profile; }); it('should return unread count with no query', async () => { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); const messages = await messageRepository.findBySubscriberChannel( session.environment._id, subscriberProfile!._id, ChannelTypeEnum.IN_APP ); const messageId = messages[0]._id; expect(messages[0].read).to.equal(false); await axios.post( `http://127.0.0.1:${process.env.PORT}/v1/widgets/messages/markAs`, { messageId, mark: { read: true } }, { headers: { Authorization: `Bearer ${subscriberToken}`, }, } ); const unreadFeed = await getUnreadCount(); expect(unreadFeed.data.count).to.equal(2); }); it('should return unread count with query read false', async () => { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); const messages = await messageRepository.findBySubscriberChannel( session.environment._id, subscriberProfile!._id, ChannelTypeEnum.IN_APP ); const messageId = messages[0]._id; expect(messages[0].read).to.equal(false); await axios.post( `http://127.0.0.1:${process.env.PORT}/v1/widgets/messages/markAs`, { messageId, mark: { read: true } }, { headers: { Authorization: `Bearer ${subscriberToken}`, }, } ); const unreadFeed = await getUnreadCount({ read: false }); expect(unreadFeed.data.count).to.equal(2); }); it('should return unread count with query read true', async () => { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); const messages = await messageRepository.findBySubscriberChannel( session.environment._id, subscriberProfile!._id, ChannelTypeEnum.IN_APP ); const messageId = messages[0]._id; expect(messages[0].read).to.equal(false); await axios.post( `http://127.0.0.1:${process.env.PORT}/v1/widgets/messages/markAs`, { messageId, mark: { read: true } }, { headers: { Authorization: `Bearer ${subscriberToken}`, }, } ); const readFeed = await getUnreadCount({ read: true }); expect(readFeed.data.count).to.equal(1); }); async function getUnreadCount(query = {}) { const response = await axios.get(`http://127.0.0.1:${process.env.PORT}/v1/widgets/notifications/unread`, { params: { ...query, }, headers: { Authorization: `Bearer ${subscriberToken}`, }, }); return response.data; } }); ================================================ FILE: apps/api/src/app/widgets/e2e/get-unseen-count.e2e.ts ================================================ import { Novu } from '@novu/api'; import { buildFeedKey, buildMessageCountKey, CacheInMemoryProviderService, CacheService, InvalidateCacheService, } from '@novu/application-generic'; import { MessageRepository, NotificationTemplateEntity, SubscriberRepository } from '@novu/dal'; import { ChannelTypeEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import axios from 'axios'; import { expect } from 'chai'; import { initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('Unseen Count - GET /widget/notifications/unseen #novu-v0', () => { const messageRepository = new MessageRepository(); let session: UserSession; let template: NotificationTemplateEntity; let subscriberId: string; let subscriberToken: string; let subscriberProfile: { _id: string; } | null = null; let cacheInMemoryProviderService: CacheInMemoryProviderService; let invalidateCache: InvalidateCacheService; let novuClient: Novu; before(async () => { cacheInMemoryProviderService = new CacheInMemoryProviderService(); const cacheService = new CacheService(cacheInMemoryProviderService); await cacheService.initialize(); invalidateCache = new InvalidateCacheService(cacheService); }); beforeEach(async () => { session = new UserSession(); await session.initialize(); novuClient = initNovuClassSdk(session); subscriberId = SubscriberRepository.createObjectId(); template = await session.createTemplate({ noFeedId: true, }); const { body } = await session.testAgent .post('/v1/widgets/session/initialize') .send({ applicationIdentifier: session.environment.identifier, subscriberId, firstName: 'Test', lastName: 'User', email: 'test@example.com', }) .expect(201); const { token, profile } = body.data; subscriberToken = token; subscriberProfile = profile; }); it('should return unseen count with no query', async () => { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); const messages = await messageRepository.findBySubscriberChannel( session.environment._id, String(subscriberProfile?._id), ChannelTypeEnum.IN_APP ); const messageId = messages[0]._id; expect(messages[0].seen).to.equal(false); await axios.post( `http://127.0.0.1:${process.env.PORT}/v1/widgets/messages/markAs`, { messageId, mark: { seen: true } }, { headers: { Authorization: `Bearer ${subscriberToken}`, }, } ); const unseenFeed = await getUnseenCount(); expect(unseenFeed.data.count).to.equal(2); }); it('should return unseen count with query seen false', async () => { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); const messages = await messageRepository.findBySubscriberChannel( session.environment._id, String(subscriberProfile?._id), ChannelTypeEnum.IN_APP ); const messageId = messages[0]._id; expect(messages[0].seen).to.equal(false); await axios.post( `http://127.0.0.1:${process.env.PORT}/v1/widgets/messages/markAs`, { messageId, mark: { seen: true } }, { headers: { Authorization: `Bearer ${subscriberToken}`, }, } ); const unseenFeed = await getUnseenCount({ seen: false }); expect(unseenFeed.data.count).to.equal(2); }); it('should return unseen count with query seen true', async () => { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); const messages = await messageRepository.findBySubscriberChannel( session.environment._id, String(subscriberProfile?._id), ChannelTypeEnum.IN_APP ); const messageId = messages[0]._id; expect(messages[0].seen).to.equal(false); await axios.post( `http://127.0.0.1:${process.env.PORT}/v1/widgets/messages/markAs`, { messageId, mark: { seen: true } }, { headers: { Authorization: `Bearer ${subscriberToken}`, }, } ); const seenFeed = await getUnseenCount({ seen: true }); expect(seenFeed.data.count).to.equal(1); }); it('should return unseen count after mark as request', async () => { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); const messages = await messageRepository.findBySubscriberChannel( session.environment._id, String(subscriberProfile?._id), ChannelTypeEnum.IN_APP ); const messageId = messages[0]._id; let seenCount = (await getUnseenCount({ seen: false })).data.count; expect(seenCount).to.equal(3); await invalidateCache.invalidateQuery({ key: buildMessageCountKey().invalidate({ subscriberId, _environmentId: session.environment._id, }), }); await axios.post( `http://127.0.0.1:${process.env.PORT}/v1/widgets/messages/markAs`, { messageId, mark: { seen: true } }, { headers: { Authorization: `Bearer ${subscriberToken}`, }, } ); seenCount = (await getUnseenCount({ seen: false })).data.count; expect(seenCount).to.equal(2); }); async function getUnseenCount(query = {}) { const response = await axios.get(`http://127.0.0.1:${process.env.PORT}/v1/widgets/notifications/unseen`, { params: { ...query, }, headers: { Authorization: `Bearer ${subscriberToken}`, }, }); return response.data; } }); ================================================ FILE: apps/api/src/app/widgets/e2e/initialize-widget-session.e2e.ts ================================================ import { createHash } from '@novu/application-generic'; import { IntegrationRepository } from '@novu/dal'; import { ChannelTypeEnum, InAppProviderIdEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; // import { encryptApiKeysMigration } from '../../../../migrations/encrypt-api-keys/encrypt-api-keys-migration'; const integrationRepository = new IntegrationRepository(); const subscriberId = '12345'; describe('Initialize Session - /widgets/session/initialize (POST) #novu-v0', async () => { let session: UserSession; beforeEach(async () => { session = new UserSession(); await session.initialize(); await setHmacConfig(session); }); it('should create a valid app session for current widget user', async () => { const secretKey = session.environment.apiKeys[0].key; const hmacHash = createHash(secretKey, subscriberId); const firstName = 'Test'; const lastName = 'User'; const phone = '054777777'; const result = await session.testAgent.post('/v1/widgets/session/initialize').send({ applicationIdentifier: session.environment.identifier, subscriberId, firstName, lastName, email: 'test@example.com', phone, hmacHash, }); const { body } = result; expect(body.data.token).to.be.ok; expect(body.data.profile._id).to.be.ok; expect(body.data.profile.firstName).to.equal(firstName); expect(body.data.profile.lastName).to.equal(lastName); expect(body.data.profile.phone).to.equal(phone); }); it('should throw an error when an invalid environment Id passed', async () => { const { body } = await session.testAgent.post('/v1/widgets/session/initialize').send({ applicationIdentifier: 'some-not-existing-id', subscriberId, firstName: 'Test', lastName: 'User', email: 'test@example.com', phone: '054777777', }); expect(body.message).to.contain('Please provide a valid app identifier'); }); it('should pass the test with valid HMAC hash', async () => { const secretKey = session.environment.apiKeys[0].key; const hmacHash = createHash(secretKey, subscriberId); const response = await initWidgetSession(subscriberId, session, hmacHash); expect(response.status).to.equal(201); }); it('should fail the test with invalid subscriber id', async () => { const validSecretKey = session.environment.apiKeys[0].key; const invalidSubscriberId = `invalid-suscriberId`; const validSubscriberHmacHash = createHash(validSecretKey, subscriberId); const responseInvalidSubscriberId = await initWidgetSession(invalidSubscriberId, session, validSubscriberHmacHash); expect(responseInvalidSubscriberId.body?.data?.profile).to.not.exist; expect(responseInvalidSubscriberId.body.message).to.contain('Please provide a valid HMAC hash'); }); it('should fail the test with invalid secret key', async () => { const validSecretKey = session.environment.apiKeys[0].key; const invalidSecretKey = 'invalid-secret-key'; const invalidSubscriberHmacHash = createHash(invalidSecretKey, subscriberId); const responseInvalidSecretKey = await initWidgetSession(subscriberId, session, invalidSubscriberHmacHash); expect(responseInvalidSecretKey.body?.data?.profile).to.not.exist; expect(responseInvalidSecretKey.body.message).to.contain('Please provide a valid HMAC hash'); }); /* * it('should pass api key migration regression tests', async function () { * const validSecretKey = session.environment.apiKeys[0].key; */ // const invalidSubscriberHmacHash = createHash(validSecretKey, subscriberId); // await encryptApiKeysMigration(); // const response = await initWidgetSession(subscriberId, session, invalidSubscriberHmacHash); /* * expect(response.status).to.equal(201); * }); */ }); async function initWidgetSession(subscriberIdentifier: string, session, hmacHash?: string) { return await session.testAgent.post('/v1/widgets/session/initialize').send({ applicationIdentifier: session.environment.identifier, subscriberId: subscriberIdentifier, firstName: 'Test', lastName: 'User', email: 'test@example.com', phone: '054777777', hmacHash, }); } async function setHmacConfig(session: UserSession) { const result = await integrationRepository.update( { _environmentId: session.environment._id, _organizationId: session.organization._id, providerId: InAppProviderIdEnum.Novu, channel: ChannelTypeEnum.IN_APP, active: true, }, { $set: { 'credentials.hmac': true, }, } ); expect(result.matched).to.equal(1); expect(result.modified).to.equal(1); } ================================================ FILE: apps/api/src/app/widgets/e2e/mark-all-as-read.e2e.ts ================================================ import { Novu } from '@novu/api'; import { MessageRepository, NotificationTemplateEntity, SubscriberRepository } from '@novu/dal'; import { UserSession } from '@novu/testing'; import axios from 'axios'; import { expect } from 'chai'; import { initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('Mark all as read - /widgets/messages/seen (POST) #novu-v0', () => { const messageRepository = new MessageRepository(); let session: UserSession; let template: NotificationTemplateEntity; let subscriberId: string; let subscriberToken: string; let subscriberProfile: { _id: string; } | null = null; let novuClient: Novu; beforeEach(async () => { session = new UserSession(); await session.initialize(); subscriberId = SubscriberRepository.createObjectId(); novuClient = initNovuClassSdk(session); template = await session.createTemplate({ noFeedId: true, }); const { body } = await session.testAgent .post('/v1/widgets/session/initialize') .send({ applicationIdentifier: session.environment.identifier, subscriberId, firstName: 'Test', lastName: 'User', email: 'test@example.com', }) .expect(201); const { token, profile } = body.data; subscriberToken = token; subscriberProfile = profile; }); it('should mark all as seen', async () => { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); const unseenMessagesBefore = await getFeedCount({ seen: false }); expect(unseenMessagesBefore.data.count).to.equal(3); await axios.post( `http://127.0.0.1:${process.env.PORT}/v1/widgets/messages/seen`, {}, { headers: { Authorization: `Bearer ${subscriberToken}`, }, } ); const unseenMessagesAfter = await getFeedCount({ seen: false }); expect(unseenMessagesAfter.data.count).to.equal(0); }); it('should mark all as read', async () => { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); const unseenMessagesBefore = await getNotificationCount('read=false'); expect(unseenMessagesBefore.data.count).to.equal(3); await axios.post( `http://127.0.0.1:${process.env.PORT}/v1/widgets/messages/read`, {}, { headers: { Authorization: `Bearer ${subscriberToken}`, }, } ); const unseenMessagesAfter = await getNotificationCount('read=false'); expect(unseenMessagesAfter.data.count).to.equal(0); }); async function getFeedCount(query = {}) { const response = await axios.get(`http://127.0.0.1:${process.env.PORT}/v1/widgets/notifications/unseen`, { params: { ...query, }, headers: { Authorization: `Bearer ${subscriberToken}`, }, }); return response.data; } async function getNotificationCount(query: string) { const response = await axios.get(`http://127.0.0.1:${process.env.PORT}/v1/widgets/notifications/count?${query}`, { headers: { Authorization: `Bearer ${subscriberToken}`, }, }); return response.data; } }); ================================================ FILE: apps/api/src/app/widgets/e2e/mark-as-by-mark.e2e.ts ================================================ import { Novu } from '@novu/api'; import { MessageEntity, MessageRepository, NotificationTemplateEntity, SubscriberEntity, SubscriberRepository, } from '@novu/dal'; import { ChannelTypeEnum, MessagesStatusEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import axios from 'axios'; import { expect } from 'chai'; import { initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('Mark as Seen - /widgets/messages/mark-as (POST) #novu-v0', async () => { const messageRepository = new MessageRepository(); const subscriberRepository = new SubscriberRepository(); let session: UserSession; let template: NotificationTemplateEntity; let subscriberId; let subscriberToken: string; let subscriber: SubscriberEntity; let message: MessageEntity; let novuClient: Novu; before(async () => { session = new UserSession(); await session.initialize(); subscriberId = SubscriberRepository.createObjectId(); template = await session.createTemplate(); novuClient = initNovuClassSdk(session); const { body } = await session.testAgent .post('/v1/widgets/session/initialize') .send({ applicationIdentifier: session.environment.identifier, subscriberId, firstName: 'Test', lastName: 'User', email: 'test@example.com', }) .expect(201); subscriberToken = body.data.token; subscriber = await getSubscriber(session, subscriberRepository, subscriberId); }); beforeEach(async () => { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); message = await getMessage(session, messageRepository, subscriber); expect(message.seen).to.equal(false); expect(message.read).to.equal(false); expect(message.lastSeenDate).to.be.not.ok; expect(message.lastReadDate).to.be.not.ok; }); afterEach(async () => { await pruneMessages(messageRepository); }); it('should change the seen status', async () => { await markAs(subscriberToken, message._id, MessagesStatusEnum.SEEN); const updatedMessage = await getMessage(session, messageRepository, subscriber); expect(updatedMessage.seen).to.equal(true); expect(updatedMessage.read).to.equal(false); expect(updatedMessage.lastSeenDate).to.be.ok; expect(updatedMessage.lastReadDate).to.be.not.ok; }); it('should change the read status', async () => { await markAs(subscriberToken, message._id, MessagesStatusEnum.READ); const updatedMessage = await getMessage(session, messageRepository, subscriber); expect(updatedMessage.seen).to.equal(true); expect(updatedMessage.read).to.equal(true); expect(updatedMessage.lastSeenDate).to.be.ok; expect(updatedMessage.lastReadDate).to.be.ok; }); it('should change the seen status to unseen', async () => { // simulate user seen await markAs(subscriberToken, message._id, MessagesStatusEnum.SEEN); const seenMessage = await getMessage(session, messageRepository, subscriber); expect(seenMessage.seen).to.equal(true); expect(seenMessage.read).to.equal(false); expect(seenMessage.lastSeenDate).to.be.ok; expect(seenMessage.lastReadDate).to.be.not.ok; await markAs(subscriberToken, message._id, MessagesStatusEnum.UNSEEN); const updatedMessage = await getMessage(session, messageRepository, subscriber); expect(updatedMessage.seen).to.equal(false); expect(updatedMessage.read).to.equal(false); expect(updatedMessage.lastSeenDate).to.be.ok; expect(updatedMessage.lastReadDate).to.be.not.ok; }); it('should change the read status to unread', async () => { // simulate user read await markAs(subscriberToken, message._id, MessagesStatusEnum.READ); const readMessage = await getMessage(session, messageRepository, subscriber); expect(readMessage.seen).to.equal(true); expect(readMessage.read).to.equal(true); expect(readMessage.lastSeenDate).to.be.ok; expect(readMessage.lastReadDate).to.be.ok; await markAs(subscriberToken, message._id, MessagesStatusEnum.UNREAD); const updateMessage = await getMessage(session, messageRepository, subscriber); expect(updateMessage.seen).to.equal(true); expect(updateMessage.read).to.equal(false); expect(updateMessage.lastSeenDate).to.be.ok; expect(updateMessage.lastReadDate).to.be.ok; }); it('should throw exception if messages were not provided', async () => { const failureMessage = 'should not reach here, should throw error'; try { await markAs(subscriberToken, undefined, MessagesStatusEnum.SEEN); expect.fail(failureMessage); } catch (e) { if (e.message === failureMessage) { expect(e.message).to.be.empty; } expect(e.response.data.message).to.equal('messageId is required'); expect(e.response.data.statusCode).to.equal(400); } try { await markAs(subscriberToken, [], MessagesStatusEnum.SEEN); expect.fail(failureMessage); } catch (e) { if (e.message === failureMessage) { expect(e.message).to.be.empty; } expect(e.response.data.message).to.equal('messageId is required'); expect(e.response.data.statusCode).to.equal(400); } }); }); async function getMessage( session: UserSession, messageRepository: MessageRepository, subscriber: SubscriberEntity ): Promise { const message = await messageRepository.findOne({ _environmentId: session.environment._id, _subscriberId: subscriber._id, channel: ChannelTypeEnum.IN_APP, }); if (!message) { expect(message).to.be.ok; throw new Error('message not found'); } return message; } async function markAs(subscriberToken: string, messageIds: string | string[] | undefined, mark: MessagesStatusEnum) { return await axios.post( `http://127.0.0.1:${process.env.PORT}/v1/widgets/messages/mark-as`, { messageId: messageIds, markAs: mark }, { headers: { Authorization: `Bearer ${subscriberToken}`, }, } ); } async function getSubscriber( session: UserSession, subscriberRepository: SubscriberRepository, subscriberId: string ): Promise { const subscriberRes = await subscriberRepository.findOne({ _environmentId: session.environment._id, subscriberId, }); if (!subscriberRes) { expect(subscriberRes).to.be.ok; throw new Error('subscriber not found'); } return subscriberRes; } async function pruneMessages(messageRepository) { await messageRepository.delete({}); } ================================================ FILE: apps/api/src/app/widgets/e2e/mark-as.e2e.ts ================================================ import { Novu } from '@novu/api'; import { MessageEntity, MessageRepository, NotificationTemplateEntity, SubscriberRepository } from '@novu/dal'; import { ChannelTypeEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import axios from 'axios'; import { expect } from 'chai'; import { initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('Mark as Seen - /widgets/messages/markAs (POST) #novu-v0', async () => { const messageRepository = new MessageRepository(); let session: UserSession; let template: NotificationTemplateEntity; let subscriberId; let novuClient: Novu; before(async () => { session = new UserSession(); await session.initialize(); subscriberId = SubscriberRepository.createObjectId(); template = await session.createTemplate(); novuClient = initNovuClassSdk(session); }); it('should change the seen status', async () => { const { body } = await session.testAgent .post('/v1/widgets/session/initialize') .send({ applicationIdentifier: session.environment.identifier, subscriberId, firstName: 'Test', lastName: 'User', email: 'test@example.com', }) .expect(201); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); const { token } = body.data; const messages = await messageRepository.findBySubscriberChannel( session.environment._id, body.data.profile._id, ChannelTypeEnum.IN_APP ); const messageId = messages[0]._id; expect(messages[0].seen).to.equal(false); await axios.post( `http://127.0.0.1:${process.env.PORT}/v1/widgets/messages/markAs`, { messageId, mark: { seen: true } }, { headers: { Authorization: `Bearer ${token}`, }, } ); const modifiedMessage = (await messageRepository.findOne({ _id: messageId, _environmentId: session.environment._id, })) as MessageEntity; expect(modifiedMessage.seen).to.equal(true); expect(modifiedMessage.lastSeenDate).to.be.ok; }); }); ================================================ FILE: apps/api/src/app/widgets/e2e/remove-all-messages.e2e.ts ================================================ import { Novu } from '@novu/api'; import { MessageRepository, NotificationTemplateEntity, SubscriberRepository } from '@novu/dal'; import { ChannelTypeEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import axios from 'axios'; import { expect } from 'chai'; import { initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('Remove all messages - /widgets/messages (DELETE) #novu-v0', () => { const messageRepository = new MessageRepository(); let session: UserSession; let template: NotificationTemplateEntity; let subscriberId: string; let subscriberToken: string; let subscriberProfile: { _id: string; } | null = null; let novuClient: Novu; beforeEach(async () => { session = new UserSession(); await session.initialize(); subscriberId = SubscriberRepository.createObjectId(); novuClient = initNovuClassSdk(session); template = await session.createTemplate({ noFeedId: true, }); const { body } = await session.testAgent .post('/v1/widgets/session/initialize') .send({ applicationIdentifier: session.environment.identifier, subscriberId, firstName: 'Test', lastName: 'User', email: 'test@example.com', }) .expect(201); const { token, profile } = body.data; subscriberToken = token; subscriberProfile = profile; }); it('should remove all messages', async () => { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); const messagesBefore = await messageRepository.find({ _environmentId: session.environment._id, _subscriberId: subscriberProfile?._id, channel: ChannelTypeEnum.IN_APP, }); expect(messagesBefore.length).to.equal(3); await axios.delete(`http://127.0.0.1:${process.env.PORT}/v1/widgets/messages`, { headers: { Authorization: `Bearer ${subscriberToken}`, }, }); const messagesAfter = await messageRepository.find({ _environmentId: session.environment._id, _subscriberId: subscriberProfile?._id, channel: ChannelTypeEnum.IN_APP, }); expect(messagesAfter.length).to.equal(0); }); it('should remove all messages of a specific feed', async () => { const templateWithFeed = await session.createTemplate({ noFeedId: false }); const _feedId = templateWithFeed?.steps[0]?.template?._feedId; await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: templateWithFeed.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: templateWithFeed.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(templateWithFeed._id); await session.waitForJobCompletion(template._id); const messagesBefore = await messageRepository.find({ _environmentId: session.environment._id, _subscriberId: subscriberProfile?._id, channel: ChannelTypeEnum.IN_APP, }); expect(messagesBefore.length).to.equal(5); await axios.delete(`http://127.0.0.1:${process.env.PORT}/v1/widgets/messages?feedId=${_feedId}`, { headers: { Authorization: `Bearer ${subscriberToken}`, }, }); const messagesAfter = await messageRepository.find({ _environmentId: session.environment._id, _subscriberId: subscriberProfile?._id, channel: ChannelTypeEnum.IN_APP, }); expect(messagesAfter.length).to.equal(3); }); }); ================================================ FILE: apps/api/src/app/widgets/e2e/remove-messages-bulk.e2e.ts ================================================ import { Novu } from '@novu/api'; import { MessageRepository, NotificationTemplateEntity, SubscriberRepository } from '@novu/dal'; import { ChannelTypeEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import axios from 'axios'; import { expect } from 'chai'; import { initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('Remove messages by bulk - /widgets/messages/bulk/delete (POST) #novu-v0', () => { const messageRepository = new MessageRepository(); let session: UserSession; let template: NotificationTemplateEntity; let subscriberId: string; let subscriberToken: string; let subscriberProfile: { _id: string; } | null = null; let novuClient: Novu; beforeEach(async () => { session = new UserSession(); await session.initialize(); subscriberId = SubscriberRepository.createObjectId(); novuClient = initNovuClassSdk(session); template = await session.createTemplate({ noFeedId: true, }); const { body } = await session.testAgent .post('/v1/widgets/session/initialize') .send({ applicationIdentifier: session.environment.identifier, subscriberId, firstName: 'Test', lastName: 'User', email: 'test@example.com', }) .expect(201); const { token, profile } = body.data; subscriberToken = token; subscriberProfile = profile; }); it('should remove messages by bulk', async () => { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); const messagesBefore = await messageRepository.find({ _environmentId: session.environment._id, _subscriberId: subscriberProfile?._id, channel: ChannelTypeEnum.IN_APP, }); expect(messagesBefore.length).to.equal(3); const [firstMessage, ...messagesToDelete] = messagesBefore; await axios.post( `http://127.0.0.1:${process.env.PORT}/v1/widgets/messages/bulk/delete`, { messageIds: messagesToDelete.map((msg) => msg._id) }, { headers: { Authorization: `Bearer ${subscriberToken}`, }, } ); const messagesAfter = await messageRepository.find({ _environmentId: session.environment._id, _subscriberId: subscriberProfile?._id, channel: ChannelTypeEnum.IN_APP, }); expect(messagesAfter.length).to.equal(1); expect(messagesAfter[0]._id).to.equal(firstMessage._id); }); it('should throw an exception when message ids were not provided', async () => { await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await novuClient.trigger({ workflowId: template.triggers[0].identifier, to: subscriberId }); await session.waitForJobCompletion(template._id); try { const res = await axios.post( `http://127.0.0.1:${process.env.PORT}/v1/widgets/messages/bulk/delete`, {}, { headers: { Authorization: `Bearer ${subscriberToken}`, }, } ); expect(true).to.equal(false); } catch (e) { expect(e.response.data.message).to.contain('messageIds should not be empty'); } }); it('should throw an exception message amount exceeds the api limit', async () => { const randomMongoId = session.organization._id; let messageIds = duplicateStr(randomMongoId, 100); const res = await axios.post( `http://127.0.0.1:${process.env.PORT}/v1/widgets/messages/bulk/delete`, { messageIds }, { headers: { Authorization: `Bearer ${subscriberToken}`, }, } ); expect(res.status).to.equal(200); try { messageIds = duplicateStr(randomMongoId, 101); await axios.post( `http://127.0.0.1:${process.env.PORT}/v1/widgets/messages/bulk/delete`, { messageIds }, { headers: { Authorization: `Bearer ${subscriberToken}`, }, } ); expect(true).to.equal(false); } catch (e) { expect(e.response.data.message).to.contain('messageIds must contain no more than 100 elements'); } }); }); function duplicateStr(str: string, count: number): string[] { return [...Array(count)].map((_, i) => str); } ================================================ FILE: apps/api/src/app/widgets/e2e/update-subscriber-preference.e2e.ts ================================================ import { NotificationTemplateEntity, SubscriberRepository } from '@novu/dal'; import { ChannelTypeEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import axios from 'axios'; import { expect } from 'chai'; import { UpdateSubscriberPreferenceRequestDto } from '../dtos/update-subscriber-preference-request.dto'; import { getSubscriberPreference } from './get-subscriber-preference.e2e'; describe('PATCH /widgets/preferences/:templateId #novu-v0', () => { let template: NotificationTemplateEntity; let session: UserSession; let subscriberId: string; beforeEach(async () => { session = new UserSession(); await session.initialize(); subscriberId = SubscriberRepository.createObjectId(); template = await session.createTemplate({ noFeedId: true, }); }); // `enabled` flag is not used anymore. The presence of a preference object means that the subscriber has enabled notifications. it.skip('should create user preference', async () => { const updateData = { enabled: false, }; await updateSubscriberPreference(updateData, session.subscriberToken, template._id); const response = await getSubscriberPreference(session.subscriberToken); const data = response.data.data[0]; expect(data.preference.enabled).to.equal(false); expect(data.preference.channels.email).to.equal(true); expect(data.preference.channels.in_app).to.equal(true); }); it('should update user preference', async () => { const createData = { enabled: true, }; await updateSubscriberPreference(createData, session.subscriberToken, template._id); const updateDataEmailFalse = { channel: { type: ChannelTypeEnum.EMAIL, enabled: false, }, }; const response = (await updateSubscriberPreference(updateDataEmailFalse, session.subscriberToken, template._id)) .data.data; expect(response.preference.enabled).to.equal(true); expect(response.preference.channels.email).to.equal(false); expect(response.preference.channels.in_app).to.equal(true); expect(response.preference.channels.sms).to.be.not.ok; expect(response.preference.channels.chat).to.be.not.ok; }); it( 'should not update empty object should throw exception if ' + 'no channel and not template enable param - user preference', async () => { const createData = { templateId: template._id, enabled: true, }; await updateSubscriberPreference(createData, session.subscriberToken, template._id); const updateDataEmailFalse = { channel: {}, } as UpdateSubscriberPreferenceRequestDto; let responseMessage = ''; try { await updateSubscriberPreference(updateDataEmailFalse, session.subscriberToken, template._id); } catch (e) { responseMessage = 'In order to make an update you need to provider channel or enabled'; } expect(responseMessage).to.equal('In order to make an update you need to provider channel or enabled'); } ); it('should override template preference defaults after subscriber update', async () => { const templateDefaultSettings = await session.createTemplate({ preferenceSettingsOverride: { email: false, chat: true, push: true, sms: true, in_app: true }, noFeedId: true, }); const updateEmailEnable = { channel: { type: ChannelTypeEnum.EMAIL, enabled: true, }, }; const response = ( await updateSubscriberPreference(updateEmailEnable, session.subscriberToken, templateDefaultSettings._id) ).data.data; expect(response.preference.enabled).to.equal(true); expect(response.preference.channels.email).to.equal(true); expect(response.preference.channels.in_app).to.equal(true); }); }); export async function updateSubscriberPreference( data: UpdateSubscriberPreferenceRequestDto, subscriberToken: string, templateId: string ) { return await axios.patch(`http://127.0.0.1:${process.env.PORT}/v1/widgets/preferences/${templateId}`, data, { headers: { Authorization: `Bearer ${subscriberToken}`, }, }); } ================================================ FILE: apps/api/src/app/widgets/pipes/limit-pipe/limit-pipe.spec.ts ================================================ import { Paramtype } from '@nestjs/common/interfaces/features/paramtype.interface'; import { expect } from 'chai'; import { LimitPipe } from './limit-pipe'; enum MetadataEnum { DATA = 'limit', TYPE = 'query', } describe('LimitPipe', () => { let pipe: LimitPipe; const metadata = { data: MetadataEnum.DATA, type: MetadataEnum.TYPE as Paramtype, metatype: String }; beforeEach(() => { pipe = new LimitPipe(1, 1000); }); it('should return the input value if it is within the limits', () => { let limit = 1; let res = pipe.transform(limit, metadata); expect(res).to.equal(limit); limit = 500; res = pipe.transform(limit, metadata); expect(res).to.equal(limit); limit = 999; res = pipe.transform(limit, metadata); expect(res).to.equal(limit); limit = 1000; res = pipe.transform(limit, metadata); expect(res).to.equal(limit); }); it('should throw exception when the limit is lower then the min threshold', () => { let limit = -1; expect(() => pipe.transform(limit, metadata)).to.throw(`${MetadataEnum.DATA} must not be less than 1`); limit = 0; expect(() => pipe.transform(limit, metadata)).to.throw(`${MetadataEnum.DATA} must not be less than 1`); }); it('should throw exception when the limit is higher then the limit ', () => { const limit = 1001; expect(() => pipe.transform(limit, metadata)).to.throw(`${MetadataEnum.DATA} must not be greater than 1000`); }); it('should return undefined input value if optional', () => { pipe = new LimitPipe(1, 1000, true); let limit: undefined | null; let res = pipe.transform(limit, metadata); expect(res).to.equal(limit); limit = null; res = pipe.transform(limit, metadata); expect(res).to.equal(limit); }); it('should throw exception if the input value is not optional', () => { pipe = new LimitPipe(1, 1000, false); let limit: undefined | null; expect(() => pipe.transform(limit, metadata)).to.throw( `${MetadataEnum.DATA} must be a number conforming to the specified constraints` ); expect(() => pipe.transform(limit, metadata)).to.throw( `${MetadataEnum.DATA} must be a number conforming to the specified constraints` ); limit = null; expect(() => pipe.transform(limit, metadata)).to.throw( `${MetadataEnum.DATA} must be a number conforming to the specified constraints` ); }); it('should set isOptional as false by default on LimitPipe initialize', () => { pipe = new LimitPipe(1, 1000); const limit = undefined; expect(() => pipe.transform(limit, metadata)).to.throw( `${MetadataEnum.DATA} must be a number conforming to the specified constraints` ); }); }); ================================================ FILE: apps/api/src/app/widgets/pipes/limit-pipe/limit-pipe.ts ================================================ import { ArgumentMetadata, BadRequestException, PipeTransform } from '@nestjs/common'; export class LimitPipe implements PipeTransform { private readonly minInt: number; private readonly maxInt: number; private readonly isOptional: boolean; constructor(minInt: number, maxInt: number, isOptional = false) { this.minInt = minInt; this.maxInt = maxInt; this.isOptional = isOptional; } transform(value: number | undefined | null, metadata: ArgumentMetadata) { if (this.isOptional && (value === null || value === undefined)) { return value; } if (!this.isOptional && (value === null || value === undefined)) { throw new BadRequestException(`${metadata.data} must be a number conforming to the specified constraints`); } if (value! < this.minInt) { throw new BadRequestException(`${metadata.data} must not be less than ${this.minInt}`); } if (value! > this.maxInt) { throw new BadRequestException(`${metadata.data} must not be greater than ${this.maxInt}`); } return value; } } ================================================ FILE: apps/api/src/app/widgets/queries/get-count.query.ts ================================================ export class GetCountQuery { feedIdentifier?: string[] | string; seen?: boolean; read?: boolean; } ================================================ FILE: apps/api/src/app/widgets/queries/store.query.ts ================================================ export class StoreQuery { seen?: boolean; read?: boolean; } ================================================ FILE: apps/api/src/app/widgets/usecases/get-feed-count/get-feed-count.command.ts ================================================ import { Transform } from 'class-transformer'; import { IsArray, IsOptional, Max, Min } from 'class-validator'; import { EnvironmentWithSubscriber } from '../../../shared/commands/project.command'; export class GetFeedCountCommand extends EnvironmentWithSubscriber { @IsOptional() @IsArray() feedId?: string[]; @IsOptional() seen?: boolean; @IsOptional() read?: boolean; @IsOptional() @Transform(({ value }) => { if (Number.isNaN(value) || value == null) { return 100; } return value; }) @Min(1) @Max(1000) limit: number; } ================================================ FILE: apps/api/src/app/widgets/usecases/get-feed-count/get-feed-count.usecase.ts ================================================ import { BadRequestException, Injectable } from '@nestjs/common'; import { buildMessageCountKey, CachedQuery, InstrumentUsecase } from '@novu/application-generic'; import { MessageRepository, SubscriberRepository } from '@novu/dal'; import { ChannelTypeEnum } from '@novu/shared'; import { GetFeedCountCommand } from './get-feed-count.command'; @Injectable() export class GetFeedCount { constructor( private messageRepository: MessageRepository, private subscriberRepository: SubscriberRepository ) {} @InstrumentUsecase() @CachedQuery({ builder: ({ environmentId, subscriberId, ...command }: GetFeedCountCommand) => buildMessageCountKey().cache({ environmentId, subscriberId, ...command, }), }) async execute(command: GetFeedCountCommand): Promise<{ count: number }> { const subscriber = await this.subscriberRepository.findBySubscriberId( command.environmentId, command.subscriberId, true ); if (!subscriber) { throw new BadRequestException( `Subscriber ${command.subscriberId} is not exist in environment ${command.environmentId}, ` + `please provide a valid subscriber identifier` ); } const count = await this.messageRepository.getCount( command.environmentId, subscriber._id, ChannelTypeEnum.IN_APP, { feedId: command.feedId, seen: command.seen, read: command.read, }, { limit: command.limit } ); return { count }; } } ================================================ FILE: apps/api/src/app/widgets/usecases/get-notifications-feed/get-notifications-feed.command.ts ================================================ import { IsArray, IsNumber, IsOptional, IsString } from 'class-validator'; import { EnvironmentWithSubscriber } from '../../../shared/commands/project.command'; import { StoreQuery } from '../../queries/store.query'; export class GetNotificationsFeedCommand extends EnvironmentWithSubscriber { @IsNumber() @IsOptional() page = 0; @IsNumber() @IsOptional() limit = 10; @IsOptional() @IsArray() feedId?: string[]; @IsOptional() query: StoreQuery; @IsOptional() @IsString() payload?: string; } ================================================ FILE: apps/api/src/app/widgets/usecases/get-notifications-feed/get-notifications-feed.usecase.ts ================================================ import { BadRequestException, Injectable } from '@nestjs/common'; import { AnalyticsService, buildFeedKey, buildSubscriberKey, CachedQuery, CachedResponse, InstrumentUsecase, } from '@novu/application-generic'; import { MessageRepository, SubscriberEntity, SubscriberRepository } from '@novu/dal'; import { ActorTypeEnum, ChannelTypeEnum } from '@novu/shared'; import { FeedResponseDto } from '../../dtos/feeds-response.dto'; import { GetNotificationsFeedCommand } from './get-notifications-feed.command'; @Injectable() export class GetNotificationsFeed { constructor( private messageRepository: MessageRepository, private analyticsService: AnalyticsService, private subscriberRepository: SubscriberRepository ) {} private getPayloadObject(payload?: string): object | undefined { if (!payload) { return; } try { return JSON.parse(Buffer.from(payload, 'base64').toString()); } catch (e) { throw new BadRequestException('Invalid payload, the JSON object should be encoded to base64 string.'); } } @InstrumentUsecase() async execute(command: GetNotificationsFeedCommand): Promise { const payload = this.getPayloadObject(command.payload); const subscriber = await this.fetchSubscriber({ _environmentId: command.environmentId, subscriberId: command.subscriberId, }); if (!subscriber) { throw new BadRequestException( `Subscriber not found for this environment with the id: ${ command.subscriberId }. Make sure to create a subscriber before fetching the feed.` ); } const feed = await this.messageRepository.findBySubscriberChannel( command.environmentId, subscriber._id, ChannelTypeEnum.IN_APP, { feedId: command.feedId, seen: command.query.seen, read: command.query.read, payload }, { limit: command.limit, skip: command.page * command.limit, } ); if (feed.length) { this.analyticsService.mixpanelTrack('Fetch Feed - [Notification Center]', '', { _subscriber: feed[0]?._subscriberId, _organization: command.organizationId, feedSize: feed.length, }); } for (const message of feed) { if (message._actorId && message.actor?.type === ActorTypeEnum.USER) { message.actor.data = message.actorSubscriber?.avatar || null; } } const skip = command.page * command.limit; let totalCount = 0; if (feed.length) { totalCount = await this.messageRepository.getCount( command.environmentId, subscriber._id, ChannelTypeEnum.IN_APP, { feedId: command.feedId, seen: command.query.seen, read: command.query.read, payload, }, { limit: command.limit + 1, skip } ); } const hasMore = feed.length < totalCount; totalCount = Math.min(totalCount, command.limit); const data = feed.map((el) => ({ ...el, content: el.content as string })); return { data, totalCount, hasMore, pageSize: command.limit, page: command.page, }; } @CachedResponse({ builder: (command: { subscriberId: string; _environmentId: string }) => buildSubscriberKey({ _environmentId: command._environmentId, subscriberId: command.subscriberId, }), }) private async fetchSubscriber({ subscriberId, _environmentId, }: { subscriberId: string; _environmentId: string; }): Promise { return await this.subscriberRepository.findBySubscriberId(_environmentId, subscriberId); } } ================================================ FILE: apps/api/src/app/widgets/usecases/get-organization-data/get-organization-data.command.ts ================================================ import { EnvironmentWithSubscriber } from '../../../shared/commands/project.command'; export class GetOrganizationDataCommand extends EnvironmentWithSubscriber {} ================================================ FILE: apps/api/src/app/widgets/usecases/get-organization-data/get-organization-data.usecase.ts ================================================ import { Injectable, NotFoundException } from '@nestjs/common'; import { CommunityOrganizationRepository } from '@novu/dal'; import { OrganizationResponseDto } from '../../dtos/organization-response.dto'; import { GetOrganizationDataCommand } from './get-organization-data.command'; @Injectable() export class GetOrganizationData { constructor(private communityOrganizationRepository: CommunityOrganizationRepository) {} async execute(command: GetOrganizationDataCommand): Promise { const organization = await this.communityOrganizationRepository.findById(command.organizationId); if (!organization) { throw new NotFoundException(`Organization with id ${command.organizationId} not found`); } return { _id: organization._id, name: organization.name, branding: organization.branding, }; } } ================================================ FILE: apps/api/src/app/widgets/usecases/index.ts ================================================ import { GetFeedCount } from './get-feed-count/get-feed-count.usecase'; import { GetNotificationsFeed } from './get-notifications-feed/get-notifications-feed.usecase'; import { GetOrganizationData } from './get-organization-data/get-organization-data.usecase'; import { InitializeSession } from './initialize-session/initialize-session.usecase'; import { UpdateMessageActions } from './mark-action-as-done/update-message-actions.usecase'; import { MarkAllMessagesAs } from './mark-all-messages-as/mark-all-messages-as.usecase'; import { MarkMessageAs } from './mark-message-as/mark-message-as.usecase'; import { MarkMessageAsByMark } from './mark-message-as-by-mark/mark-message-as-by-mark.usecase'; import { RemoveMessage } from './remove-message/remove-message.usecase'; import { RemoveAllMessages } from './remove-messages/remove-all-messages.usecase'; import { RemoveMessagesBulk } from './remove-messages-bulk/remove-messages-bulk.usecase'; import { MessageInteractionService, WorkflowRunService } from '@novu/application-generic'; export const USE_CASES = [ GetOrganizationData, UpdateMessageActions, MarkMessageAs, GetFeedCount, GetNotificationsFeed, InitializeSession, RemoveMessage, RemoveAllMessages, MarkAllMessagesAs, RemoveMessagesBulk, MarkMessageAsByMark, MessageInteractionService, WorkflowRunService, ]; ================================================ FILE: apps/api/src/app/widgets/usecases/initialize-session/initialize-session.command.ts ================================================ import { BaseCommand } from '@novu/application-generic'; import { IsDefined, IsEmail, IsOptional, IsString } from 'class-validator'; export class InitializeSessionCommand extends BaseCommand { @IsDefined() @IsString() subscriberId: string; @IsDefined() @IsString() applicationIdentifier: string; firstName?: string; lastName?: string; @IsEmail() @IsOptional() email?: string; @IsString() @IsOptional() phone?: string; @IsString() @IsOptional() hmacHash?: string; } ================================================ FILE: apps/api/src/app/widgets/usecases/initialize-session/initialize-session.usecase.ts ================================================ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { AnalyticsService, CreateOrUpdateSubscriberCommand, CreateOrUpdateSubscriberUseCase, createHash, decryptApiKey, InstrumentUsecase, LogDecorator, SelectIntegration, SelectIntegrationCommand, } from '@novu/application-generic'; import { EnvironmentRepository } from '@novu/dal'; import { ChannelTypeEnum, InAppProviderIdEnum } from '@novu/shared'; import { AuthService } from '../../../auth/services/auth.service'; import { isHmacValid } from '../../../shared/helpers/is-valid-hmac'; import { SessionInitializeResponseDto } from '../../dtos/session-initialize-response.dto'; import { InitializeSessionCommand } from './initialize-session.command'; @Injectable() export class InitializeSession { constructor( private environmentRepository: EnvironmentRepository, private createOrUpdateSubscriberUsecase: CreateOrUpdateSubscriberUseCase, private authService: AuthService, private selectIntegration: SelectIntegration, private analyticsService: AnalyticsService ) {} @LogDecorator() @InstrumentUsecase() async execute(command: InitializeSessionCommand): Promise { const environment = await this.environmentRepository.findEnvironmentByIdentifier(command.applicationIdentifier); if (!environment) { throw new BadRequestException('Please provide a valid app identifier'); } const inAppIntegration = await this.selectIntegration.execute( SelectIntegrationCommand.create({ environmentId: environment._id, organizationId: environment._organizationId, channelType: ChannelTypeEnum.IN_APP, providerId: InAppProviderIdEnum.Novu, filterData: {}, }) ); if (!inAppIntegration) { throw new NotFoundException('In app integration could not be found'); } if (inAppIntegration.credentials.hmac) { validateNotificationCenterEncryption(environment, command); } const subscriber = await this.createOrUpdateSubscriberUsecase.execute( CreateOrUpdateSubscriberCommand.create({ environmentId: environment._id, organizationId: environment._organizationId, subscriberId: command.subscriberId, firstName: command.firstName, lastName: command.lastName, email: command.email, phone: command.phone, allowUpdate: isHmacValid(environment.apiKeys[0].key, command.subscriberId, command.hmacHash), }) ); this.analyticsService.mixpanelTrack('Initialize Widget Session - [Notification Center]', '', { _organization: environment._organizationId, environmentName: environment.name, _subscriber: subscriber._id, }); return { token: await this.authService.getSubscriberWidgetToken(subscriber, []), profile: { _id: subscriber._id, firstName: subscriber.firstName, lastName: subscriber.lastName, phone: subscriber.phone, }, }; } } function validateNotificationCenterEncryption(environment, command: InitializeSessionCommand) { if (!isHmacValid(environment.apiKeys[0].key, command.subscriberId, command.hmacHash)) { throw new BadRequestException('Please provide a valid HMAC hash'); } } ================================================ FILE: apps/api/src/app/widgets/usecases/mark-action-as-done/update-message-actions.command.ts ================================================ import { ButtonTypeEnum, MessageActionStatusEnum } from '@novu/shared'; import { IsDefined, IsMongoId, IsOptional } from 'class-validator'; import { EnvironmentWithSubscriber } from '../../../shared/commands/project.command'; export class UpdateMessageActionsCommand extends EnvironmentWithSubscriber { @IsMongoId() messageId: string; @IsDefined() type: ButtonTypeEnum; @IsDefined() status: MessageActionStatusEnum; @IsOptional() payload?: any; } ================================================ FILE: apps/api/src/app/widgets/usecases/mark-action-as-done/update-message-actions.usecase.ts ================================================ import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { AnalyticsService } from '@novu/application-generic'; import { MessageEntity, MessageRepository, MessageTemplateEntity, SubscriberRepository } from '@novu/dal'; import { UpdateMessageActionsCommand } from './update-message-actions.command'; @Injectable() export class UpdateMessageActions { constructor( private messageRepository: MessageRepository, private subscriberRepository: SubscriberRepository, private analyticsService: AnalyticsService ) {} async execute(command: UpdateMessageActionsCommand): Promise { const foundMessage = await this.messageRepository.findOne({ _environmentId: command.environmentId, _id: command.messageId, }); if (!foundMessage) { throw new NotFoundException(`Message ${command.messageId} not found`); } const updatePayload: Partial = {}; if (command.type) { updatePayload['cta.action.result.type'] = command.type; } if (command.status) { updatePayload['cta.action.status'] = command.status; } if (command.payload) { updatePayload['cta.action.result.payload'] = command.payload; } const subscriber = await this.subscriberRepository.findBySubscriberId(command.environmentId, command.subscriberId); if (!subscriber) { throw new BadRequestException( `Subscriber with the id: ${command.subscriberId} was not found for this environment. ` + `Make sure to create a subscriber before trying to modify it.` ); } const modificationResponse = await this.messageRepository.update( { _environmentId: command.environmentId, _subscriberId: subscriber._id, _id: command.messageId, }, { $set: updatePayload, } ); if (!modificationResponse.modified) { throw new BadRequestException( `Message with the id: ${command.messageId} was not found for this environment. ` + `Make sure to address correct message before trying to modify it.` ); } this.analyticsService.track('Notification Action Clicked - [Notification Center]', command.organizationId, { _subscriber: subscriber._id, _organization: command.organizationId, _environment: command.environmentId, }); return (await this.messageRepository.findOne({ _environmentId: command.environmentId, _id: command.messageId, })) as MessageEntity; } } ================================================ FILE: apps/api/src/app/widgets/usecases/mark-all-messages-as/mark-all-messages-as.command.ts ================================================ import { MessagesStatusEnum } from '@novu/shared'; import { IsDefined, IsOptional } from 'class-validator'; import { EnvironmentWithSubscriber } from '../../../shared/commands/project.command'; export class MarkAllMessagesAsCommand extends EnvironmentWithSubscriber { @IsOptional() feedIdentifiers?: string[]; @IsDefined() markAs: MessagesStatusEnum; } ================================================ FILE: apps/api/src/app/widgets/usecases/mark-all-messages-as/mark-all-messages-as.usecase.ts ================================================ import { Inject, Injectable, NotFoundException, Optional } from '@nestjs/common'; import { AnalyticsService, buildFeedKey, buildMessageCountKey, InvalidateCacheService, messageWebhookMapper, SendWebhookMessage, WebSocketsQueueService, } from '@novu/application-generic'; import { EnvironmentRepository, MessageRepository, SubscriberRepository } from '@novu/dal'; import { ChannelTypeEnum, MessagesStatusEnum, WebhookEventEnum, WebhookObjectTypeEnum } from '@novu/shared'; import { mapMarkMessageToWebSocketEvent } from '../../../shared/helpers'; import { MarkAllMessagesAsCommand } from './mark-all-messages-as.command'; @Injectable() export class MarkAllMessagesAs { constructor( @Inject(InvalidateCacheService) private invalidateCache: InvalidateCacheService, private messageRepository: MessageRepository, private webSocketsQueueService: WebSocketsQueueService, private subscriberRepository: SubscriberRepository, private analyticsService: AnalyticsService, private sendWebhookMessage: SendWebhookMessage, private environmentRepository: EnvironmentRepository ) {} async execute(command: MarkAllMessagesAsCommand): Promise { const subscriber = await this.subscriberRepository.findBySubscriberId(command.environmentId, command.subscriberId); if (!subscriber) { throw new NotFoundException( `Subscriber ${command.subscriberId} does not exist in environment ${command.environmentId}, ` + `please provide a valid subscriber identifier` ); } const environment = await this.environmentRepository.findOne( { _id: command.environmentId, }, 'webhookAppId identifier' ); if (!environment) { throw new Error(`Environment not found for id ${command.environmentId}`); } await this.invalidateCache.invalidateQuery({ key: buildMessageCountKey().invalidate({ subscriberId: command.subscriberId, _environmentId: command.environmentId, }), }); const updatedMessages = await this.messageRepository.markAllMessagesAs({ subscriberId: subscriber._id, environmentId: command.environmentId, markAs: command.markAs, feedIdentifiers: command.feedIdentifiers, channel: ChannelTypeEnum.IN_APP, }); if (command.markAs !== MessagesStatusEnum.UNSEEN) { let eventType = WebhookEventEnum.MESSAGE_SEEN; if (command.markAs === MessagesStatusEnum.READ) { eventType = WebhookEventEnum.MESSAGE_READ; } else if (command.markAs === MessagesStatusEnum.UNREAD) { eventType = WebhookEventEnum.MESSAGE_UNREAD; } const webhookPromises = updatedMessages.map((message) => this.sendWebhookMessage.execute({ eventType: eventType, objectType: WebhookObjectTypeEnum.MESSAGE, payload: { object: messageWebhookMapper(message, command.subscriberId), }, organizationId: command.organizationId, environmentId: command.environmentId, environment, }) ); await Promise.all(webhookPromises); } const eventMessage = mapMarkMessageToWebSocketEvent(command.markAs); if (eventMessage !== undefined) { this.webSocketsQueueService.add({ name: 'sendMessage', data: { event: eventMessage, userId: subscriber._id, _environmentId: command.environmentId, contextKeys: [], }, groupId: subscriber._organizationId, }); } this.analyticsService.track( `Mark all messages as ${command.markAs}- [Notification Center]`, command.organizationId, { _organization: command.organizationId, _subscriberId: subscriber._id, feedIds: command.feedIdentifiers, markAs: command.markAs, } ); return updatedMessages.length; } } ================================================ FILE: apps/api/src/app/widgets/usecases/mark-message-as/mark-message-as.command.ts ================================================ import { IsArray, IsDefined } from 'class-validator'; import { EnvironmentWithSubscriber } from '../../../shared/commands/project.command'; export class MarkMessageAsCommand extends EnvironmentWithSubscriber { @IsArray() messageIds: string[]; @IsDefined() mark: { seen?: boolean; read?: boolean }; } export enum MarkEnum { SEEN = 'seen', READ = 'read', } ================================================ FILE: apps/api/src/app/widgets/usecases/mark-message-as/mark-message-as.usecase.ts ================================================ import { Injectable, NotFoundException } from '@nestjs/common'; import { AnalyticsService, buildMessageCountKey, buildSubscriberKey, CachedResponse, EventType, InvalidateCacheService, LogRepository, MessageInteractionService, MessageInteractionTrace, mapEventTypeToTitle, messageWebhookMapper, PinoLogger, SendWebhookMessage, StepType, WebSocketsQueueService, } from '@novu/application-generic'; import { MessageEntity, MessageRepository, SubscriberEntity, SubscriberRepository } from '@novu/dal'; import { DeliveryLifecycleStatusEnum, WebhookEventEnum, WebhookObjectTypeEnum, WebSocketEventEnum } from '@novu/shared'; import { MarkEnum, MarkMessageAsCommand } from './mark-message-as.command'; @Injectable() export class MarkMessageAs { constructor( private invalidateCache: InvalidateCacheService, private messageRepository: MessageRepository, private webSocketsQueueService: WebSocketsQueueService, private analyticsService: AnalyticsService, private subscriberRepository: SubscriberRepository, private messageInteractionService: MessageInteractionService, private logger: PinoLogger, private sendWebhookMessage: SendWebhookMessage ) { this.logger.setContext(this.constructor.name); } async execute(command: MarkMessageAsCommand): Promise { await this.invalidateCache.invalidateQuery({ key: buildMessageCountKey().invalidate({ subscriberId: command.subscriberId, _environmentId: command.environmentId, }), }); const subscriber = await this.fetchSubscriber({ _environmentId: command.environmentId, subscriberId: command.subscriberId, }); if (!subscriber) throw new NotFoundException(`Subscriber ${command.subscriberId} not found`); await this.messageRepository.changeStatus(command.environmentId, subscriber._id, command.messageIds, command.mark); const updatedMessages = await this.messageRepository.find({ _environmentId: command.environmentId, _id: { $in: command.messageIds, }, }); const allTraceData: MessageInteractionTrace[] = []; if (command.mark.seen != null) { await this.updateServices(command, subscriber, updatedMessages, MarkEnum.SEEN); allTraceData.push( ...this.prepareTrace( updatedMessages, command.mark.seen ? 'message_seen' : 'message_unseen', command.subscriberId ) ); if (command.mark.seen === true) { await this.sendWebhookForMessages( updatedMessages, WebhookEventEnum.MESSAGE_SEEN, command.organizationId, command.environmentId, command.subscriberId ); } } if (command.mark.read !== undefined || command.mark.read !== null) { await this.updateServices(command, subscriber, updatedMessages, MarkEnum.READ); allTraceData.push( ...this.prepareTrace( updatedMessages, command.mark.read ? 'message_read' : 'message_unread', command.subscriberId ) ); await this.sendWebhookForMessages( updatedMessages, command.mark.read ? WebhookEventEnum.MESSAGE_READ : WebhookEventEnum.MESSAGE_UNREAD, command.organizationId, command.environmentId, command.subscriberId ); } if (allTraceData.length > 0) { try { await this.messageInteractionService.trace(allTraceData, DeliveryLifecycleStatusEnum.INTERACTED); } catch (error) { this.logger.warn({ err: error }, `Failed to create engagement traces for ${allTraceData.length} traces`); } } return updatedMessages; } private prepareTrace(messages: MessageEntity[], eventType: EventType, userId: string): MessageInteractionTrace[] { const traceDataArray: MessageInteractionTrace[] = []; for (const message of messages) { if (message._jobId) { traceDataArray.push({ created_at: LogRepository.formatDateTime64(new Date()), organization_id: message._organizationId, environment_id: message._environmentId, user_id: userId, subscriber_id: message._subscriberId, event_type: eventType, title: mapEventTypeToTitle(eventType), message: `Message ${eventType.replace('message_', '')} for subscriber ${message._subscriberId}`, raw_data: '', status: 'success', entity_id: message._jobId, external_subscriber_id: message._subscriberId, step_run_type: message.channel as StepType, workflow_run_identifier: '', _notificationId: message._notificationId, workflow_id: message._templateId, provider_id: '', }); } } return traceDataArray; } private async updateServices(command: MarkMessageAsCommand, subscriber, messages, marked: MarkEnum) { this.updateSocketCount(subscriber, marked); for (const message of messages) { this.analyticsService.mixpanelTrack(`Mark as ${marked} - [Notification Center]`, '', { _subscriber: message._subscriberId, _organization: command.organizationId, _template: message._templateId, }); } } private updateSocketCount(subscriber: SubscriberEntity, mark: MarkEnum) { const eventMessage = mark === MarkEnum.READ ? WebSocketEventEnum.UNREAD : WebSocketEventEnum.UNSEEN; this.webSocketsQueueService.add({ name: 'sendMessage', data: { event: eventMessage, userId: subscriber._id, _environmentId: subscriber._environmentId, contextKeys: [], }, groupId: subscriber._organizationId, }); } private async sendWebhookForMessages( messages: MessageEntity[], eventType: WebhookEventEnum, organizationId: string, environmentId: string, subscriberId: string ): Promise { const webhookPromises = messages.map((message) => this.sendWebhookMessage.execute({ eventType: eventType, objectType: WebhookObjectTypeEnum.MESSAGE, payload: { object: messageWebhookMapper(message, subscriberId), }, organizationId: organizationId, environmentId: environmentId, }) ); await Promise.all(webhookPromises); } @CachedResponse({ builder: (command: { subscriberId: string; _environmentId: string }) => buildSubscriberKey({ _environmentId: command._environmentId, subscriberId: command.subscriberId, }), }) private async fetchSubscriber({ subscriberId, _environmentId, }: { subscriberId: string; _environmentId: string; }): Promise { return await this.subscriberRepository.findBySubscriberId(_environmentId, subscriberId); } } ================================================ FILE: apps/api/src/app/widgets/usecases/mark-message-as-by-mark/mark-message-as-by-mark.command.ts ================================================ import { MessagesStatusEnum } from '@novu/shared'; import { IsArray, IsDefined, IsEnum, IsNotEmpty, IsString } from 'class-validator'; import { EnvironmentWithSubscriber } from '../../../shared/commands/project.command'; export class MarkMessageAsByMarkCommand extends EnvironmentWithSubscriber { @IsArray() messageIds: string[]; @IsDefined() @IsEnum(MessagesStatusEnum) markAs: MessagesStatusEnum; @IsNotEmpty() @IsString() __source: 'notification_center' | 'api'; } ================================================ FILE: apps/api/src/app/widgets/usecases/mark-message-as-by-mark/mark-message-as-by-mark.usecase.ts ================================================ import { Injectable, NotFoundException, Optional } from '@nestjs/common'; import { AnalyticsService, buildFeedKey, buildMessageCountKey, buildSubscriberKey, CachedResponse, InvalidateCacheService, messageWebhookMapper, SendWebhookMessage, WebSocketsQueueService, } from '@novu/application-generic'; import { EnvironmentRepository, MessageEntity, MessageRepository, SubscriberEntity, SubscriberRepository, } from '@novu/dal'; import { MessagesStatusEnum, WebhookEventEnum, WebhookObjectTypeEnum } from '@novu/shared'; import { mapMarkMessageToWebSocketEvent } from '../../../shared/helpers'; import { MessageResponseDto } from '../../dtos/message-response.dto'; import { MarkMessageAsByMarkCommand } from './mark-message-as-by-mark.command'; @Injectable() export class MarkMessageAsByMark { constructor( private invalidateCache: InvalidateCacheService, private messageRepository: MessageRepository, private webSocketsQueueService: WebSocketsQueueService, private analyticsService: AnalyticsService, private subscriberRepository: SubscriberRepository, private sendWebhookMessage: SendWebhookMessage, private environmentRepository: EnvironmentRepository ) {} async execute(command: MarkMessageAsByMarkCommand): Promise { const subscriber = await this.fetchSubscriber({ _environmentId: command.environmentId, subscriberId: command.subscriberId, }); if (!subscriber) throw new NotFoundException(`Subscriber ${command.subscriberId} not found`); const environment = await this.environmentRepository.findOne( { _id: command.environmentId, }, 'webhookAppId identifier' ); if (!environment) { throw new Error(`Environment not found for id ${command.environmentId}`); } await this.invalidateCache.invalidateQuery({ key: buildMessageCountKey().invalidate({ subscriberId: command.subscriberId, _environmentId: command.environmentId, }), }); const updatedMessages = await this.messageRepository.changeMessagesStatus({ environmentId: command.environmentId, subscriberId: subscriber._id, messageIds: command.messageIds, markAs: command.markAs, }); await this.updateServices(command, subscriber, updatedMessages, command.markAs); if (command.markAs !== MessagesStatusEnum.UNSEEN) { let eventType = WebhookEventEnum.MESSAGE_SEEN; if (command.markAs === MessagesStatusEnum.READ) { eventType = WebhookEventEnum.MESSAGE_READ; } else if (command.markAs === MessagesStatusEnum.UNREAD) { eventType = WebhookEventEnum.MESSAGE_UNREAD; } const webhookPromises = updatedMessages.map((message) => this.sendWebhookMessage.execute({ eventType: eventType, objectType: WebhookObjectTypeEnum.MESSAGE, payload: { object: messageWebhookMapper(message, command.subscriberId), }, organizationId: command.organizationId, environmentId: command.environmentId, environment, }) ); await Promise.all(webhookPromises); } return updatedMessages.map(mapMessageEntityToResponseDto); } private async updateServices(command: MarkMessageAsByMarkCommand, subscriber, messages, markAs: MessagesStatusEnum) { this.updateSocketCount(subscriber, markAs); const analyticMessage = command.__source === 'notification_center' ? `Mark as ${markAs} - [Notification Center]` : `Mark as ${markAs} - [API]`; for (const message of messages) { this.analyticsService.mixpanelTrack(analyticMessage, '', { _subscriber: message._subscriberId, _organization: command.organizationId, _template: message._templateId, }); } } private updateSocketCount(subscriber: SubscriberEntity, markAs: MessagesStatusEnum) { const eventMessage = mapMarkMessageToWebSocketEvent(markAs); if (eventMessage === undefined) { return; } this.webSocketsQueueService.add({ name: 'sendMessage', data: { event: eventMessage, userId: subscriber._id, _environmentId: subscriber._environmentId, contextKeys: [], }, groupId: subscriber._organizationId, }); } @CachedResponse({ builder: (command: { subscriberId: string; _environmentId: string }) => buildSubscriberKey({ _environmentId: command._environmentId, subscriberId: command.subscriberId, }), }) private async fetchSubscriber({ subscriberId, _environmentId, }: { subscriberId: string; _environmentId: string; }): Promise { return await this.subscriberRepository.findBySubscriberId(_environmentId, subscriberId); } } export function mapMessageEntityToResponseDto(entity: MessageEntity): MessageResponseDto { const responseDto = new MessageResponseDto(); responseDto._id = entity._id; responseDto._templateId = entity._templateId; responseDto._environmentId = entity._environmentId; responseDto._messageTemplateId = entity._messageTemplateId; responseDto._organizationId = entity._organizationId; responseDto._notificationId = entity._notificationId; responseDto._subscriberId = entity._subscriberId; responseDto.templateIdentifier = entity.templateIdentifier; responseDto.createdAt = entity.createdAt; responseDto.lastSeenDate = entity.lastSeenDate; responseDto.lastReadDate = entity.lastReadDate; responseDto.content = entity.content; // Assuming content can be directly assigned responseDto.transactionId = entity.transactionId; responseDto.subject = entity.subject; responseDto.channel = entity.channel; responseDto.read = entity.read; responseDto.seen = entity.seen; responseDto.snoozedUntil = entity.snoozedUntil; responseDto.deliveredAt = entity.deliveredAt; // snoozed notifications can have multiple delivery dates responseDto.email = entity.email; responseDto.phone = entity.phone; responseDto.directWebhookUrl = entity.directWebhookUrl; responseDto.providerId = entity.providerId; responseDto.deviceTokens = entity.deviceTokens; responseDto.title = entity.title; responseDto.cta = entity.cta; // Assuming cta can be directly assigned responseDto._feedId = entity._feedId ?? null; // Handle optional _feedId responseDto.status = entity.status; responseDto.errorId = entity.errorId; responseDto.errorText = entity.errorText; responseDto.payload = entity.payload; responseDto.overrides = entity.overrides; return responseDto; } ================================================ FILE: apps/api/src/app/widgets/usecases/remove-message/remove-message.command.ts ================================================ import { IsMongoId } from 'class-validator'; import { EnvironmentWithSubscriber } from '../../../shared/commands/project.command'; export class RemoveMessageCommand extends EnvironmentWithSubscriber { @IsMongoId() messageId: string; } ================================================ FILE: apps/api/src/app/widgets/usecases/remove-message/remove-message.usecase.ts ================================================ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { AnalyticsService, buildFeedKey, buildMessageCountKey, InvalidateCacheService, WebSocketsQueueService, } from '@novu/application-generic'; import { DalException, MessageRepository, SubscriberEntity, SubscriberRepository } from '@novu/dal'; import { WebSocketEventEnum } from '@novu/shared'; import { MarkEnum } from '../mark-message-as/mark-message-as.command'; import { RemoveMessageCommand } from './remove-message.command'; @Injectable() export class RemoveMessage { constructor( private invalidateCache: InvalidateCacheService, private messageRepository: MessageRepository, private webSocketsQueueService: WebSocketsQueueService, private analyticsService: AnalyticsService, private subscriberRepository: SubscriberRepository ) {} async execute(command: RemoveMessageCommand): Promise { await this.invalidateCache.invalidateQuery({ key: buildMessageCountKey().invalidate({ subscriberId: command.subscriberId, _environmentId: command.environmentId, }), }); const subscriber = await this.subscriberRepository.findBySubscriberId(command.environmentId, command.subscriberId); if (!subscriber) throw new NotFoundException(`Subscriber ${command.subscriberId} not found`); try { const deletedMessage = await this.messageRepository.delete({ _environmentId: command.environmentId, _organizationId: command.organizationId, _id: command.messageId, _subscriberId: subscriber._id, }); if (deletedMessage.deletedCount) { await Promise.all([ this.updateServices(command, subscriber, command.messageId, MarkEnum.READ), this.updateServices(command, subscriber, command.messageId, MarkEnum.SEEN), ]); } } catch (e) { if (e instanceof DalException) { throw new BadRequestException(e.message); } throw e; } } private async updateServices(command: RemoveMessageCommand, subscriber, message, marked: MarkEnum) { await this.updateSocketCount(subscriber, marked); this.analyticsService.track(`Removed Message - [Notification Center]`, command.organizationId, { _subscriber: message._subscriberId, _organization: command.organizationId, _template: message._templateId, }); } private async updateSocketCount(subscriber: SubscriberEntity, mark: MarkEnum) { const eventMessage = mark === MarkEnum.READ ? WebSocketEventEnum.UNREAD : WebSocketEventEnum.UNSEEN; await this.webSocketsQueueService.add({ name: 'sendMessage', data: { event: eventMessage, userId: subscriber._id, _environmentId: subscriber._environmentId, contextKeys: [], }, groupId: subscriber._organizationId, }); } } ================================================ FILE: apps/api/src/app/widgets/usecases/remove-messages/remove-all-messages.command.ts ================================================ import { IsOptional, IsString } from 'class-validator'; import { EnvironmentWithSubscriber } from '../../../shared/commands/project.command'; export class RemoveAllMessagesCommand extends EnvironmentWithSubscriber { @IsString() @IsOptional() feedId?: string; } ================================================ FILE: apps/api/src/app/widgets/usecases/remove-messages/remove-all-messages.usecase.ts ================================================ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { AnalyticsService, buildFeedKey, buildMessageCountKey, InvalidateCacheService, WebSocketsQueueService, } from '@novu/application-generic'; import { DalException, EnforceEnvId, FeedRepository, MessageEntity, MessageRepository, SubscriberEntity, SubscriberRepository, } from '@novu/dal'; import { ChannelTypeEnum, WebSocketEventEnum } from '@novu/shared'; import { MarkEnum } from '../mark-message-as/mark-message-as.command'; import { RemoveAllMessagesCommand } from './remove-all-messages.command'; @Injectable() export class RemoveAllMessages { constructor( private invalidateCache: InvalidateCacheService, private messageRepository: MessageRepository, private webSocketsQueueService: WebSocketsQueueService, private analyticsService: AnalyticsService, private subscriberRepository: SubscriberRepository, private feedRepository: FeedRepository ) {} async execute(command: RemoveAllMessagesCommand): Promise { const subscriber = await this.subscriberRepository.findBySubscriberId(command.environmentId, command.subscriberId); if (!subscriber) throw new NotFoundException(`Subscriber ${command.subscriberId} not found`); try { let feed; if (command.feedId) { feed = await this.feedRepository.findOne({ _id: command.feedId, _organizationId: command.organizationId }); if (!feed) { throw new NotFoundException(`Feed with ${command.feedId} not found`); } } const deleteMessageQuery: Partial & EnforceEnvId = { _environmentId: command.environmentId, _organizationId: command.organizationId, _subscriberId: subscriber._id, channel: ChannelTypeEnum.IN_APP, }; if (feed) { deleteMessageQuery._feedId = feed._id; } const deletedMessages = await this.messageRepository.delete(deleteMessageQuery); if (deletedMessages.deletedCount > 0) { await Promise.all([ this.updateServices(command, subscriber, MarkEnum.SEEN), this.updateServices(command, subscriber, MarkEnum.READ), this.invalidateCache.invalidateQuery({ key: buildMessageCountKey().invalidate({ subscriberId: command.subscriberId, _environmentId: command.environmentId, }), }), ]); } this.analyticsService.track(`Removed All Feed Messages - [Notification Center]`, command.organizationId, { _subscriber: subscriber._id, _organization: command.organizationId, _environment: command.environmentId, _feedId: command.feedId, }); } catch (e) { if (e instanceof DalException) { throw new BadRequestException(e.message); } throw e; } } private async updateServices(command: RemoveAllMessagesCommand, subscriber, marked: string): Promise { await this.updateSocketCount(subscriber, marked); } private async updateSocketCount(subscriber: SubscriberEntity, mark: string): Promise { const eventMessage = mark === MarkEnum.READ ? WebSocketEventEnum.UNREAD : WebSocketEventEnum.UNSEEN; await this.webSocketsQueueService.add({ name: 'sendMessage', data: { event: eventMessage, userId: subscriber._id, _environmentId: subscriber._environmentId, contextKeys: [], }, groupId: subscriber._organizationId, }); } } ================================================ FILE: apps/api/src/app/widgets/usecases/remove-messages-bulk/remove-messages-bulk.command.ts ================================================ import { ArrayMaxSize, ArrayNotEmpty, IsArray, IsMongoId } from 'class-validator'; import { EnvironmentWithSubscriber } from '../../../shared/commands/project.command'; export class RemoveMessagesBulkCommand extends EnvironmentWithSubscriber { @IsArray() @ArrayNotEmpty() @IsMongoId({ each: true }) @ArrayMaxSize(100) messageIds: string[]; } ================================================ FILE: apps/api/src/app/widgets/usecases/remove-messages-bulk/remove-messages-bulk.usecase.ts ================================================ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { AnalyticsService, buildFeedKey, buildMessageCountKey, InvalidateCacheService, WebSocketsQueueService, } from '@novu/application-generic'; import { DalException, MessageRepository, SubscriberEntity, SubscriberRepository } from '@novu/dal'; import { ChannelTypeEnum, WebSocketEventEnum } from '@novu/shared'; import { MarkEnum } from '../mark-message-as/mark-message-as.command'; import { RemoveMessagesBulkCommand } from './remove-messages-bulk.command'; @Injectable() export class RemoveMessagesBulk { constructor( private invalidateCache: InvalidateCacheService, private messageRepository: MessageRepository, private webSocketsQueueService: WebSocketsQueueService, private analyticsService: AnalyticsService, private subscriberRepository: SubscriberRepository ) {} async execute(command: RemoveMessagesBulkCommand): Promise { const subscriber = await this.subscriberRepository.findBySubscriberId(command.environmentId, command.subscriberId); if (!subscriber) throw new NotFoundException(`Subscriber ${command.subscriberId} not found`); try { const deletedMessages = await this.messageRepository.delete({ _environmentId: command.environmentId, _organizationId: command.organizationId, _subscriberId: subscriber._id, channel: ChannelTypeEnum.IN_APP, _id: { $in: command.messageIds }, }); if (deletedMessages.deletedCount > 0) { await Promise.all([ this.updateServices(subscriber, MarkEnum.SEEN), this.updateServices(subscriber, MarkEnum.READ), this.invalidateCache.invalidateQuery({ key: buildMessageCountKey().invalidate({ subscriberId: command.subscriberId, _environmentId: command.environmentId, }), }), ]); } } catch (e) { if (e instanceof DalException) { throw new BadRequestException(e.message); } throw e; } } private async updateServices(subscriber: SubscriberEntity, marked: MarkEnum): Promise { const eventMessage = marked === MarkEnum.READ ? WebSocketEventEnum.UNREAD : WebSocketEventEnum.UNSEEN; await this.webSocketsQueueService.add({ name: 'sendMessage', data: { event: eventMessage, userId: subscriber._id, _environmentId: subscriber._environmentId, contextKeys: [], }, groupId: subscriber._organizationId, }); } } ================================================ FILE: apps/api/src/app/widgets/widgets.controller.ts ================================================ import { BadRequestException, Body, Controller, DefaultValuePipe, Delete, Get, HttpCode, HttpStatus, NotFoundException, Param, Patch, Post, Query, UseGuards, } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { ApiExcludeController, ApiOperation, ApiQuery } from '@nestjs/swagger'; import { AnalyticsService } from '@novu/application-generic'; import { MessageEntity, BaseRepository } from '@novu/dal'; import { ButtonTypeEnum, IPreferenceChannels, MessageActionStatusEnum, MessagesStatusEnum, PreferenceLevelEnum, TriggerTypeEnum, WorkflowCriticalityEnum, } from '@novu/shared'; import { UpdatePreferencesCommand } from '../inbox/usecases/update-preferences/update-preferences.command'; import { UpdatePreferences } from '../inbox/usecases/update-preferences/update-preferences.usecase'; import { ExcludeFromIdempotency } from '../shared/framework/exclude-from-idempotency'; import { ApiCommonResponses, ApiNoContentResponse } from '../shared/framework/response.decorator'; import { SubscriberSession } from '../shared/framework/user.decorator'; import { UpdateSubscriberGlobalPreferencesRequestDto } from '../subscribers/dtos/update-subscriber-global-preferences-request.dto'; import { GetPreferencesByLevelCommand } from '../subscribers/usecases/get-preferences-by-level/get-preferences-by-level.command'; import { GetPreferencesByLevel } from '../subscribers/usecases/get-preferences-by-level/get-preferences-by-level.usecase'; import { GetSubscriberPreference, GetSubscriberPreferenceCommand, } from '../subscribers/usecases/get-subscriber-preference'; import { GetNotificationsFeedDto } from './dtos/get-notifications-feed-request.dto'; import { LogUsageRequestDto } from './dtos/log-usage-request.dto'; import { LogUsageResponseDto } from './dtos/log-usage-response.dto'; import { MessageMarkAsRequestDto } from './dtos/mark-as-request.dto'; import { MessageResponseDto } from './dtos/message-response.dto'; import { OrganizationResponseDto } from './dtos/organization-response.dto'; import { RemoveAllMessagesDto } from './dtos/remove-all-messages.dto'; import { RemoveMessagesBulkRequestDto } from './dtos/remove-messages-bulk-request.dto'; import { SessionInitializeRequestDto } from './dtos/session-initialize-request.dto'; import { SessionInitializeResponseDto } from './dtos/session-initialize-response.dto'; import { UnseenCountResponse } from './dtos/unseen-count-response.dto'; import { UpdateSubscriberPreferenceRequestDto } from './dtos/update-subscriber-preference-request.dto'; import { UpdateSubscriberPreferenceResponseDto } from './dtos/update-subscriber-preference-response.dto'; import { LimitPipe } from './pipes/limit-pipe/limit-pipe'; import { GetCountQuery } from './queries/get-count.query'; import { GetFeedCountCommand } from './usecases/get-feed-count/get-feed-count.command'; import { GetFeedCount } from './usecases/get-feed-count/get-feed-count.usecase'; import { GetNotificationsFeedCommand } from './usecases/get-notifications-feed/get-notifications-feed.command'; import { GetNotificationsFeed } from './usecases/get-notifications-feed/get-notifications-feed.usecase'; import { GetOrganizationDataCommand } from './usecases/get-organization-data/get-organization-data.command'; import { GetOrganizationData } from './usecases/get-organization-data/get-organization-data.usecase'; import { InitializeSessionCommand } from './usecases/initialize-session/initialize-session.command'; import { InitializeSession } from './usecases/initialize-session/initialize-session.usecase'; import { UpdateMessageActionsCommand } from './usecases/mark-action-as-done/update-message-actions.command'; import { UpdateMessageActions } from './usecases/mark-action-as-done/update-message-actions.usecase'; import { MarkAllMessagesAsCommand } from './usecases/mark-all-messages-as/mark-all-messages-as.command'; import { MarkAllMessagesAs } from './usecases/mark-all-messages-as/mark-all-messages-as.usecase'; import { MarkMessageAsCommand } from './usecases/mark-message-as/mark-message-as.command'; import { MarkMessageAs } from './usecases/mark-message-as/mark-message-as.usecase'; import { MarkMessageAsByMarkCommand } from './usecases/mark-message-as-by-mark/mark-message-as-by-mark.command'; import { MarkMessageAsByMark } from './usecases/mark-message-as-by-mark/mark-message-as-by-mark.usecase'; import { RemoveMessageCommand } from './usecases/remove-message/remove-message.command'; import { RemoveMessage } from './usecases/remove-message/remove-message.usecase'; import { RemoveAllMessagesCommand } from './usecases/remove-messages/remove-all-messages.command'; import { RemoveAllMessages } from './usecases/remove-messages/remove-all-messages.usecase'; import { RemoveMessagesBulkCommand } from './usecases/remove-messages-bulk/remove-messages-bulk.command'; import { RemoveMessagesBulk } from './usecases/remove-messages-bulk/remove-messages-bulk.usecase'; @ApiCommonResponses() @Controller('/widgets') @ApiExcludeController() export class WidgetsController { constructor( private initializeSessionUsecase: InitializeSession, private getNotificationsFeedUsecase: GetNotificationsFeed, private getFeedCountUsecase: GetFeedCount, private markMessageAsUsecase: MarkMessageAs, private markMessageAsByMarkUsecase: MarkMessageAsByMark, private removeMessageUsecase: RemoveMessage, private removeAllMessagesUsecase: RemoveAllMessages, private removeMessagesBulkUsecase: RemoveMessagesBulk, private updateMessageActionsUsecase: UpdateMessageActions, private getOrganizationUsecase: GetOrganizationData, private getSubscriberPreferenceUsecase: GetSubscriberPreference, private getSubscriberPreferenceByLevelUsecase: GetPreferencesByLevel, private updatePreferencesUsecase: UpdatePreferences, private markAllMessagesAsUsecase: MarkAllMessagesAs, private analyticsService: AnalyticsService ) {} @ExcludeFromIdempotency() @Post('/session/initialize') async sessionInitialize(@Body() body: SessionInitializeRequestDto): Promise { return await this.initializeSessionUsecase.execute( InitializeSessionCommand.create({ subscriberId: body.subscriberId, applicationIdentifier: body.applicationIdentifier, email: body.email, firstName: body.firstName, lastName: body.lastName, phone: body.phone, hmacHash: body.hmacHash, }) ); } @UseGuards(AuthGuard('subscriberJwt')) @Get('/notifications/feed') @ApiQuery({ name: 'seen', type: Boolean, required: false, }) async getNotificationsFeed( @SubscriberSession() subscriberSession: SubscriberSession, @Query() query: GetNotificationsFeedDto ) { let feedsQuery: string[] | undefined; if (query.feedIdentifier) { feedsQuery = Array.isArray(query.feedIdentifier) ? query.feedIdentifier : [query.feedIdentifier]; } const command = GetNotificationsFeedCommand.create({ organizationId: subscriberSession._organizationId, subscriberId: subscriberSession.subscriberId, environmentId: subscriberSession._environmentId, page: query.page, feedId: feedsQuery, query: { seen: query.seen, read: query.read }, limit: query.limit, payload: query.payload, }); return await this.getNotificationsFeedUsecase.execute(command); } @UseGuards(AuthGuard('subscriberJwt')) @Get('/notifications/unseen') async getUnseenCount( @SubscriberSession() subscriberSession: SubscriberSession, @Query('feedIdentifier') feedId: string[] | string, @Query('seen') seen: boolean | string, @Query('limit', new DefaultValuePipe(100), new LimitPipe(1, 100, true)) limit: number ): Promise { const feedsQuery = this.toArray(feedId); const parsedSeen = seen === undefined ? false : seen === 'true' || seen === true; return await this.getFeedCountUsecase.execute( GetFeedCountCommand.create({ organizationId: subscriberSession._organizationId, subscriberId: subscriberSession.subscriberId, environmentId: subscriberSession._environmentId, feedId: feedsQuery, seen: parsedSeen, limit, }) ); } @UseGuards(AuthGuard('subscriberJwt')) @Get('/notifications/unread') async getUnreadCount( @SubscriberSession() subscriberSession: SubscriberSession, @Query('feedIdentifier') feedId: string[] | string, @Query('read') read: boolean | string, @Query('limit', new DefaultValuePipe(100), new LimitPipe(1, 100, true)) limit: number ): Promise { const feedsQuery = this.toArray(feedId); const parsedRead = read === undefined ? false : read === 'true' || read === true; return await this.getFeedCountUsecase.execute( GetFeedCountCommand.create({ organizationId: subscriberSession._organizationId, subscriberId: subscriberSession.subscriberId, environmentId: subscriberSession._environmentId, feedId: feedsQuery, read: parsedRead, limit, }) ); } @UseGuards(AuthGuard('subscriberJwt')) @Get('/notifications/count') async getCount( @SubscriberSession() subscriberSession: SubscriberSession, @Query() query: GetCountQuery, @Query('limit', new DefaultValuePipe(100), new LimitPipe(1, 100, true)) limit: number ): Promise { const feedsQuery = this.toArray(query.feedIdentifier); if (query.seen === undefined && query.read === undefined) { query.seen = false; } const command = GetFeedCountCommand.create({ organizationId: subscriberSession._organizationId, subscriberId: subscriberSession.subscriberId, environmentId: subscriberSession._environmentId, feedId: feedsQuery, seen: query.seen, read: query.read, limit, }); return await this.getFeedCountUsecase.execute(command); } @ApiOperation({ summary: 'Mark a subscriber feed messages as seen or as read', description: `Introducing '/messages/mark-as endpoint for consistent read and seen message handling, deprecating old legacy endpoint.`, deprecated: true, }) @UseGuards(AuthGuard('subscriberJwt')) @Post('/messages/markAs') async markMessageAs( @SubscriberSession() subscriberSession: SubscriberSession, @Body() body: { messageId: string | string[]; mark: { seen?: boolean; read?: boolean } } ): Promise { const messageIds = this.toArray(body.messageId); if (!messageIds) throw new BadRequestException('messageId is required'); const invalidIds = messageIds.filter((id) => !BaseRepository.isInternalId(id)); if (invalidIds.length > 0) { throw new BadRequestException(`Invalid messageId format: ${invalidIds.join(', ')}`); } return await this.markMessageAsUsecase.execute( MarkMessageAsCommand.create({ organizationId: subscriberSession._organizationId, subscriberId: subscriberSession.subscriberId, environmentId: subscriberSession._environmentId, messageIds, mark: body.mark, }) ); } @ApiOperation({ summary: 'Mark a subscriber messages as seen, read, unseen or unread', }) @UseGuards(AuthGuard('subscriberJwt')) @Post('/messages/mark-as') async markMessagesAs( @SubscriberSession() subscriberSession: SubscriberSession, @Body() body: MessageMarkAsRequestDto ): Promise { const messageIds = this.toArray(body.messageId); if (!messageIds || messageIds.length === 0) throw new BadRequestException('messageId is required'); return await this.markMessageAsByMarkUsecase.execute( MarkMessageAsByMarkCommand.create({ organizationId: subscriberSession._organizationId, subscriberId: subscriberSession.subscriberId, environmentId: subscriberSession._environmentId, messageIds, markAs: body.markAs, __source: 'notification_center', }) ); } @ApiOperation({ summary: 'Remove a subscriber feed message', }) @UseGuards(AuthGuard('subscriberJwt')) @Delete('/messages/:messageId') async removeMessage( @SubscriberSession() subscriberSession: SubscriberSession, @Param('messageId') messageId: string ): Promise { if (!messageId || !BaseRepository.isInternalId(messageId)) { throw new BadRequestException('messageId must be a valid MongoDB ObjectId'); } const command = RemoveMessageCommand.create({ organizationId: subscriberSession._organizationId, subscriberId: subscriberSession.subscriberId, environmentId: subscriberSession._environmentId, messageId, }); return await this.removeMessageUsecase.execute(command); } @ApiOperation({ summary: `Remove a subscriber's feed messages`, }) @UseGuards(AuthGuard('subscriberJwt')) @Delete('/messages') @ApiNoContentResponse({ description: 'Messages removed' }) @HttpCode(HttpStatus.NO_CONTENT) async removeAllMessages( @SubscriberSession() subscriberSession: SubscriberSession, @Query() query: RemoveAllMessagesDto ): Promise { const command = RemoveAllMessagesCommand.create({ organizationId: subscriberSession._organizationId, subscriberId: subscriberSession.subscriberId, environmentId: subscriberSession._environmentId, feedId: query.feedId, }); await this.removeAllMessagesUsecase.execute(command); } @ApiOperation({ summary: 'Remove subscriber messages in bulk', }) @UseGuards(AuthGuard('subscriberJwt')) @Post('/messages/bulk/delete') @HttpCode(HttpStatus.OK) async removeMessagesBulk( @SubscriberSession() subscriberSession: SubscriberSession, @Body() body: RemoveMessagesBulkRequestDto ) { return await this.removeMessagesBulkUsecase.execute( RemoveMessagesBulkCommand.create({ organizationId: subscriberSession._organizationId, subscriberId: subscriberSession.subscriberId, environmentId: subscriberSession._environmentId, messageIds: body.messageIds, }) ); } @ApiOperation({ summary: "Mark subscriber's all unread messages as read", }) @UseGuards(AuthGuard('subscriberJwt')) @Post('/messages/read') async markAllUnreadAsRead( @SubscriberSession() subscriberSession: SubscriberSession, @Body() body: { feedId?: string | string[] } ) { const feedIds = this.toArray(body.feedId); return await this.markAllMessagesAsUsecase.execute( MarkAllMessagesAsCommand.create({ organizationId: subscriberSession._organizationId, subscriberId: subscriberSession.subscriberId, environmentId: subscriberSession._environmentId, markAs: MessagesStatusEnum.READ, feedIdentifiers: feedIds, }) ); } @ApiOperation({ summary: "Mark subscriber's all unseen messages as seen", }) @UseGuards(AuthGuard('subscriberJwt')) @Post('/messages/seen') async markAllUnseenAsSeen( @SubscriberSession() subscriberSession: SubscriberSession, @Body() body: { feedId?: string | string[] } ): Promise { const feedIds = this.toArray(body.feedId); return await this.markAllMessagesAsUsecase.execute( MarkAllMessagesAsCommand.create({ organizationId: subscriberSession._organizationId, subscriberId: subscriberSession.subscriberId, environmentId: subscriberSession._environmentId, markAs: MessagesStatusEnum.SEEN, feedIdentifiers: feedIds, }) ); } @UseGuards(AuthGuard('subscriberJwt')) @Post('/messages/:messageId/actions/:type') async markActionAsSeen( @SubscriberSession() subscriberSession: SubscriberSession, @Param('messageId') messageId: string, @Param('type') type: ButtonTypeEnum, @Body() body: { payload: any; status: MessageActionStatusEnum } ): Promise { return await this.updateMessageActionsUsecase.execute( UpdateMessageActionsCommand.create({ organizationId: subscriberSession._organizationId, subscriberId: subscriberSession.subscriberId, environmentId: subscriberSession._environmentId, messageId, type, payload: body.payload, status: body.status, }) ); } @UseGuards(AuthGuard('subscriberJwt')) @Get('/organization') async getOrganizationData( @SubscriberSession() subscriberSession: SubscriberSession ): Promise { const command = GetOrganizationDataCommand.create({ organizationId: subscriberSession._organizationId, subscriberId: subscriberSession._id, environmentId: subscriberSession._environmentId, }); return await this.getOrganizationUsecase.execute(command); } @UseGuards(AuthGuard('subscriberJwt')) @Get('/preferences') async getSubscriberPreference(@SubscriberSession() subscriberSession: SubscriberSession) { const command = GetSubscriberPreferenceCommand.create({ organizationId: subscriberSession._organizationId, subscriberId: subscriberSession.subscriberId, environmentId: subscriberSession._environmentId, includeInactiveChannels: false, criticality: WorkflowCriticalityEnum.NON_CRITICAL, }); return await this.getSubscriberPreferenceUsecase.execute(command); } @UseGuards(AuthGuard('subscriberJwt')) @Get('/preferences/:level') async getSubscriberPreferenceByLevel( @SubscriberSession() subscriberSession: SubscriberSession, @Param('level') level: PreferenceLevelEnum ) { const command = GetPreferencesByLevelCommand.create({ organizationId: subscriberSession._organizationId, subscriberId: subscriberSession.subscriberId, environmentId: subscriberSession._environmentId, includeInactiveChannels: false, level, }); return await this.getSubscriberPreferenceByLevelUsecase.execute(command); } @UseGuards(AuthGuard('subscriberJwt')) @Patch('/preferences/:templateId') async updateSubscriberPreference( @SubscriberSession() subscriberSession: SubscriberSession, @Param('templateId') templateId: string, @Body() body: UpdateSubscriberPreferenceRequestDto ): Promise { const result = await this.updatePreferencesUsecase.execute( UpdatePreferencesCommand.create({ environmentId: subscriberSession._environmentId, organizationId: subscriberSession._organizationId, subscriberId: subscriberSession.subscriberId, workflowIdOrIdentifier: templateId, level: PreferenceLevelEnum.TEMPLATE, includeInactiveChannels: false, ...(body.channel && { [body.channel.type]: body.channel.enabled }), }) ); if (!result.workflow) throw new NotFoundException('Workflow not found'); return { preference: { channels: result.channels, enabled: result.enabled, }, template: { _id: result.workflow.id, name: result.workflow.name, critical: result.workflow.critical, tags: result.workflow.tags, data: result.workflow.data, triggers: [ { identifier: result.workflow.identifier, type: TriggerTypeEnum.EVENT, variables: [], }, ], }, }; } @UseGuards(AuthGuard('subscriberJwt')) @Patch('/preferences') async updateSubscriberGlobalPreference( @SubscriberSession() subscriberSession: SubscriberSession, @Body() body: UpdateSubscriberGlobalPreferencesRequestDto ) { const channels = body.preferences?.reduce((acc, curr) => { acc[curr.type] = curr.enabled; return acc; }, {} as IPreferenceChannels); const result = await this.updatePreferencesUsecase.execute( UpdatePreferencesCommand.create({ environmentId: subscriberSession._environmentId, organizationId: subscriberSession._organizationId, subscriberId: subscriberSession.subscriberId, level: PreferenceLevelEnum.GLOBAL, includeInactiveChannels: false, ...channels, }) ); return { preference: { channels: result.channels, enabled: result.enabled, }, }; } @UseGuards(AuthGuard('subscriberJwt')) @Post('/usage/log') async logUsage( @SubscriberSession() subscriberSession: SubscriberSession, @Body() body: LogUsageRequestDto ): Promise { this.analyticsService.track(body.name, subscriberSession._organizationId, { environmentId: subscriberSession._environmentId, _organization: subscriberSession._organizationId, ...(body.payload || {}), }); return { success: true, }; } private toArray(param: string[] | string | undefined): string[] | undefined { let paramArray: string[] | undefined; if (param) { paramArray = Array.isArray(param) ? param : String(param).split(','); } return paramArray as string[]; } } ================================================ FILE: apps/api/src/app/widgets/widgets.module.ts ================================================ import { forwardRef, Module } from '@nestjs/common'; import { CommunityOrganizationRepository } from '@novu/dal'; import { AuthModule } from '../auth/auth.module'; import { IntegrationModule } from '../integrations/integrations.module'; import { OutboundWebhooksModule } from '../outbound-webhooks/outbound-webhooks.module'; import { SharedModule } from '../shared/shared.module'; import { SubscribersV1Module } from '../subscribers/subscribersV1.module'; import { USE_CASES } from './usecases'; import { WidgetsController } from './widgets.controller'; @Module({ imports: [ SharedModule, forwardRef(() => SubscribersV1Module), AuthModule, IntegrationModule, OutboundWebhooksModule.forRoot(), ], providers: [...USE_CASES, CommunityOrganizationRepository], exports: [...USE_CASES], controllers: [WidgetsController], }) export class WidgetsModule {} ================================================ FILE: apps/api/src/app/workflow-overrides/dtos/create-workflow-override-request.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ICreateWorkflowOverrideRequestDto } from '@novu/shared'; import { Type } from 'class-transformer'; import { IsBoolean, IsDefined, IsOptional, IsString, ValidateNested } from 'class-validator'; import { SubscriberPreferenceChannels } from '../../shared/dtos/preference-channels'; export class CreateWorkflowOverrideRequestDto implements ICreateWorkflowOverrideRequestDto { @ApiProperty() @IsString() @IsDefined() workflowId: string; @ApiProperty() @IsString() @IsDefined() tenantId: string; @ApiPropertyOptional() @IsBoolean() @IsOptional() active?: boolean; @ApiPropertyOptional({ type: SubscriberPreferenceChannels, }) @IsOptional() @ValidateNested() @Type(() => SubscriberPreferenceChannels) preferenceSettings?: SubscriberPreferenceChannels; } ================================================ FILE: apps/api/src/app/workflow-overrides/dtos/create-workflow-override-response.dto.ts ================================================ import { ICreateWorkflowOverrideResponseDto } from '@novu/shared'; import { OverrideResponseDto } from './shared'; export class CreateWorkflowOverrideResponseDto extends OverrideResponseDto implements ICreateWorkflowOverrideResponseDto {} ================================================ FILE: apps/api/src/app/workflow-overrides/dtos/get-workflow-override-response.dto.ts ================================================ import { IWorkflowOverrideResponseDto } from '@novu/shared'; import { OverrideResponseDto } from './shared'; export class GetWorkflowOverrideResponseDto extends OverrideResponseDto implements IWorkflowOverrideResponseDto {} ================================================ FILE: apps/api/src/app/workflow-overrides/dtos/get-workflow-overrides-request.dto.ts ================================================ import { PaginationRequestDto } from '../../shared/dtos/pagination-request'; export class GetWorkflowOverridesRequestDto extends PaginationRequestDto(10, 100) {} ================================================ FILE: apps/api/src/app/workflow-overrides/dtos/get-workflow-overrides-response.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { IWorkflowOverridesResponseDto } from '@novu/shared'; import { OverrideResponseDto } from './shared'; export class GetWorkflowOverridesResponseDto implements IWorkflowOverridesResponseDto { @ApiProperty() hasMore: boolean; @ApiProperty() data: OverrideResponseDto[]; @ApiProperty() pageSize: number; @ApiProperty() page: number; } ================================================ FILE: apps/api/src/app/workflow-overrides/dtos/index.ts ================================================ export * from './create-workflow-override-request.dto'; export * from './create-workflow-override-response.dto'; export * from './get-workflow-override-response.dto'; export * from './get-workflow-overrides-request.dto'; export * from './get-workflow-overrides-response.dto'; export * from './update-workflow-override-request.dto'; export * from './update-workflow-override-response.dto'; ================================================ FILE: apps/api/src/app/workflow-overrides/dtos/shared.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { EnvironmentId, IPreferenceChannels, IWorkflowOverride, OrganizationId, WorkflowOverrideId, } from '@novu/shared'; import { SubscriberPreferenceChannels } from '../../shared/dtos/preference-channels'; export class OverrideResponseDto implements IWorkflowOverride { @ApiProperty() _id: WorkflowOverrideId; @ApiProperty() _organizationId: OrganizationId; @ApiProperty() _environmentId: EnvironmentId; @ApiProperty() _workflowId: string; @ApiProperty() _tenantId: string; @ApiProperty() active: boolean; @ApiProperty({ type: SubscriberPreferenceChannels, }) preferenceSettings: IPreferenceChannels; @ApiProperty() deleted: boolean; @ApiPropertyOptional() deletedAt?: string; @ApiPropertyOptional() deletedBy?: string; @ApiProperty() createdAt: string; @ApiProperty() updatedAt?: string; } ================================================ FILE: apps/api/src/app/workflow-overrides/dtos/update-workflow-override-request.dto.ts ================================================ import { ApiPropertyOptional } from '@nestjs/swagger'; import { IUpdateWorkflowOverrideRequestDto } from '@novu/shared'; import { Type } from 'class-transformer'; import { IsBoolean, IsOptional, ValidateNested } from 'class-validator'; import { SubscriberPreferenceChannels } from '../../shared/dtos/preference-channels'; export class UpdateWorkflowOverrideRequestDto implements IUpdateWorkflowOverrideRequestDto { @ApiPropertyOptional() @IsBoolean() @IsOptional() active?: boolean; @ApiPropertyOptional({ type: SubscriberPreferenceChannels, }) @IsOptional() @ValidateNested() @Type(() => SubscriberPreferenceChannels) preferenceSettings?: SubscriberPreferenceChannels; } ================================================ FILE: apps/api/src/app/workflow-overrides/dtos/update-workflow-override-response.dto.ts ================================================ import { IUpdateWorkflowOverrideResponseDto } from '@novu/shared'; import { OverrideResponseDto } from './shared'; export class UpdateWorkflowOverrideResponseDto extends OverrideResponseDto implements IUpdateWorkflowOverrideResponseDto {} ================================================ FILE: apps/api/src/app/workflow-overrides/e2e/create-workflow-override.e2e.ts ================================================ import { NotificationTemplateRepository, TenantRepository } from '@novu/dal'; import { ICreateWorkflowOverrideRequestDto } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; describe('Create Integration - /workflow-overrides (POST) #novu-v0', () => { let session: UserSession; const tenantRepository = new TenantRepository(); const notificationTemplateRepository = new NotificationTemplateRepository(); beforeEach(async () => { session = new UserSession(); await session.initialize(); }); it('should successfully create new workflow override', async () => { const tenant = await tenantRepository.create({ _organizationId: session.organization._id, _environmentId: session.environment._id, identifier: 'identifier_123', name: 'name_123', data: { test1: 'test value1', test2: 'test value2' }, }); const workflow = await notificationTemplateRepository.create({ _organizationId: session.organization._id, _environmentId: session.environment._id, name: 'test api template', description: 'This is a test description', tags: ['test-tag-api'], notificationGroupId: session.notificationGroups[0]._id, steps: [], triggers: [{ identifier: 'test-trigger-api' }], }); const payload: ICreateWorkflowOverrideRequestDto = { preferenceSettings: { email: false }, active: false, workflowId: workflow._id, tenantId: tenant._id, }; const res = await session.testAgent.post('/v1/workflow-overrides').send(payload); expect(res.status).to.equal(201); expect(res.body.data.active).to.equal(false); expect(res.body.data._workflowId).to.equal(workflow._id); expect(res.body.data._tenantId).to.equal(tenant._id); expect(res.body.data.preferenceSettings).to.deep.equal({ email: false, sms: true, in_app: true, chat: true, push: true, }); expect(res.body.data.deleted).to.equal(false); expect(res.body.data._environmentId).to.equal(session.environment._id); expect(res.body.data._organizationId).to.equal(session.organization._id); }); it('should fail on creation of new workflow override with missing workflow id', async () => { const tenant = await tenantRepository.create({ _organizationId: session.organization._id, _environmentId: session.environment._id, identifier: 'identifier_123', name: 'name_123', data: { test1: 'test value1', test2: 'test value2' }, }); const payload: ICreateWorkflowOverrideRequestDto = { preferenceSettings: { email: false }, active: false, tenantId: tenant._id, workflowId: undefined as any, }; const res = await session.testAgent.post('/v1/workflow-overrides').send(payload); expect(res.body.statusCode).to.equal(400); expect(res.body.message[0]).to.equal('workflowId should not be null or undefined'); expect(res.body.message[1]).to.equal('workflowId must be a string'); }); it('should fail on creation of new workflow override with missing tenant id', async () => { const workflow = await notificationTemplateRepository.create({ _organizationId: session.organization._id, _environmentId: session.environment._id, name: 'test api template', description: 'This is a test description', tags: ['test-tag-api'], notificationGroupId: session.notificationGroups[0]._id, steps: [], triggers: [{ identifier: 'test-trigger-api' }], }); const payload: ICreateWorkflowOverrideRequestDto = { preferenceSettings: { email: false }, active: false, workflowId: workflow._id, tenantId: 'fake-tenant-identifier', }; const res = await session.testAgent.post('/v1/workflow-overrides').send(payload); expect(res.body.statusCode).to.equal(422); expect(res.body.errors._tenantId.messages[0]).to.equal(`_tenantId must be a mongodb id`); }); }); ================================================ FILE: apps/api/src/app/workflow-overrides/e2e/delete-workflow-override.e2e.ts ================================================ import { TenantRepository, WorkflowOverrideRepository } from '@novu/dal'; import { UserSession, WorkflowOverrideService } from '@novu/testing'; import { expect } from 'chai'; describe('Delete workflow override - /workflow-overrides/:overrideId (Delete) #novu-v0', async () => { let session: UserSession; const tenantRepository = new TenantRepository(); const workflowOverrideRepository = new WorkflowOverrideRepository(); before(async () => { session = new UserSession(); await session.initialize(); }); it('should delete the workflow override', async () => { const workflowOverrideService = new WorkflowOverrideService({ organizationId: session.organization._id, environmentId: session.environment._id, }); const { tenant, workflowOverride } = await workflowOverrideService.createWorkflowOverride(); if (!tenant) throw new Error('Tenant not found'); const validatedCreationWorkflowOverride = await workflowOverrideRepository.findOne({ _environmentId: session.environment._id, _id: workflowOverride._id, }); if (!validatedCreationWorkflowOverride) throw new Error('WorkflowOverride not found'); expect(validatedCreationWorkflowOverride._id).to.be.ok; const deleteRes = await session.testAgent.delete(`/v1/workflow-overrides/${validatedCreationWorkflowOverride._id}`); const foundWorkflowOverride: boolean = deleteRes.body.data; expect(foundWorkflowOverride).to.equal(true); const findDeleted = await workflowOverrideRepository.findOne({ _environmentId: session.environment._id, _id: workflowOverride._id, }); expect(findDeleted).to.be.null; }); it('should fail to delete non-existing workflow override', async () => { const fakeWorkflowOverrideId = session.user._id; const deleteRes = await session.testAgent.delete(`/v1/workflow-overrides/${fakeWorkflowOverrideId}`); const foundWorkflowOverride = deleteRes.body; expect(foundWorkflowOverride.statusCode).to.equal(404); expect(foundWorkflowOverride.message).to.equal(`Workflow Override with id ${fakeWorkflowOverrideId} not found`); }); }); ================================================ FILE: apps/api/src/app/workflow-overrides/e2e/get-workflow-override-by-id.e2e.ts ================================================ import { TenantRepository } from '@novu/dal'; import { IWorkflowOverride } from '@novu/shared'; import { UserSession, WorkflowOverrideService } from '@novu/testing'; import { expect } from 'chai'; describe('Get workflow override by ID - /workflow-overrides/:overrideId (GET) #novu-v0', async () => { let session: UserSession; const tenantRepository = new TenantRepository(); before(async () => { session = new UserSession(); await session.initialize(); }); it('should return the workflow override by ID', async () => { const workflowOverrideService = new WorkflowOverrideService({ organizationId: session.organization._id, environmentId: session.environment._id, }); const { workflowOverride } = await workflowOverrideService.createWorkflowOverride(); const tenant = await tenantRepository.findOne({ _environmentId: session.environment._id, _id: workflowOverride._tenantId, }); if (!tenant) throw new Error('Tenant not found'); const res = await session.testAgent.get(`/v1/workflow-overrides/${workflowOverride._id}`); const foundWorkflowOverride: IWorkflowOverride = res.body.data; expect(foundWorkflowOverride._workflowId).to.equal(workflowOverride._workflowId); expect(foundWorkflowOverride._tenantId).to.equal(workflowOverride._tenantId); expect(foundWorkflowOverride.active).to.equal(workflowOverride.active); expect(foundWorkflowOverride.preferenceSettings.chat).to.equal(workflowOverride.preferenceSettings.chat); expect(foundWorkflowOverride.preferenceSettings.sms).to.equal(workflowOverride.preferenceSettings.sms); expect(foundWorkflowOverride.preferenceSettings.in_app).to.equal(workflowOverride.preferenceSettings.in_app); expect(foundWorkflowOverride.preferenceSettings.email).to.equal(workflowOverride.preferenceSettings.email); expect(foundWorkflowOverride.preferenceSettings.push).to.equal(workflowOverride.preferenceSettings.push); }); }); ================================================ FILE: apps/api/src/app/workflow-overrides/e2e/get-workflow-override.e2e.ts ================================================ import { TenantRepository } from '@novu/dal'; import { IWorkflowOverride } from '@novu/shared'; import { UserSession, WorkflowOverrideService } from '@novu/testing'; import { expect } from 'chai'; describe('Get workflow override - /workflow-overrides/workflows/:workflowId/tenants/:tenantIdentifier (GET) #novu-v0', async () => { let session: UserSession; const tenantRepository = new TenantRepository(); before(async () => { session = new UserSession(); await session.initialize(); }); it('should return the workflow override', async () => { const workflowOverrideService = new WorkflowOverrideService({ organizationId: session.organization._id, environmentId: session.environment._id, }); const { workflowOverride } = await workflowOverrideService.createWorkflowOverride(); const tenant = await tenantRepository.findOne({ _environmentId: session.environment._id, _id: workflowOverride._tenantId, }); if (!tenant) throw new Error('Tenant not found'); const res = await session.testAgent.get( `/v1/workflow-overrides/workflows/${workflowOverride._workflowId}/tenants/${tenant._id}` ); const foundWorkflowOverride: IWorkflowOverride = res.body.data; expect(foundWorkflowOverride._workflowId).to.equal(workflowOverride._workflowId); expect(foundWorkflowOverride._tenantId).to.equal(workflowOverride._tenantId); expect(foundWorkflowOverride.active).to.equal(workflowOverride.active); expect(foundWorkflowOverride.preferenceSettings.chat).to.equal(workflowOverride.preferenceSettings.chat); expect(foundWorkflowOverride.preferenceSettings.sms).to.equal(workflowOverride.preferenceSettings.sms); expect(foundWorkflowOverride.preferenceSettings.in_app).to.equal(workflowOverride.preferenceSettings.in_app); expect(foundWorkflowOverride.preferenceSettings.email).to.equal(workflowOverride.preferenceSettings.email); expect(foundWorkflowOverride.preferenceSettings.push).to.equal(workflowOverride.preferenceSettings.push); }); }); ================================================ FILE: apps/api/src/app/workflow-overrides/e2e/get-workflow-overrides.e2e.ts ================================================ import { NotificationGroupRepository, NotificationTemplateRepository, WorkflowOverrideRepository } from '@novu/dal'; import { UserSession, WorkflowOverrideService } from '@novu/testing'; import { expect } from 'chai'; describe('Get workflows overrides - /workflow-overrides (GET) #novu-v0', async () => { let session: UserSession; const notificationTemplateRepository = new NotificationTemplateRepository(); const notificationGroupRepository = new NotificationGroupRepository(); const workflowOverrideRepository = new WorkflowOverrideRepository(); before(async () => { session = new UserSession(); await session.initialize(); }); it('should return all workflows override by workflow id', async () => { const workflowOverrideService = new WorkflowOverrideService({ organizationId: session.organization._id, environmentId: session.environment._id, }); const groups = await notificationGroupRepository.find({ _environmentId: session.environment._id, }); const noOverrides = (await session.testAgent.get(`/v1/workflow-overrides`)).body.data; expect(noOverrides.length).to.equal(0); let workflow = await notificationTemplateRepository.create({ _organizationId: session.organization._id, _environmentId: session.environment._id, name: 'test api template', description: 'This is a test description', tags: ['test-tag-api'], notificationGroupId: groups[0]._id, steps: [], triggers: [{ identifier: 'test-trigger-api_1' }], }); await workflowOverrideService.createWorkflowOverride({ workflowId: workflow._id }); workflow = await notificationTemplateRepository.create({ _organizationId: session.organization._id, _environmentId: session.environment._id, name: 'test api template', description: 'This is a test description', tags: ['test-tag-api'], notificationGroupId: groups[0]._id, steps: [], triggers: [{ identifier: 'test-trigger-api_2' }], }); await workflowOverrideService.createWorkflowOverride({ workflowId: workflow._id }); workflow = await notificationTemplateRepository.create({ _organizationId: session.organization._id, _environmentId: session.environment._id, name: 'test api template', description: 'This is a test description', tags: ['test-tag-api'], notificationGroupId: groups[0]._id, steps: [], triggers: [{ identifier: 'test-trigger-api_3' }], }); await workflowOverrideService.createWorkflowOverride({ workflowId: workflow._id }); const { data } = (await session.testAgent.get(`/v1/workflow-overrides`)).body; expect(data.length).to.equal(3); const paginatedData = (await session.testAgent.get(`/v1/workflow-overrides?page=1&limit=2`)).body.data; expect(paginatedData.length).to.equal(1); }); it('should return all workflows override by workflow id with pagination', async () => { await workflowOverrideRepository.delete({} as any); const workflowOverrideService = new WorkflowOverrideService({ organizationId: session.organization._id, environmentId: session.environment._id, }); const groups = await notificationGroupRepository.find({ _environmentId: session.environment._id, }); let workflow = await notificationTemplateRepository.create({ _organizationId: session.organization._id, _environmentId: session.environment._id, name: 'test api template', description: 'This is a test description', tags: ['test-tag-api'], notificationGroupId: groups[0]._id, steps: [], triggers: [{ identifier: 'test-trigger-api_1' }], }); await workflowOverrideService.createWorkflowOverride({ workflowId: workflow._id }); workflow = await notificationTemplateRepository.create({ _organizationId: session.organization._id, _environmentId: session.environment._id, name: 'test api template', description: 'This is a test description', tags: ['test-tag-api'], notificationGroupId: groups[0]._id, steps: [], triggers: [{ identifier: 'test-trigger-api_2' }], }); await workflowOverrideService.createWorkflowOverride({ workflowId: workflow._id }); workflow = await notificationTemplateRepository.create({ _organizationId: session.organization._id, _environmentId: session.environment._id, name: 'test api template', description: 'This is a test description', tags: ['test-tag-api'], notificationGroupId: groups[0]._id, steps: [], triggers: [{ identifier: 'test-trigger-api_2' }], }); await workflowOverrideService.createWorkflowOverride({ workflowId: workflow._id }); const page1 = (await session.testAgent.get(`/v1/workflow-overrides?limit=2`)).body; expect(page1.data.length).to.equal(2); expect(page1.hasMore).to.equal(true); const page2 = (await session.testAgent.get(`/v1/workflow-overrides?page=1&limit=2`)).body; expect(page2.data.length).to.equal(1); expect(page2.hasMore).to.equal(false); }); }); ================================================ FILE: apps/api/src/app/workflow-overrides/e2e/update-workflow-override-by-id.e2e.ts ================================================ import { IUpdateWorkflowOverrideRequestDto } from '@novu/shared'; import { UserSession, WorkflowOverrideService } from '@novu/testing'; import { expect } from 'chai'; describe('Update Workflow Override By ID - /workflow-overrides/:overrideId (PUT) #novu-v0', () => { let session: UserSession; beforeEach(async () => { session = new UserSession(); await session.initialize(); }); it('should successfully update workflow override by ID', async () => { const workflowOverrideService = new WorkflowOverrideService({ organizationId: session.organization._id, environmentId: session.environment._id, }); const { workflowOverride } = await workflowOverrideService.createWorkflowOverride({ preferenceSettings: { email: false, sms: true, in_app: true, chat: true, push: true, }, }); expect(workflowOverride.preferenceSettings).to.deep.equal({ email: false, sms: true, in_app: true, chat: true, push: true, }); expect(workflowOverride.active).to.equal(false); const updatePayload: IUpdateWorkflowOverrideRequestDto = { preferenceSettings: { email: true, sms: false }, active: true, }; const updatedOverrides = ( await session.testAgent.put(`/v1/workflow-overrides/${workflowOverride._id}`).send(updatePayload) ).body.data; expect(updatedOverrides.preferenceSettings).to.deep.equal({ email: true, sms: false, in_app: true, chat: true, push: true, }); expect(updatedOverrides.active).to.equal(true); }); }); ================================================ FILE: apps/api/src/app/workflow-overrides/e2e/update-workflow-override.e2e.ts ================================================ import { NotificationTemplateRepository, TenantRepository } from '@novu/dal'; import { ICreateWorkflowOverrideRequestDto, IUpdateWorkflowOverrideRequestDto } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; describe('Update Workflow Override - /workflow-overrides/workflows/:workflowId/tenants/:tenantIdentifier (PUT) #novu-v0', () => { let session: UserSession; const tenantRepository = new TenantRepository(); const notificationTemplateRepository = new NotificationTemplateRepository(); beforeEach(async () => { session = new UserSession(); await session.initialize(); }); it('should successfully update workflow override', async () => { const { tenant, workflow, overrides } = await initializeOverrides(); expect(overrides.preferenceSettings).to.deep.equal({ email: false, sms: true, in_app: true, chat: true, push: true, }); expect(overrides.active).to.equal(false); const updatePayload: IUpdateWorkflowOverrideRequestDto = { preferenceSettings: { email: true, sms: false }, active: true, }; const updatedOverrides = ( await session.testAgent .put(`/v1/workflow-overrides/workflows/${workflow._id}/tenants/${tenant._id}`) .send(updatePayload) ).body.data; expect(updatedOverrides.preferenceSettings).to.deep.equal({ email: true, sms: false, in_app: true, chat: true, push: true, }); expect(updatedOverrides.active).to.equal(true); }); it('should fail update workflow override with invalid tenant identifier', async () => { const { tenant, workflow, overrides } = await initializeOverrides(); expect(overrides.preferenceSettings).to.deep.equal({ email: false, sms: true, in_app: true, chat: true, push: true, }); expect(overrides.active).to.equal(false); const updatePayload: IUpdateWorkflowOverrideRequestDto = { preferenceSettings: { email: true, sms: false }, active: true, }; const invalidTenantIdentifier = 'invalid-tenant-identifier'; const updatedOverrides = ( await session.testAgent .put(`/v1/workflow-overrides/workflows/${workflow._id}/tenants/${invalidTenantIdentifier}`) .send(updatePayload) ).body; expect(updatedOverrides.statusCode).to.equal(422); expect(updatedOverrides.errors._tenantId.messages[0]).to.equal('_tenantId must be a mongodb id'); }); it('should fail update workflow override with invalid workflow id', async () => { const { tenant, workflow, overrides } = await initializeOverrides(); expect(overrides.preferenceSettings).to.deep.equal({ email: false, sms: true, in_app: true, chat: true, push: true, }); expect(overrides.active).to.equal(false); const updatePayload: IUpdateWorkflowOverrideRequestDto = { preferenceSettings: { email: true, sms: false }, active: true, }; const invalidWorkflowId = tenant._id; const updatedOverrides = ( await session.testAgent .put(`/v1/workflow-overrides/workflows/${invalidWorkflowId}/tenants/${tenant.identifier}`) .send(updatePayload) ).body; expect(updatedOverrides.statusCode).to.equal(422); expect(updatedOverrides.errors._tenantId.messages[0]).to.equal(`_tenantId must be a mongodb id`); }); it('should fail update workflow override with now existing workflow override', async () => { const tenant = await tenantRepository.create({ _organizationId: session.organization._id, _environmentId: session.environment._id, identifier: 'identifier_123', name: 'name_123', data: { test1: 'test value1', test2: 'test value2' }, }); const workflow = await notificationTemplateRepository.create({ _organizationId: session.organization._id, _environmentId: session.environment._id, name: 'test api template', description: 'This is a test description', tags: ['test-tag-api'], notificationGroupId: session.notificationGroups[0]._id, steps: [], triggers: [{ identifier: 'test-trigger-api' }], }); const updatePayload: IUpdateWorkflowOverrideRequestDto = { preferenceSettings: { email: true, sms: false }, active: true, }; const updatedOverrides = ( await session.testAgent .put(`/v1/workflow-overrides/workflows/${workflow._id}/tenants/${tenant.identifier}`) .send(updatePayload) ).body; expect(updatedOverrides.statusCode).to.equal(422); expect(updatedOverrides.errors._tenantId.messages[0]).to.equal(`_tenantId must be a mongodb id`); }); async function initializeOverrides() { const tenant = await tenantRepository.create({ _organizationId: session.organization._id, _environmentId: session.environment._id, identifier: 'identifier_123', name: 'name_123', data: { test1: 'test value1', test2: 'test value2' }, }); const workflow = await notificationTemplateRepository.create({ _organizationId: session.organization._id, _environmentId: session.environment._id, name: 'test api template', description: 'This is a test description', tags: ['test-tag-api'], notificationGroupId: session.notificationGroups[0]._id, steps: [], triggers: [{ identifier: 'test-trigger-api' }], }); const payload: ICreateWorkflowOverrideRequestDto = { preferenceSettings: { email: false }, active: false, workflowId: workflow._id, tenantId: tenant._id, }; const overrides = (await session.testAgent.post('/v1/workflow-overrides').send(payload)).body.data; return { tenant, workflow, overrides }; } }); ================================================ FILE: apps/api/src/app/workflow-overrides/usecases/create-workflow-override/create-workflow-override.command.ts ================================================ import { Type } from 'class-transformer'; import { IsBoolean, IsDefined, IsMongoId, IsOptional, ValidateNested } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; import { SubscriberPreferenceChannels } from '../../../shared/dtos/preference-channels'; export class CreateWorkflowOverrideCommand extends EnvironmentWithUserCommand { @IsMongoId() @IsDefined() _workflowId: string; @IsMongoId() @IsDefined() _tenantId: string; @IsBoolean() @IsOptional() active?: boolean; @IsOptional() @ValidateNested() @Type(() => SubscriberPreferenceChannels) preferenceSettings?: SubscriberPreferenceChannels; } ================================================ FILE: apps/api/src/app/workflow-overrides/usecases/create-workflow-override/create-workflow-override.usecase.ts ================================================ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { NotificationTemplateEntity, NotificationTemplateRepository, TenantEntity, TenantRepository, WorkflowOverrideRepository, } from '@novu/dal'; import { CreateWorkflowOverrideResponseDto } from '../../dtos'; import { CreateWorkflowOverrideCommand } from './create-workflow-override.command'; @Injectable() export class CreateWorkflowOverride { constructor( private tenantRepository: TenantRepository, private notificationTemplateRepository: NotificationTemplateRepository, private workflowOverrideRepository: WorkflowOverrideRepository ) {} async execute(command: CreateWorkflowOverrideCommand): Promise { const { tenant, workflow } = await this.extractEntities(command); return await this.workflowOverrideRepository.create({ _organizationId: command.organizationId, _environmentId: command.environmentId, _tenantId: tenant._id, _workflowId: workflow._id, active: command.active, preferenceSettings: command.preferenceSettings, }); } private async extractEntities( command: CreateWorkflowOverrideCommand ): Promise<{ tenant: TenantEntity; workflow: NotificationTemplateEntity }> { const tenant = await this.tenantRepository.findOne({ _environmentId: command.environmentId, _id: command._tenantId, }); if (!tenant) { throw new NotFoundException(`Tenant with id ${command._tenantId} is not found`); } const workflow = await this.notificationTemplateRepository.findOne({ _environmentId: command.environmentId, _id: command._workflowId, }); if (!workflow) { throw new NotFoundException(`Workflow with id ${command._workflowId} is not found`); } return { tenant, workflow }; } } ================================================ FILE: apps/api/src/app/workflow-overrides/usecases/delete-workflow-override/delete-workflow-override.command.ts ================================================ import { IsDefined, IsMongoId } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; export class DeleteWorkflowOverrideCommand extends EnvironmentWithUserCommand { @IsMongoId() @IsDefined() _id: string; } ================================================ FILE: apps/api/src/app/workflow-overrides/usecases/delete-workflow-override/delete-workflow-override.usecase.ts ================================================ import { Injectable, NotFoundException } from '@nestjs/common'; import { WorkflowOverrideRepository } from '@novu/dal'; import { DeleteWorkflowOverrideCommand } from './delete-workflow-override.command'; @Injectable() export class DeleteWorkflowOverride { constructor(private workflowOverrideRepository: WorkflowOverrideRepository) {} async execute(command: DeleteWorkflowOverrideCommand): Promise { const workflowOverride = await this.workflowOverrideRepository.findOne({ _environmentId: command.environmentId, _id: command._id, }); if (!workflowOverride) { throw new NotFoundException(`Workflow Override with id ${command._id} not found`); } const deletedWorkflowOverride = await this.workflowOverrideRepository.delete({ _environmentId: command.environmentId, _id: command._id, }); if (!deletedWorkflowOverride?.acknowledged) { throw new Error(`Unexpected error: failed to delete workflow override with id ${command._id}`); } return true; } } ================================================ FILE: apps/api/src/app/workflow-overrides/usecases/get-workflow-override/get-workflow-override.command.ts ================================================ import { IsDefined, IsMongoId, IsString } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; export class GetWorkflowOverrideCommand extends EnvironmentWithUserCommand { @IsMongoId() @IsDefined() _workflowId: string; @IsMongoId() @IsDefined() _tenantId: string; } ================================================ FILE: apps/api/src/app/workflow-overrides/usecases/get-workflow-override/get-workflow-override.usecase.ts ================================================ import { Injectable, NotFoundException } from '@nestjs/common'; import { TenantRepository, WorkflowOverrideRepository } from '@novu/dal'; import { GetWorkflowOverrideResponseDto } from '../../dtos/get-workflow-override-response.dto'; import { GetWorkflowOverrideCommand } from './get-workflow-override.command'; @Injectable() export class GetWorkflowOverride { constructor( private tenantRepository: TenantRepository, private workflowOverrideRepository: WorkflowOverrideRepository ) {} async execute(command: GetWorkflowOverrideCommand): Promise { const workflowOverride = await this.workflowOverrideRepository.findOne({ _environmentId: command.environmentId, _workflowId: command._workflowId, _tenantId: command._tenantId, }); if (!workflowOverride) { throw new NotFoundException( `Workflow Override with workflow id ${command._workflowId}, tenant id ${command._tenantId} not found` ); } return workflowOverride; } } ================================================ FILE: apps/api/src/app/workflow-overrides/usecases/get-workflow-override-by-id/get-workflow-override-by-id.command.ts ================================================ import { IsDefined, IsMongoId } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; export class GetWorkflowOverrideByIdCommand extends EnvironmentWithUserCommand { @IsMongoId() @IsDefined() overrideId: string; } ================================================ FILE: apps/api/src/app/workflow-overrides/usecases/get-workflow-override-by-id/get-workflow-override-by-id.usecase.ts ================================================ import { Injectable, NotFoundException } from '@nestjs/common'; import { WorkflowOverrideRepository } from '@novu/dal'; import { GetWorkflowOverrideResponseDto } from '../../dtos'; import { GetWorkflowOverrideByIdCommand } from './get-workflow-override-by-id.command'; @Injectable() export class GetWorkflowOverrideById { constructor(private workflowOverrideRepository: WorkflowOverrideRepository) {} async execute(command: GetWorkflowOverrideByIdCommand): Promise { const workflowOverride = await this.workflowOverrideRepository.findOne({ _environmentId: command.environmentId, _id: command.overrideId, }); if (!workflowOverride) { throw new NotFoundException(`Workflow Override with id ${command.overrideId} not found`); } return workflowOverride; } } ================================================ FILE: apps/api/src/app/workflow-overrides/usecases/get-workflow-overrides/get-workflow-overrides.command.ts ================================================ import { IsDefined, IsMongoId, IsNumber } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; export class GetWorkflowOverridesCommand extends EnvironmentWithUserCommand { @IsNumber() @IsDefined() page: number; @IsNumber() @IsDefined() limit: number; } ================================================ FILE: apps/api/src/app/workflow-overrides/usecases/get-workflow-overrides/get-workflow-overrides.usecase.ts ================================================ import { Injectable } from '@nestjs/common'; import { WorkflowOverrideRepository } from '@novu/dal'; import { GetWorkflowOverridesResponseDto } from '../../dtos/get-workflow-overrides-response.dto'; import { GetWorkflowOverridesCommand } from './get-workflow-overrides.command'; @Injectable() export class GetWorkflowOverrides { constructor(private workflowOverrideRepository: WorkflowOverrideRepository) {} async execute(command: GetWorkflowOverridesCommand): Promise { const { data } = await this.workflowOverrideRepository.getList( { skip: command.page * command.limit, limit: command.limit, }, { environmentId: command.environmentId, } ); return { data, page: command.page, pageSize: command.limit, hasMore: data?.length === command.limit, }; } } ================================================ FILE: apps/api/src/app/workflow-overrides/usecases/index.ts ================================================ import { CreateWorkflowOverride } from './create-workflow-override/create-workflow-override.usecase'; import { DeleteWorkflowOverride } from './delete-workflow-override/delete-workflow-override.usecase'; import { GetWorkflowOverride } from './get-workflow-override/get-workflow-override.usecase'; import { GetWorkflowOverrideById } from './get-workflow-override-by-id/get-workflow-override-by-id.usecase'; import { GetWorkflowOverrides } from './get-workflow-overrides/get-workflow-overrides.usecase'; import { UpdateWorkflowOverride } from './update-workflow-override/update-workflow-override.usecase'; import { UpdateWorkflowOverrideById } from './update-workflow-override-by-id/update-workflow-override-by-id.usecase'; export const USE_CASES = [ CreateWorkflowOverride, UpdateWorkflowOverride, GetWorkflowOverride, DeleteWorkflowOverride, GetWorkflowOverrides, GetWorkflowOverrideById, UpdateWorkflowOverrideById, ]; ================================================ FILE: apps/api/src/app/workflow-overrides/usecases/update-workflow-override/update-workflow-override.command.ts ================================================ import { Type } from 'class-transformer'; import { IsBoolean, IsDefined, IsMongoId, IsOptional, ValidateNested } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; import { SubscriberPreferenceChannels } from '../../../shared/dtos/preference-channels'; export class UpdateWorkflowOverrideCommand extends EnvironmentWithUserCommand { @IsMongoId() @IsDefined() _workflowId: string; @IsMongoId() @IsDefined() _tenantId: string; @IsBoolean() @IsOptional() active?: boolean; @IsOptional() @ValidateNested() @Type(() => SubscriberPreferenceChannels) preferenceSettings?: SubscriberPreferenceChannels; } ================================================ FILE: apps/api/src/app/workflow-overrides/usecases/update-workflow-override/update-workflow-override.usecase.ts ================================================ import { Injectable, NotFoundException } from '@nestjs/common'; import { NotificationTemplateEntity, NotificationTemplateRepository, TenantEntity, TenantRepository, WorkflowOverrideEntity, WorkflowOverrideRepository, } from '@novu/dal'; import { UpdateWorkflowOverrideResponseDto } from '../../dtos/update-workflow-override-response.dto'; import { UpdateWorkflowOverrideCommand } from './update-workflow-override.command'; @Injectable() export class UpdateWorkflowOverride { constructor( private tenantRepository: TenantRepository, private notificationTemplateRepository: NotificationTemplateRepository, private workflowOverrideRepository: WorkflowOverrideRepository ) {} async execute(command: UpdateWorkflowOverrideCommand): Promise { const { tenant, workflow } = await this.extractEntities(command); const currentOverrideEntity = await this.workflowOverrideRepository.findOne({ _environmentId: command.environmentId, _workflowId: workflow._id, _tenantId: tenant._id, }); if (!currentOverrideEntity) { throw new NotFoundException( `Workflow override with workflow id ${command._workflowId} and tenant id ${command._tenantId} was not found` ); } const updatePayload: Partial = {}; if (command.active != null) { updatePayload.active = command.active; } if (command.preferenceSettings != null) { updatePayload.preferenceSettings = { ...currentOverrideEntity.preferenceSettings, ...command.preferenceSettings, }; } await this.workflowOverrideRepository.update( { _environmentId: command.environmentId, _id: currentOverrideEntity._id, }, { $set: updatePayload, } ); return { ...currentOverrideEntity, ...updatePayload }; } private async extractEntities( command: UpdateWorkflowOverrideCommand ): Promise<{ tenant: TenantEntity; workflow: NotificationTemplateEntity }> { const tenant = await this.tenantRepository.findOne({ _environmentId: command.environmentId, _id: command._tenantId, }); if (!tenant) { throw new NotFoundException(`Tenant with id ${command._tenantId} is not found`); } const workflow = await this.notificationTemplateRepository.findOne({ _environmentId: command.environmentId, _id: command._workflowId, }); if (!workflow) { throw new NotFoundException(`Workflow with id ${command._workflowId} is not found`); } return { tenant, workflow }; } } ================================================ FILE: apps/api/src/app/workflow-overrides/usecases/update-workflow-override-by-id/update-workflow-override-by-id.command.ts ================================================ import { Type } from 'class-transformer'; import { IsBoolean, IsDefined, IsMongoId, IsOptional, ValidateNested } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; import { SubscriberPreferenceChannels } from '../../../shared/dtos/preference-channels'; export class UpdateWorkflowOverrideByIdCommand extends EnvironmentWithUserCommand { @IsMongoId() @IsDefined() overrideId: string; @IsBoolean() @IsOptional() active?: boolean; @IsOptional() @ValidateNested() @Type(() => SubscriberPreferenceChannels) preferenceSettings?: SubscriberPreferenceChannels; } ================================================ FILE: apps/api/src/app/workflow-overrides/usecases/update-workflow-override-by-id/update-workflow-override-by-id.usecase.ts ================================================ import { Injectable, NotFoundException } from '@nestjs/common'; import { NotificationTemplateRepository, TenantRepository, WorkflowOverrideEntity, WorkflowOverrideRepository, } from '@novu/dal'; import { UpdateWorkflowOverrideResponseDto } from '../../dtos'; import { UpdateWorkflowOverrideByIdCommand } from './update-workflow-override-by-id.command'; @Injectable() export class UpdateWorkflowOverrideById { constructor( private tenantRepository: TenantRepository, private notificationTemplateRepository: NotificationTemplateRepository, private workflowOverrideRepository: WorkflowOverrideRepository ) {} async execute(command: UpdateWorkflowOverrideByIdCommand): Promise { const currentOverrideEntity = await this.workflowOverrideRepository.findOne({ _environmentId: command.environmentId, _id: command.overrideId, }); if (!currentOverrideEntity) { throw new NotFoundException(`Workflow override with id ${command.overrideId} not found`); } const updatePayload: Partial = {}; if (command.active != null) { updatePayload.active = command.active; } if (command.preferenceSettings != null) { updatePayload.preferenceSettings = { ...currentOverrideEntity.preferenceSettings, ...command.preferenceSettings, }; } await this.workflowOverrideRepository.update( { _environmentId: command.environmentId, _id: currentOverrideEntity._id, }, { $set: updatePayload, } ); return { ...currentOverrideEntity, ...updatePayload }; } } ================================================ FILE: apps/api/src/app/workflow-overrides/workflow-overrides.controller.ts ================================================ import { Body, ClassSerializerInterceptor, Controller, Delete, Get, Param, Post, Put, Query, UseGuards, UseInterceptors, } from '@nestjs/common'; import { ApiExcludeController, ApiOperation, ApiTags } from '@nestjs/swagger'; import { UserSessionData } from '@novu/shared'; import { RequireAuthentication } from '../auth/framework/auth.decorator'; import { ExternalApiAccessible } from '../auth/framework/external-api.decorator'; import { RootEnvironmentGuard } from '../auth/framework/root-environment-guard.service'; import { DataBooleanDto } from '../shared/dtos/data-wrapper-dto'; import { ApiCommonResponses, ApiOkResponse, ApiResponse } from '../shared/framework/response.decorator'; import { UserSession } from '../shared/framework/user.decorator'; import { CreateWorkflowOverrideRequestDto, CreateWorkflowOverrideResponseDto, GetWorkflowOverrideResponseDto, GetWorkflowOverridesRequestDto, GetWorkflowOverridesResponseDto, UpdateWorkflowOverrideRequestDto, UpdateWorkflowOverrideResponseDto, } from './dtos'; import { CreateWorkflowOverrideCommand } from './usecases/create-workflow-override/create-workflow-override.command'; import { CreateWorkflowOverride } from './usecases/create-workflow-override/create-workflow-override.usecase'; import { DeleteWorkflowOverrideCommand } from './usecases/delete-workflow-override/delete-workflow-override.command'; import { DeleteWorkflowOverride } from './usecases/delete-workflow-override/delete-workflow-override.usecase'; import { GetWorkflowOverrideCommand } from './usecases/get-workflow-override/get-workflow-override.command'; import { GetWorkflowOverride } from './usecases/get-workflow-override/get-workflow-override.usecase'; import { GetWorkflowOverrideByIdCommand } from './usecases/get-workflow-override-by-id/get-workflow-override-by-id.command'; import { GetWorkflowOverrideById } from './usecases/get-workflow-override-by-id/get-workflow-override-by-id.usecase'; import { GetWorkflowOverridesCommand } from './usecases/get-workflow-overrides/get-workflow-overrides.command'; import { GetWorkflowOverrides } from './usecases/get-workflow-overrides/get-workflow-overrides.usecase'; import { UpdateWorkflowOverrideCommand } from './usecases/update-workflow-override/update-workflow-override.command'; import { UpdateWorkflowOverride } from './usecases/update-workflow-override/update-workflow-override.usecase'; import { UpdateWorkflowOverrideByIdCommand } from './usecases/update-workflow-override-by-id/update-workflow-override-by-id.command'; import { UpdateWorkflowOverrideById } from './usecases/update-workflow-override-by-id/update-workflow-override-by-id.usecase'; @ApiCommonResponses() @Controller('/workflow-overrides') @UseInterceptors(ClassSerializerInterceptor) @RequireAuthentication() @ApiTags('Workflows-Overrides') @ApiExcludeController() export class WorkflowOverridesController { constructor( private createWorkflowOverrideUsecase: CreateWorkflowOverride, private updateWorkflowOverrideUsecase: UpdateWorkflowOverride, private updateWorkflowOverrideByIdUsecase: UpdateWorkflowOverrideById, private getWorkflowOverrideUsecase: GetWorkflowOverride, private getWorkflowOverrideByIdUsecase: GetWorkflowOverrideById, private deleteWorkflowOverrideUsecase: DeleteWorkflowOverride, private getWorkflowOverridesUsecase: GetWorkflowOverrides ) {} @Post('/') @UseGuards(RootEnvironmentGuard) @ApiResponse(CreateWorkflowOverrideResponseDto) @ApiOperation({ summary: 'Create workflow override', }) @ExternalApiAccessible() create( @UserSession() user: UserSessionData, @Body() body: CreateWorkflowOverrideRequestDto ): Promise { return this.createWorkflowOverrideUsecase.execute( CreateWorkflowOverrideCommand.create({ organizationId: user.organizationId, environmentId: user.environmentId, userId: user._id, active: body.active, preferenceSettings: body.preferenceSettings, _tenantId: body.tenantId, _workflowId: body.workflowId, }) ); } @Put('/:overrideId') @UseGuards(RootEnvironmentGuard) @ApiResponse(UpdateWorkflowOverrideResponseDto) @ApiOperation({ summary: 'Update workflow override by id', }) @ExternalApiAccessible() updateWorkflowOverrideById( @UserSession() user: UserSessionData, @Body() body: UpdateWorkflowOverrideRequestDto, @Param('overrideId') overrideId: string ): Promise { return this.updateWorkflowOverrideByIdUsecase.execute( UpdateWorkflowOverrideByIdCommand.create({ organizationId: user.organizationId, environmentId: user.environmentId, userId: user._id, active: body.active, preferenceSettings: body.preferenceSettings, overrideId, }) ); } @Put('/workflows/:workflowId/tenants/:tenantId') @UseGuards(RootEnvironmentGuard) @ApiResponse(UpdateWorkflowOverrideResponseDto) @ApiOperation({ summary: 'Update workflow override', }) @ExternalApiAccessible() updateWorkflowOverride( @UserSession() user: UserSessionData, @Body() body: UpdateWorkflowOverrideRequestDto, @Param('workflowId') workflowId: string, @Param('tenantId') tenantId: string ): Promise { return this.updateWorkflowOverrideUsecase.execute( UpdateWorkflowOverrideCommand.create({ organizationId: user.organizationId, environmentId: user.environmentId, userId: user._id, active: body.active, preferenceSettings: body.preferenceSettings, _tenantId: tenantId, _workflowId: workflowId, }) ); } @Get('/:overrideId') @UseGuards(RootEnvironmentGuard) @ApiResponse(GetWorkflowOverrideResponseDto) @ApiOperation({ summary: 'Get workflow override by id', }) @ExternalApiAccessible() getWorkflowOverrideById( @UserSession() user: UserSessionData, @Param('overrideId') overrideId: string ): Promise { return this.getWorkflowOverrideByIdUsecase.execute( GetWorkflowOverrideByIdCommand.create({ organizationId: user.organizationId, environmentId: user.environmentId, userId: user._id, overrideId, }) ); } @Get('/workflows/:workflowId/tenants/:tenantId') @UseGuards(RootEnvironmentGuard) @ApiResponse(GetWorkflowOverrideResponseDto) @ApiOperation({ summary: 'Get workflow override', }) @ExternalApiAccessible() getWorkflowOverride( @UserSession() user: UserSessionData, @Param('workflowId') workflowId: string, @Param('tenantId') tenantId: string ): Promise { return this.getWorkflowOverrideUsecase.execute( GetWorkflowOverrideCommand.create({ organizationId: user.organizationId, environmentId: user.environmentId, userId: user._id, _tenantId: tenantId, _workflowId: workflowId, }) ); } @Delete('/:overrideId') @UseGuards(RootEnvironmentGuard) @ApiOkResponse({ type: DataBooleanDto, }) @ApiOperation({ summary: 'Delete workflow override', }) @ExternalApiAccessible() deleteWorkflowOverride( @UserSession() user: UserSessionData, @Param('overrideId') overrideId: string ): Promise { return this.deleteWorkflowOverrideUsecase.execute( DeleteWorkflowOverrideCommand.create({ organizationId: user.organizationId, environmentId: user.environmentId, userId: user._id, _id: overrideId, }) ); } @Get('/') @UseGuards(RootEnvironmentGuard) @ApiResponse(GetWorkflowOverridesResponseDto) @ApiOperation({ summary: 'Get workflow overrides', }) @ExternalApiAccessible() getWorkflowOverrides( @UserSession() user: UserSessionData, @Query() query: GetWorkflowOverridesRequestDto ): Promise { return this.getWorkflowOverridesUsecase.execute( GetWorkflowOverridesCommand.create({ organizationId: user.organizationId, environmentId: user.environmentId, userId: user._id, page: query.page, limit: query.limit, }) ); } } ================================================ FILE: apps/api/src/app/workflow-overrides/workflow-overrides.module.ts ================================================ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; import { SharedModule } from '../shared/shared.module'; import { USE_CASES } from './usecases'; import { WorkflowOverridesController } from './workflow-overrides.controller'; @Module({ imports: [SharedModule, AuthModule], controllers: [WorkflowOverridesController], providers: [...USE_CASES], exports: [...USE_CASES], }) export class WorkflowOverridesModule implements NestModule { configure(consumer: MiddlewareConsumer): MiddlewareConsumer | void {} } ================================================ FILE: apps/api/src/app/workflows-v1/dtos/change-workflow-status-request.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { IsBoolean, IsDefined } from 'class-validator'; /** * @deprecated use dto's in /workflows directory */ export class ChangeWorkflowStatusRequestDto { @ApiProperty() @IsDefined() @IsBoolean() active: boolean; } ================================================ FILE: apps/api/src/app/workflows-v1/dtos/create-workflow.request.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { CustomDataType, ICreateWorkflowDto, INotificationGroup, IPreferenceChannels } from '@novu/shared'; import { IsArray, IsBoolean, IsDefined, IsOptional, IsString, MaxLength, ValidateNested } from 'class-validator'; import { NotificationStepDto } from '../../shared/dtos/notification-step-dto'; import { SubscriberPreferenceChannels } from '../../shared/dtos/preference-channels'; /** * @deprecated use dto's in /workflows directory */ export class CreateWorkflowRequestDto implements ICreateWorkflowDto { @ApiProperty() @IsString() @IsDefined() name: string; @ApiProperty() @IsString() @IsDefined({ message: 'Notification group must be provided ', }) notificationGroupId: string; @ApiProperty() @IsOptional() notificationGroup?: INotificationGroup; @ApiPropertyOptional() @IsOptional() @IsArray() tags: string[]; @ApiPropertyOptional() @IsString() @IsOptional() @MaxLength(1000) description: string; @ApiProperty({ type: [NotificationStepDto], }) @IsDefined() @IsArray() @ValidateNested() steps: NotificationStepDto[]; @ApiPropertyOptional() @IsBoolean() @IsOptional() active?: boolean; @ApiPropertyOptional({ deprecated: true }) @IsBoolean() @IsOptional() draft?: boolean; @ApiPropertyOptional() @IsBoolean() @IsOptional() critical?: boolean; @ApiPropertyOptional({ type: SubscriberPreferenceChannels, }) @IsOptional() preferenceSettings?: IPreferenceChannels; @ApiPropertyOptional() @IsOptional() @IsString() blueprintId?: string; @ApiPropertyOptional() @IsOptional() data?: CustomDataType; } ================================================ FILE: apps/api/src/app/workflows-v1/dtos/index.ts ================================================ export * from './change-workflow-status-request.dto'; export * from './create-workflow.request.dto'; export * from './update-workflow-request.dto'; export * from './variables.response.dto'; ================================================ FILE: apps/api/src/app/workflows-v1/dtos/update-workflow-request.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { CustomDataType, IPreferenceChannels, IUpdateWorkflowDto } from '@novu/shared'; import { IsArray, IsMongoId, IsOptional, IsString, MaxLength, ValidateNested } from 'class-validator'; import { NotificationStepDto } from '../../shared/dtos/notification-step-dto'; import { SubscriberPreferenceChannels } from '../../shared/dtos/preference-channels'; /** * @deprecated use dto's in /workflows directory */ export class UpdateWorkflowRequestDto implements IUpdateWorkflowDto { @ApiProperty() @IsString() @IsOptional() name: string; @ApiPropertyOptional() @IsArray() @IsOptional() tags: string[]; @ApiPropertyOptional() @IsString() @IsOptional() @MaxLength(300) description: string; @ApiPropertyOptional() @IsString() @IsOptional() identifier?: string; @ApiPropertyOptional() @IsArray() @IsOptional() @ValidateNested() steps: NotificationStepDto[]; @ApiProperty() @IsOptional() @IsMongoId() notificationGroupId: string; @ApiPropertyOptional() critical?: boolean; @ApiPropertyOptional({ type: SubscriberPreferenceChannels, }) preferenceSettings?: IPreferenceChannels; @ApiPropertyOptional() @IsOptional() data?: CustomDataType; } ================================================ FILE: apps/api/src/app/workflows-v1/dtos/variables.response.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; /** * @deprecated use dto's in /workflows directory */ export class VariablesResponseDto { @ApiProperty() translations: Record; @ApiProperty() system: Record; } ================================================ FILE: apps/api/src/app/workflows-v1/dtos/workflow-response.dto.ts ================================================ import { ApiExtraModels, ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { CustomDataType, INotificationTemplate, TriggerTypeEnum, WorkflowIntegrationStatus } from '@novu/shared'; import { IsOptional } from 'class-validator'; import { NotificationStepDto } from '../../shared/dtos/notification-step-dto'; import { SubscriberPreferenceChannels } from '../../shared/dtos/preference-channels'; /** * @deprecated use dto's in /workflows directory */ export class NotificationGroup { @ApiPropertyOptional() _id?: string; @ApiProperty() name: string; @ApiProperty() _environmentId: string; @ApiProperty() _organizationId: string; @ApiPropertyOptional() _parentId?: string; } export class NotificationTriggerVariable { name: string; } export class NotificationTrigger { @ApiProperty({ enum: TriggerTypeEnum, }) type: TriggerTypeEnum; @ApiProperty() identifier: string; @ApiProperty({ type: [NotificationTriggerVariable], }) variables: NotificationTriggerVariable[]; @ApiProperty({ type: [NotificationTriggerVariable], }) subscriberVariables?: NotificationTriggerVariable[]; } @ApiExtraModels(NotificationGroup) export class WorkflowResponse implements INotificationTemplate { @ApiPropertyOptional() _id?: string; @ApiProperty() name: string; @ApiProperty() description: string; @ApiProperty() active: boolean; @ApiProperty() draft: boolean; @ApiProperty({ type: SubscriberPreferenceChannels, }) preferenceSettings: SubscriberPreferenceChannels; @ApiProperty() critical: boolean; @ApiProperty() tags: string[]; @ApiProperty({ type: [NotificationStepDto], }) steps: NotificationStepDto[]; @ApiProperty() _organizationId: string; @ApiProperty() _creatorId: string; @ApiProperty() _environmentId: string; @ApiProperty({ type: [NotificationTrigger], }) triggers: NotificationTrigger[]; @ApiProperty() _notificationGroupId: string; @ApiPropertyOptional() _parentId?: string; @ApiProperty() deleted: boolean; @ApiProperty() deletedAt: string; @ApiProperty() deletedBy: string; @ApiPropertyOptional({ type: NotificationGroup, }) readonly notificationGroup?: NotificationGroup; @ApiPropertyOptional() @IsOptional() data?: CustomDataType; workflowIntegrationStatus?: WorkflowIntegrationStatus; } ================================================ FILE: apps/api/src/app/workflows-v1/dtos/workflows-request.dto.ts ================================================ import { PaginationWithFiltersRequestDto } from '../../shared/dtos/pagination-with-filters-request'; /** * @deprecated use dto's in /workflows directory */ export class WorkflowsRequestDto extends PaginationWithFiltersRequestDto({ defaultLimit: 10, maxLimit: 100, queryDescription: 'It allows filtering based on either the name or trigger identifier of the workflow items.', }) {} ================================================ FILE: apps/api/src/app/workflows-v1/dtos/workflows.response.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { WorkflowResponse } from './workflow-response.dto'; /** * @deprecated use dto's in /workflows directory */ export class WorkflowsResponseDto { @ApiProperty() totalCount: number; @ApiProperty() data: WorkflowResponse[]; @ApiProperty() pageSize: number; @ApiProperty() page: number; } ================================================ FILE: apps/api/src/app/workflows-v1/e2e/change-template-status.e2e.ts ================================================ import { NotificationTemplateRepository } from '@novu/dal'; import { NotificationTemplateService, UserSession } from '@novu/testing'; import { expect } from 'chai'; describe('Change workflow status by id - /workflows/:workflowId/status (PUT) #novu-v0', async () => { let session: UserSession; const notificationTemplateRepository = new NotificationTemplateRepository(); before(async () => { session = new UserSession(); await session.initialize(); }); it('should change the status from active false to active true', async () => { const notificationTemplateService = new NotificationTemplateService( session.user._id, session.organization._id, session.environment._id ); const template = await notificationTemplateService.createTemplate({ active: false, draft: true, }); const beforeChange = await notificationTemplateRepository.findById(template._id, template._environmentId); expect(beforeChange?.active).to.equal(false); expect(beforeChange?.draft).to.equal(true); const { body } = await session.testAgent.put(`/v1/workflows/${template._id}/status`).send({ active: true, }); const found = await notificationTemplateRepository.findById(template._id, template._environmentId); expect(found?.active).to.equal(true); expect(found?.draft).to.equal(false); }); }); ================================================ FILE: apps/api/src/app/workflows-v1/e2e/create-notification-templates.e2e.ts ================================================ import { ChangeRepository, CommunityOrganizationRepository, EnvironmentRepository, MessageTemplateRepository, NotificationTemplateEntity, NotificationTemplateRepository, OrganizationRepository, SubscriberEntity, } from '@novu/dal'; import { ChangeEntityTypeEnum, ChannelCTATypeEnum, ChannelTypeEnum, EmailBlockTypeEnum, EmailProviderIdEnum, FieldLogicalOperatorEnum, FieldOperatorEnum, FilterPartTypeEnum, IFieldFilterPart, INotificationTemplate, INotificationTemplateStep, isClerkEnabled, ResourceTypeEnum, StepTypeEnum, TriggerTypeEnum, } from '@novu/shared'; import { SubscribersService, testServer, UserSession } from '@novu/testing'; import axios from 'axios'; import { expect } from 'chai'; import { isSameDay } from 'date-fns'; import { CreateWorkflowRequestDto } from '../dtos'; describe('Create Workflow - /workflows (POST) #novu-v0', async () => { let session: UserSession; const changeRepository: ChangeRepository = new ChangeRepository(); const notificationTemplateRepository: NotificationTemplateRepository = new NotificationTemplateRepository(); const messageTemplateRepository: MessageTemplateRepository = new MessageTemplateRepository(); const environmentRepository: EnvironmentRepository = new EnvironmentRepository(); const axiosInstance = axios.create(); let subscriber: SubscriberEntity; let subscriberService: SubscribersService; beforeEach(async () => { session = new UserSession(); await session.initialize(); subscriberService = new SubscribersService(session.organization._id, session.environment._id); subscriber = await subscriberService.createSubscriber(); }); it('should be able to create a notification with the API Key', async () => { const templateBody: Partial = { name: 'test api template', description: 'This is a test description', tags: ['test-tag-api'], notificationGroupId: session.notificationGroups[0]._id, steps: [], }; const response = await axiosInstance.post(`${session.serverUrl}/v1/workflows`, templateBody, { headers: { authorization: `ApiKey ${session.apiKey}`, }, }); expect(response.data.data.name).to.equal(templateBody.name); }); it('should create email template', async () => { const defaultMessageIsActive = true; const templateRequestPayload: Partial = { name: 'test email template', description: 'This is a test description', tags: ['test-tag'], notificationGroupId: session.notificationGroups[0]._id, steps: [ { template: { name: 'Message Name', subject: 'Test email subject', preheader: 'Test email preheader', senderName: 'Test email sender name', content: [{ type: EmailBlockTypeEnum.TEXT, content: 'This is a sample text block' }], type: StepTypeEnum.EMAIL, }, filters: [ { isNegated: false, type: 'GROUP', value: FieldLogicalOperatorEnum.AND, children: [ { on: FilterPartTypeEnum.SUBSCRIBER, field: 'firstName', value: 'test value', operator: FieldOperatorEnum.EQUAL, }, ], }, ], variants: [ { template: { name: 'Better Message Template', subject: 'Better subject', preheader: 'Better pre header', senderName: 'Better pre sender name', content: [{ type: EmailBlockTypeEnum.TEXT, content: 'This is a sample of Better text block' }], type: StepTypeEnum.EMAIL, }, active: defaultMessageIsActive, filters: [ { isNegated: false, type: 'GROUP', value: FieldLogicalOperatorEnum.AND, children: [ { on: FilterPartTypeEnum.TENANT, field: 'name', value: 'Titans', operator: FieldOperatorEnum.EQUAL, }, ], }, ], }, ], }, ], }; const { body } = await session.testAgent.post(`/v1/workflows`).send(templateRequestPayload); expect(body.data).to.be.ok; const templateRequestResult: INotificationTemplate = body.data; expect(templateRequestResult._notificationGroupId).to.equal(templateRequestPayload.notificationGroupId); const message = templateRequestResult.steps[0] as INotificationTemplateStep; const messageRequest = templateRequestPayload?.steps ? templateRequestPayload?.steps[0] : null; const filtersTest = messageRequest?.filters ? messageRequest.filters[0] : null; const children: IFieldFilterPart = filtersTest?.children[0] as IFieldFilterPart; const template = message?.template; expect(message?.template?.name).to.equal(`${messageRequest?.template?.name}`); expect(message?.template?.active).to.equal(defaultMessageIsActive); expect(message?.template?.subject).to.equal(`${messageRequest?.template?.subject}`); expect(message?.template?.preheader).to.equal(`${messageRequest?.template?.preheader}`); expect(message?.template?.senderName).to.equal(`${messageRequest?.template?.senderName}`); const filters = message?.filters ? message?.filters[0] : null; expect(filters?.type).to.equal(filtersTest?.type); expect(filters?.children.length).to.equal(filtersTest?.children?.length); expect(children.value).to.equal(children.value); expect(children.operator).to.equal(children.operator); expect(templateRequestResult.tags[0]).to.equal('test-tag'); const variantRequest = messageRequest?.variants ? messageRequest?.variants[0] : null; const variantResult = (templateRequestResult.steps[0] as INotificationTemplateStep)?.variants ? (templateRequestResult.steps as INotificationTemplateStep)[0]?.variants[0] : null; expect(variantResult?.template?.name).to.equal(variantRequest?.template?.name); expect(variantResult?.template?.active).to.equal(variantRequest?.active); expect(variantResult?.template?.subject).to.equal(variantRequest?.template?.subject); expect(variantResult?.template?.preheader).to.equal(variantRequest?.template?.preheader); expect(variantResult?.template?.senderName).to.equal(variantRequest?.template?.senderName); if (Array.isArray(message?.template?.content) && Array.isArray(messageRequest?.template?.content)) { expect(message?.template?.content[0].type).to.equal(messageRequest?.template?.content[0].type); } else { throw new Error('content must be an array'); } let change = await changeRepository.findOne({ _environmentId: session.environment._id, _entityId: message._templateId, }); await session.testAgent.post(`/v1/changes/${change?._id}/apply`); change = await changeRepository.findOne({ _environmentId: session.environment._id, _entityId: templateRequestResult._id, }); await session.testAgent.post(`/v1/changes/${change?._id}/apply`); const prodEnv = await getProductionEnvironment(); if (!prodEnv) throw new Error('prodEnv was not found'); const prodVersionNotification = await notificationTemplateRepository.findOne({ _environmentId: prodEnv._id, _parentId: templateRequestResult._id, }); expect(prodVersionNotification?.tags[0]).to.equal(templateRequestResult.tags[0]); expect(prodVersionNotification?.steps.length).to.equal(templateRequestResult.steps.length); expect(prodVersionNotification?.triggers[0].type).to.equal(templateRequestResult.triggers[0].type); expect(prodVersionNotification?.triggers[0].identifier).to.equal(templateRequestResult.triggers[0].identifier); expect(prodVersionNotification?.active).to.equal(templateRequestResult.active); expect(prodVersionNotification?.draft).to.equal(templateRequestResult.draft); expect(prodVersionNotification?.name).to.equal(templateRequestResult.name); expect(prodVersionNotification?.description).to.equal(templateRequestResult.description); const prodVersionMessage = await messageTemplateRepository.findOne({ _environmentId: prodEnv._id, _parentId: message._templateId, }); expect(message?.template?.name).to.equal(prodVersionMessage?.name); expect(message?.template?.subject).to.equal(prodVersionMessage?.subject); expect(message?.template?.type).to.equal(prodVersionMessage?.type); expect(message?.template?.content).to.deep.equal(prodVersionMessage?.content); expect(message?.template?.active).to.equal(prodVersionMessage?.active); expect(message?.template?.preheader).to.equal(prodVersionMessage?.preheader); expect(message?.template?.senderName).to.equal(prodVersionMessage?.senderName); const prodVersionVariant = await messageTemplateRepository.findOne({ _environmentId: prodEnv._id, _parentId: variantResult._templateId, }); expect(variantResult?.template?.name).to.equal(prodVersionVariant?.name); expect(variantResult?.template?.subject).to.equal(prodVersionVariant?.subject); expect(variantResult?.template?.type).to.equal(prodVersionVariant?.type); expect(variantResult?.template?.content).to.deep.equal(prodVersionVariant?.content); expect(variantResult?.template?.active).to.equal(prodVersionVariant?.active); expect(variantResult?.template?.preheader).to.equal(prodVersionVariant?.preheader); expect(variantResult?.template?.senderName).to.equal(prodVersionVariant?.senderName); }); it('should create a valid notification', async () => { const testTemplate: Partial = { name: 'test template', description: 'This is a test description', notificationGroupId: session.notificationGroups[0]._id, steps: [ { template: { name: 'Message Name', content: 'Test Template', type: StepTypeEnum.IN_APP, cta: { type: ChannelCTATypeEnum.REDIRECT, data: { url: 'https://example.org/profile', }, }, }, }, ], }; const { body } = await session.testAgent.post(`/v1/workflows`).send(testTemplate); expect(body.data).to.be.ok; const template: INotificationTemplate = body.data; expect(template._id).to.be.ok; expect(template.description).to.equal(testTemplate.description); expect(template.name).to.equal(testTemplate.name); expect(template.draft).to.equal(true); expect(template.active).to.equal(false); expect(isSameDay(new Date(template?.createdAt ? template?.createdAt : '1970'), new Date())); const step = template?.steps[0] as INotificationTemplateStep; expect(template.steps.length).to.equal(1); expect(step?.template?.type).to.equal(ChannelTypeEnum.IN_APP); expect(step?.template?.content).to.equal(testTemplate?.steps?.[0]?.template?.content); expect(step?.template?.cta?.data.url).to.equal(testTemplate?.steps?.[0]?.template?.cta?.data.url); }); it('should create event trigger', async () => { const testTemplate: Partial = { name: 'test template', notificationGroupId: session.notificationGroups[0]._id, description: 'This is a test description', steps: [ { active: false, template: { name: 'Message Name', content: 'Test Template {{name}} {{lastName}}', type: StepTypeEnum.IN_APP, cta: { type: ChannelCTATypeEnum.REDIRECT, data: { url: 'https://example.org/profile', }, }, }, }, ], }; const { body } = await session.testAgent.post(`/v1/workflows`).send(testTemplate); expect(body.data).to.be.ok; const template: INotificationTemplate = body.data; expect(template.active).to.equal(false); expect(template.triggers.length).to.equal(1); expect(template.triggers[0].identifier).to.include('test'); expect(template.triggers[0].type).to.equal(TriggerTypeEnum.EVENT); }); it('should only add shortid to trigger identifier if same identifier exists', async () => { const testTemplate: Partial = { name: 'test', notificationGroupId: session.notificationGroups[0]._id, description: 'This is a test description', steps: [], }; const { body } = await session.testAgent.post(`/v1/workflows`).send(testTemplate); expect(body.data).to.be.ok; const template: INotificationTemplate = body.data; expect(template.triggers[0].identifier).to.equal('test'); const sameNameTemplate: Partial = { name: 'test', notificationGroupId: session.notificationGroups[0]._id, description: 'This is a test description', steps: [], }; const { body: newBody } = await session.testAgent.post(`/v1/workflows`).send(sameNameTemplate); expect(newBody.data).to.be.ok; const newTemplate: INotificationTemplate = newBody.data; expect(newTemplate.triggers[0].identifier).to.include('test-'); }); it('should add parentId to step', async () => { const testTemplate: Partial = { name: 'test template', description: 'This is a test description', notificationGroupId: session.notificationGroups[0]._id, steps: [ { template: { type: StepTypeEnum.IN_APP, content: 'Test Template', cta: { type: ChannelCTATypeEnum.REDIRECT, data: { url: 'https://example.org/profile', }, }, }, }, { template: { type: StepTypeEnum.IN_APP, content: 'Test Template', cta: { type: ChannelCTATypeEnum.REDIRECT, data: { url: 'https://example.org/profile', }, }, }, }, ], }; const { body } = await session.testAgent.post(`/v1/workflows`).send(testTemplate); expect(body.data).to.be.ok; const template: INotificationTemplate = body.data; const steps = template.steps as INotificationTemplateStep[]; expect(steps[0]._parentId).to.equal(null); expect(steps[0]._id).to.equal(steps[1]._parentId); }); it('should use sender name in email template', async () => { const testTemplate: Partial = { name: 'test email template', description: 'This is a test description', tags: ['test-tag'], notificationGroupId: session.notificationGroups[0]._id, steps: [ { template: { name: 'Message Name', subject: 'Test email subject', preheader: 'Test email preheader', senderName: 'test', content: [{ type: EmailBlockTypeEnum.TEXT, content: 'This is a sample text block' }], type: StepTypeEnum.EMAIL, }, filters: [], }, ], }; const { body } = await session.testAgent.post(`/v1/workflows`).send(testTemplate); expect(body.data).to.be.ok; const template: INotificationTemplate = body.data; expect(template._notificationGroupId).to.equal(testTemplate.notificationGroupId); const message = template.steps[0] as INotificationTemplateStep; expect(message.template?.senderName).to.equal('test'); }); xit('should build factory integration', () => { // const instance = testServer.getService(SendMessageEmail); const instance: any = {}; let result = instance.buildFactoryIntegration({ _environmentId: '', _organizationId: '', providerId: EmailProviderIdEnum.SendGrid, channel: ChannelTypeEnum.EMAIL, credentials: { senderName: 'credentials', }, active: false, deleted: false, deletedAt: '', deletedBy: '', }); expect(result.credentials.senderName).to.equal('credentials'); result = instance.buildFactoryIntegration( { _environmentId: '', _organizationId: '', providerId: EmailProviderIdEnum.SendGrid, channel: ChannelTypeEnum.EMAIL, credentials: { senderName: 'credentials', }, active: false, deleted: false, deletedAt: '', deletedBy: '', }, '' ); expect(result.credentials.senderName).to.equal('credentials'); result = instance.buildFactoryIntegration( { _environmentId: '', _organizationId: '', providerId: EmailProviderIdEnum.SendGrid, channel: ChannelTypeEnum.EMAIL, credentials: { senderName: 'credentials', }, active: false, deleted: false, deletedAt: '', deletedBy: '', }, 'senderName' ); expect(result.credentials.senderName).to.equal('senderName'); }); it('should not promote deleted template that is not existing in prod', async () => { const testTemplate: Partial = { name: 'test email template', description: 'This is a test description', tags: ['test-tag'], notificationGroupId: session.notificationGroups[0]._id, steps: [], }; const { body } = await session.testAgent.post(`/v1/workflows`).send(testTemplate); expect(body.data).to.be.ok; const template: INotificationTemplate = body.data; await session.testAgent.delete(`/v1/workflows/${template._id}`).send(); const change = await changeRepository.findOne({ _environmentId: session.environment._id, _entityId: template._id }); await session.testAgent.post(`/v1/changes/${change?._id}/apply`); const prodEnv = await getProductionEnvironment(); if (!prodEnv) throw new Error('prodEnv was not found'); const prodVersionNotification = await notificationTemplateRepository.findOne({ _environmentId: prodEnv._id, _parentId: template._id, }); expect(prodVersionNotification).to.equal(null); }); async function getProductionEnvironment() { return await environmentRepository.findOne({ _parentId: session.environment._id, }); } }); describe('Create Notification template from blueprint - /notification-templates (POST)', async () => { let session: UserSession; const notificationTemplateRepository: NotificationTemplateRepository = new NotificationTemplateRepository(); const environmentRepository: EnvironmentRepository = new EnvironmentRepository(); const organizationRepository: OrganizationRepository = new OrganizationRepository( new CommunityOrganizationRepository() ); before(async () => { session = new UserSession(); await session.initialize(); }); it('should create template from blueprint', async () => { const prodEnv = await getProductionEnvironment(); const { testTemplateRequestDto, testTemplate, blueprintId, createdTemplate } = await createTemplateFromBlueprint({ session, notificationTemplateRepository, prodEnv, }); expect(createdTemplate.blueprintId).to.equal(blueprintId); expect(testTemplateRequestDto.name).to.equal(createdTemplate.name); const fetchedTemplate = (await session.testAgent.get(`/v1/blueprints/${blueprintId}`).send()).body.data; expect(fetchedTemplate.isBlueprint).to.equal(true); expect(testTemplateRequestDto.name).to.equal(fetchedTemplate.name); expect(createdTemplate.blueprintId).to.equal(fetchedTemplate._id); const response = await session.testAgent.get(`/v1/blueprints/${testTemplate._id}`).send(); expect(response.body.statusCode).to.equal(404); }); it('should create notification group change from blueprint creation', async () => { const prodEnv = await getProductionEnvironment(); const { blueprintId } = await buildBlueprint(session, prodEnv, notificationTemplateRepository); const blueprint = (await session.testAgent.get(`/v1/blueprints/${blueprintId}`).send()).body.data; if (isClerkEnabled()) { process.env.BLUEPRINT_CREATOR = session.organization._id; } else { const blueprintOrg = await organizationRepository.create({ name: 'Blueprint Org' }); process.env.BLUEPRINT_CREATOR = blueprintOrg._id; } blueprint.notificationGroupId = blueprint._notificationGroupId; blueprint.notificationGroup.name = 'New Group Name'; blueprint.blueprintId = blueprint._id; const noChanges = (await session.testAgent.get(`/v1/changes?promoted=false`)).body.data; expect(noChanges.length).to.equal(0); await session.testAgent.post(`/v1/workflows`).send({ ...blueprint }); const newWorkflowChanges = (await session.testAgent.get(`/v1/changes?promoted=false`)).body.data; expect(newWorkflowChanges.length).to.equal(2); expect(newWorkflowChanges[0].type).to.equal(ChangeEntityTypeEnum.NOTIFICATION_TEMPLATE); expect(newWorkflowChanges[1].type).to.equal(ChangeEntityTypeEnum.NOTIFICATION_GROUP); }); it('should create workflow from blueprint (full blueprint mock)', async () => { const createdTemplate: NotificationTemplateEntity = ( await session.testAgent.post(`/v1/workflows`).send(blueprintTemplateMock) ).body.data; expect(createdTemplate.blueprintId).to.equal(blueprintTemplateMock.blueprintId); expect(createdTemplate.isBlueprint).to.equal(false); expect(createdTemplate.name).to.equal(blueprintTemplateMock.name); expect(createdTemplate.steps.length).to.equal(blueprintTemplateMock.steps.length); expect(createdTemplate._notificationGroupId).to.not.equal(blueprintTemplateMock.notificationGroupId); const inAppStep = createdTemplate.steps.find((step) => step.template?.type === StepTypeEnum.IN_APP); expect(inAppStep?.template?._feedId).to.be.equal(null); }); async function getProductionEnvironment() { return await environmentRepository.findOne({ _parentId: session.environment._id, }); } }); async function buildBlueprint(session, prodEnv, notificationTemplateRepository) { const testTemplateRequestDto: Partial = { name: 'test email template', description: 'This is a test description', tags: ['test-tag'], notificationGroupId: session.notificationGroups[0]._id, steps: [ { template: { name: 'Message Name', subject: 'Test email subject', preheader: 'Test email preheader', content: [{ type: EmailBlockTypeEnum.TEXT, content: 'This is a sample text block' }], type: StepTypeEnum.EMAIL, }, filters: [ { isNegated: false, type: 'GROUP', value: FieldLogicalOperatorEnum.AND, children: [ { on: FilterPartTypeEnum.SUBSCRIBER, field: 'firstName', value: 'test value', operator: FieldOperatorEnum.EQUAL, }, ], }, ], }, ], }; const testTemplate = (await session.testAgent.post(`/v1/workflows`).send(testTemplateRequestDto)).body.data; process.env.BLUEPRINT_CREATOR = session.organization._id; const testEnvBlueprintTemplate = (await session.testAgent.post(`/v1/workflows`).send(testTemplateRequestDto)).body .data; expect(testEnvBlueprintTemplate).to.be.ok; await session.applyChanges({ enabled: false, }); if (!prodEnv) throw new Error('production environment was not found'); const blueprintId = ( await notificationTemplateRepository.findOne({ _environmentId: prodEnv._id, _parentId: testEnvBlueprintTemplate._id, }) )?._id; if (!blueprintId) throw new Error('blueprintId was not found'); return { testTemplateRequestDto, testTemplate, blueprintId }; } export async function createTemplateFromBlueprint({ session, notificationTemplateRepository, prodEnv, overrides = {}, }: { session: UserSession; notificationTemplateRepository: NotificationTemplateRepository; prodEnv; overrides?: Partial; }) { const { testTemplateRequestDto, testTemplate, blueprintId } = await buildBlueprint( session, prodEnv, notificationTemplateRepository ); const blueprint = (await session.testAgent.get(`/v1/blueprints/${blueprintId}`).send()).body.data; blueprint.notificationGroupId = blueprint._notificationGroupId; blueprint.blueprintId = blueprint._id; const createdTemplate = (await session.testAgent.post(`/v1/workflows`).send({ ...blueprint })).body.data; return { testTemplateRequestDto, testTemplate, blueprintId, createdTemplate, }; } const blueprintTemplateMock = { // _id: '64731d4e1084f5a48293ceab', blueprintId: '64731d4e1084f5a48293ceab', name: 'Mention in a comment', active: true, draft: false, critical: false, isBlueprint: true, notificationGroupId: '64731d4e1084f5a48293ce85', tags: [], triggers: [ { type: 'event', identifier: 'fa-solid-fa-comment-mention-in-a-comment', variables: [ { name: 'commenterName', type: 'String', _id: '65ee069a319fc6a92cf436d5', }, { name: 'commentSnippet', type: 'String', _id: '65ee069a319fc6a92cf436d6', }, { name: 'commentLink', type: 'String', _id: '65ee069a319fc6a92cf436d7', }, ], reservedVariables: [], subscriberVariables: [ { name: 'email', _id: '65ee069a319fc6a92cf436d4', }, ], _id: '64731d1c1084f5a48293cd4a', }, ], steps: [ { active: true, shouldStopOnFail: false, uuid: 'b6944995-a283-46bd-b55a-18625fd1d4fd', name: 'In-App', type: ResourceTypeEnum.REGULAR, filters: [ { children: [], _id: '6485b9052a50bb49867584a0', }, ], _templateId: '6485b92e2a50bb4986758656', _parentId: null, metadata: { timed: { weekDays: [], monthDays: [], }, }, variants: [], _id: '6485b9052a50bb498675846d', template: { _id: '6485b92e2a50bb4986758656', type: 'in_app', active: true, subject: '', variables: [ { name: 'commenterName', type: 'String', required: false, _id: '6485b9052a50bb498675846e', }, { name: 'commentSnippet', type: 'String', required: false, _id: '6485b9052a50bb498675846f', }, ], content: '{{commenterName}} has mentioned you in "{{commentSnippet}}" ', contentType: 'editor', cta: { data: { url: '', }, type: 'redirect', }, _environmentId: '64731b391084f5a48293cb87', _organizationId: '64731b391084f5a48293cb5b', _creatorId: '64731b331084f5a48293cb52', _parentId: '6485b9052a50bb498675846d', _layoutId: null, _feedId: '64731b331084f5a48293cb52', feedId: '64731b331084f5a48293cb52', deleted: false, createdAt: '2023-06-11T12:08:14.446Z', updatedAt: '2024-03-10T19:14:45.347Z', __v: 0, actor: { type: 'none', data: null, }, }, }, { active: true, shouldStopOnFail: false, uuid: '642e42b5-51e6-4d3b-8a91-067c29e902d4', name: 'Digest', type: ResourceTypeEnum.REGULAR, filters: [], _templateId: '6485b92e2a50bb4986758662', _parentId: '6485b9052a50bb498675846d', metadata: { amount: 30, unit: 'minutes', type: 'regular', backoffUnit: 'minutes', backoffAmount: 5, backoff: true, timed: { weekDays: [], monthDays: [], }, }, variants: [], _id: '6485b9052a50bb4986758479', template: { _id: '6485b92e2a50bb4986758662', type: 'digest', active: true, subject: '', variables: [], content: '', contentType: 'editor', _environmentId: '64731b391084f5a48293cb87', _organizationId: '64731b391084f5a48293cb5b', _creatorId: '64731b331084f5a48293cb52', _parentId: '6485b9052a50bb4986758479', _layoutId: null, deleted: false, createdAt: '2023-06-11T12:08:14.520Z', updatedAt: '2024-03-10T19:14:45.377Z', __v: 0, }, }, { active: true, replyCallback: { active: true, url: 'https://webhook.com/reply-callback', }, shouldStopOnFail: false, uuid: '671d86ec-dc27-413c-a666-ec4aeb191691', name: 'Email', type: ResourceTypeEnum.REGULAR, filters: [ { value: 'AND', children: [ { operator: 'EQUAL', on: 'previousStep', step: 'b6944995-a283-46bd-b55a-18625fd1d4fd', stepType: 'unseen', _id: '6485b9052a50bb49867584a4', }, ], _id: '6485b9052a50bb49867584a3', }, ], _templateId: '6485b92e2a50bb4986758671', _parentId: '6485b9052a50bb4986758479', metadata: { timed: { weekDays: [], monthDays: [], }, }, variants: [], _id: '6485b9052a50bb4986758481', template: { _id: '6485b92e2a50bb4986758671', type: 'email', active: true, subject: '{{mentionedUser}} mention you in {{resourceName}}', variables: [ { name: 'mentionedUser', type: 'String', required: false, _id: '6485b9052a50bb4986758482', }, { name: 'resourceName', type: 'String', required: false, _id: '6485b9052a50bb4986758483', }, { name: 'commentLink', type: 'String', required: false, _id: '6485b9052a50bb4986758484', }, { name: 'step.digest', type: 'Boolean', required: false, defaultValue: true, _id: '6485b9052a50bb4986758485', }, { name: 'step.events.0.mentionedUser', type: 'String', required: false, _id: '6485b9052a50bb4986758486', }, { name: 'step.total_count', type: 'String', required: false, _id: '6485b9052a50bb4986758487', }, ], content: '{{#if step.digest}}\n {{step.events.0.mentionedUser}} and {{step.total_count}} others mentioned you in a comment. \n{{else}}\n {{mentionedUser}} mentioned you in a comment. \n{{/if}}\n \n

\n\n\n\n
\n\n{{#unless step.digest}}\n You can reply to this email, and the email contents will be posted as a comment reply to this post.\n{{/unless}}\n', contentType: 'customHtml', _environmentId: '64731b391084f5a48293cb87', _organizationId: '64731b391084f5a48293cb5b', _creatorId: '64731b331084f5a48293cb52', _parentId: '6485b9052a50bb4986758481', _layoutId: '64731d4e1084f5a48293ce8f', deleted: false, createdAt: '2023-06-11T12:08:14.551Z', updatedAt: '2024-03-10T19:14:45.409Z', __v: 0, preheader: '', senderName: '', }, }, ], preferenceSettings: { email: true, sms: true, in_app: true, chat: true, push: true, }, _environmentId: '64731b391084f5a48293cb87', _organizationId: '64731b391084f5a48293cb5b', _creatorId: '64731b331084f5a48293cb52', _parentId: '64731d1c1084f5a48293cd49', deleted: false, createdAt: '2023-05-28T09:22:22.586Z', updatedAt: '2024-03-10T19:14:45.442Z', __v: 0, deletedAt: '2023-05-30T12:55:34.842Z', notificationGroup: { _id: '64731d4e1084f5a48293ce85', name: 'General', _organizationId: '64731b391084f5a48293cb5b', _environmentId: '64731b391084f5a48293cb87', _parentId: '64731b391084f5a48293cb65', createdAt: '2023-05-28T09:22:22.381Z', updatedAt: '2023-05-28T09:22:22.381Z', __v: 0, }, }; ================================================ FILE: apps/api/src/app/workflows-v1/e2e/delete-notification-template.e2e.ts ================================================ import { ChannelTypeEnum, EnvironmentRepository, MessageTemplateRepository, NotificationGroupRepository, NotificationTemplateRepository, } from '@novu/dal'; import { ChannelCTATypeEnum } from '@novu/shared'; import { NotificationTemplateService, UserSession } from '@novu/testing'; import { expect } from 'chai'; describe('Delete workflow by id - /workflows/:workflowId (DELETE) #novu-v0', async () => { let session: UserSession; const notificationTemplateRepository = new NotificationTemplateRepository(); const notificationGroupRepository: NotificationGroupRepository = new NotificationGroupRepository(); const environmentRepository: EnvironmentRepository = new EnvironmentRepository(); const messageTemplateRepository: MessageTemplateRepository = new MessageTemplateRepository(); before(async () => { session = new UserSession(); await session.initialize(); }); it('should delete the workflow', async () => { const notificationTemplateService = new NotificationTemplateService( session.user._id, session.organization._id, session.environment._id ); const template = await notificationTemplateService.createTemplate(); await session.testAgent.delete(`/v1/workflows/${template._id}`).send(); const isDeleted = !(await notificationTemplateRepository.findOne({ _environmentId: session.environment._id, _id: template._id, })); expect(isDeleted).to.equal(true); const deletedIntegration = ( await notificationTemplateRepository.findDeleted({ _environmentId: session.environment._id, _id: template._id }) )[0]; expect(deletedIntegration.deleted).to.equal(true); }); it('should delete the production workflow', async () => { const groups = await notificationGroupRepository.find({ _environmentId: session.environment._id, }); const testTemplate = { name: 'test email template', description: 'This is a test description', tags: ['test-tag'], notificationGroupId: groups[0]._id, steps: [], }; const { body } = await session.testAgent.post(`/v1/workflows`).send(testTemplate); const notificationTemplateId = body.data._id; await session.applyChanges({ enabled: false, }); const prodEnv = await getProductionEnvironment(session.environment._id); if (!prodEnv) { throw new Error('No env found'); } const isCreated = await notificationTemplateRepository.findOne({ _environmentId: prodEnv._id, _parentId: notificationTemplateId, }); expect(isCreated).to.exist; await session.testAgent.delete(`/v1/workflows/${notificationTemplateId}`).send(); const { body: { data }, } = await session.testAgent.get(`/v1/changes?promoted=false`); expect(data[0].templateName).to.eq(body.data.name); await session.applyChanges({ enabled: false, }); const isDeleted = await notificationTemplateRepository.findOne({ _environmentId: prodEnv._id, _parentId: notificationTemplateId, }); expect(!isDeleted).to.equal(true); }); it('should only make one change on delete', async () => { const groups = await notificationGroupRepository.find({ _environmentId: session.environment._id, }); const testTemplate = { name: 'test email template', description: 'This is a test description', tags: ['test-tag'], notificationGroupId: groups[0]._id, steps: [], }; const { body } = await session.testAgent.post(`/v1/workflows`).send(testTemplate); const notificationTemplateId = body.data._id; await session.testAgent.delete(`/v1/workflows/${notificationTemplateId}`).send(); const { body: { data }, } = await session.testAgent.get(`/v1/changes?promoted=false`); expect(data[0].templateName).to.eq(body.data.name); expect(data.length).to.eq(1); }); it('should not display on listing workflows', async () => { const notificationTemplateService = new NotificationTemplateService( session.user._id, session.organization._id, session.environment._id ); const template1 = await notificationTemplateService.createTemplate(); await notificationTemplateService.createTemplate(); await notificationTemplateService.createTemplate(); const { body: templates } = await session.testAgent.get(`/v1/workflows`); expect(templates.data.length).to.equal(3); await session.testAgent.delete(`/v1/workflows/${template1._id}`).send(); const { body: templatesAfterDelete } = await session.testAgent.get(`/v1/workflows`); expect(templatesAfterDelete.data.length).to.equal(2); }); it('should fail for non-existing workflow', async () => { const dummyId = '5f6651112efc19f33b34fc39'; const response = await session.testAgent.delete(`/v1/workflows/${dummyId}`).send(); expect(response.body.message).to.contains('Workflow cannot be found'); }); it('should delete the workflow along with the message templates', async () => { const notificationTemplateService = new NotificationTemplateService( session.user._id, session.organization._id, session.environment._id ); const template = await notificationTemplateService.createTemplate(); const messageTemplateIds = template.steps.map((step) => step._templateId); const messageTemplates = await messageTemplateRepository.find({ _environmentId: session.environment._id, _id: { $in: messageTemplateIds }, }); expect(messageTemplates.length).to.equal(2); await session.testAgent.delete(`/v1/workflows/${template._id}`).send(); const deletedNotificationTemplate = await notificationTemplateRepository.findOne({ _environmentId: session.environment._id, _id: template._id, }); expect(deletedNotificationTemplate).to.equal(null); const deletedIntegration = ( await notificationTemplateRepository.findDeleted({ _environmentId: session.environment._id, _id: template._id }) )[0]; expect(deletedIntegration.deleted).to.equal(true); const deletedMessageTemplates = await messageTemplateRepository.find({ _environmentId: session.environment._id, _id: { $in: messageTemplateIds }, }); expect(deletedMessageTemplates.length).to.equal(0); }); it('should delete the production message templates', async () => { const groups = await notificationGroupRepository.find({ _environmentId: session.environment._id, }); const testTemplate = { name: 'test email template', description: 'This is a test description', tags: ['test-tag'], notificationGroupId: groups[0]._id, steps: [ { template: { type: ChannelTypeEnum.IN_APP, content: 'Test content for {{firstName}}', cta: { type: ChannelCTATypeEnum.REDIRECT, data: { url: '/cypress/test-shell/example/test?test-param=true', }, }, variables: [ { defaultValue: '', name: 'firstName', required: false, type: 'String', }, ], }, }, ], }; const { body } = await session.testAgent.post(`/v1/workflows`).send(testTemplate); const notificationTemplate = body.data; const notificationTemplateId = body.data._id; const messageTemplateId = notificationTemplate.steps[0]._templateId; await session.applyChanges({ enabled: false, }); const prodEvn = await getProductionEnvironment(session.environment._id); const isNotificationTemplatePromoted = await notificationTemplateRepository.findOne({ _environmentId: prodEvn._id, _parentId: notificationTemplateId, }); expect(isNotificationTemplatePromoted).to.exist; const isMessageTemplatePromoted = await messageTemplateRepository.findOne({ _environmentId: prodEvn._id, _parentId: messageTemplateId, }); expect(isMessageTemplatePromoted).to.exist; await session.testAgent.delete(`/v1/workflows/${notificationTemplateId}`).send(); const { body: { data }, } = await session.testAgent.get(`/v1/changes?promoted=false`); expect(data[0].templateName).to.eq(body.data.name); await session.applyChanges({ enabled: false, }); const isNotificationTemplateExists = await notificationTemplateRepository.findOne({ _environmentId: prodEvn._id, _parentId: notificationTemplateId, }); expect(isNotificationTemplateExists).to.not.exist; const isMessageTemplateExists = await notificationTemplateRepository.findOne({ _environmentId: prodEvn._id, _parentId: messageTemplateId, }); expect(isMessageTemplateExists).to.not.exist; }); async function getProductionEnvironment(currentEnvId: string) { return await environmentRepository.findOne({ _parentId: currentEnvId, }); } }); ================================================ FILE: apps/api/src/app/workflows-v1/e2e/get-notification-template.e2e.ts ================================================ import { PreferencesRepository } from '@novu/dal'; import { ChannelCTATypeEnum, INotificationTemplate, INotificationTemplateStep, StepTypeEnum } from '@novu/shared'; import { NotificationTemplateService, UserSession } from '@novu/testing'; import { expect } from 'chai'; import { CreateWorkflowRequestDto } from '../dtos'; describe('Get workflow by id - /workflows/:workflowId (GET) #novu-v0', async () => { let session: UserSession; before(async () => { session = new UserSession(); await session.initialize(); }); it('should return the workflow by its id', async () => { const notificationTemplateService = new NotificationTemplateService( session.user._id, session.organization._id, session.environment._id ); const template = await notificationTemplateService.createTemplate(); const { body } = await session.testAgent.get(`/v1/workflows/${template._id}`); const foundTemplate: INotificationTemplate = body.data; expect(foundTemplate._id).to.equal(template._id); expect(foundTemplate.name).to.equal(template.name); expect(foundTemplate.steps.length).to.equal(template.steps.length); const step = foundTemplate.steps[0] as INotificationTemplateStep; expect(step.template).to.be.ok; expect(step.template?.content).to.equal(template.steps[0].template?.content); expect(step._templateId).to.be.ok; expect(foundTemplate.triggers.length).to.equal(template.triggers.length); }); it('should return the workflow preference settings when the V2 Preferences do not exist', async () => { const testTemplate = { name: 'test template', description: 'This is a test description', notificationGroupId: session.notificationGroups[0]._id, steps: [ { template: { name: 'Message Name', content: 'Test Template', type: StepTypeEnum.IN_APP, cta: { type: ChannelCTATypeEnum.REDIRECT, data: { url: 'https://example.org/profile', }, }, }, }, ], preferenceSettings: { in_app: true, sms: true, push: true, chat: true, email: false, }, tags: [], } satisfies CreateWorkflowRequestDto; const { body: postWorkflowResponse } = await session.testAgent.post(`/v1/workflows`).send(testTemplate); const preferenceRepository = new PreferencesRepository(); await preferenceRepository.delete({ _environmentId: session.environment._id, _templateId: postWorkflowResponse.data._id, }); const { body: getWorkflowResponse } = await session.testAgent.get(`/v1/workflows/${postWorkflowResponse.data._id}`); expect(getWorkflowResponse.data).to.be.ok; const template: INotificationTemplate = getWorkflowResponse.data; expect(template.preferenceSettings).to.deep.equal(testTemplate.preferenceSettings); }); }); ================================================ FILE: apps/api/src/app/workflows-v1/e2e/get-notification-templates.e2e.ts ================================================ import { NotificationTemplateEntity } from '@novu/dal'; import { ChannelCTATypeEnum, FieldLogicalOperatorEnum, FieldOperatorEnum, FilterPartTypeEnum, StepTypeEnum, TemplateVariableTypeEnum, TriggerTypeEnum, } from '@novu/shared'; import { NotificationTemplateService, UserSession } from '@novu/testing'; import { expect } from 'chai'; describe('Get workflows - /workflows (GET) #novu-v0', async () => { let session: UserSession; const templates: NotificationTemplateEntity[] = []; let notificationTemplateService: NotificationTemplateService; before(async () => { session = new UserSession(); await session.initialize(); notificationTemplateService = new NotificationTemplateService( session.user._id, session.organization._id, session.environment._id ); templates.push( await notificationTemplateService.createTemplate({ steps: [ { type: StepTypeEnum.IN_APP, content: 'Test content for {{firstName}}', cta: { type: ChannelCTATypeEnum.REDIRECT, data: { url: '/cypress/test-shell/example/test?test-param=true', }, }, variables: [ { defaultValue: '', name: 'firstName', required: false, type: TemplateVariableTypeEnum.STRING, }, ], variants: [ { name: 'In-App', subject: 'test', type: StepTypeEnum.IN_APP, content: '', contentType: 'editor', variables: [], active: true, filters: [ { value: FieldLogicalOperatorEnum.OR, children: [ { operator: FieldOperatorEnum.EQUAL, on: FilterPartTypeEnum.PAYLOAD, field: 'ef', value: 'dsf', }, ], }, ], }, ], }, ], }) ); templates.push(await notificationTemplateService.createTemplate()); templates.push(await notificationTemplateService.createTemplate()); }); it('should return all workflows for organization', async () => { const { body } = await session.testAgent.get(`/v1/workflows`); expect(body.data.length).to.equal(3); const found = body.data.find((i) => templates[0]._id === i._id); expect(found).to.be.ok; expect(found.name).to.equal(templates[0].name); expect(found.notificationGroup.name).to.equal('General'); }); it('should not include variants data in the response', async () => { const { body } = await session.testAgent.get(`/v1/workflows`); expect(body.data.length).to.equal(3); const found = body.data.find((i) => templates[0]._id === i._id); expect(found).to.be.ok; expect(found.name).to.equal(templates[0].name); expect(found.notificationGroup.name).to.equal('General'); expect(found.steps[0].variants).to.be.undefined; }); it('should return all workflows as per pagination', async () => { templates.push(await notificationTemplateService.createTemplate()); templates.push(await notificationTemplateService.createTemplate()); templates.push(await notificationTemplateService.createTemplate()); const { body: page0Limit2Results } = await session.testAgent.get(`/v1/workflows?page=0&limit=2`); expect(page0Limit2Results.data.length).to.equal(2); expect(page0Limit2Results.totalCount).to.equal(6); expect(page0Limit2Results.page).to.equal(0); expect(page0Limit2Results.pageSize).to.equal(2); expect(page0Limit2Results.data[0]._id).to.equal(templates[5]._id); const { body: page1Limit3Results } = await session.testAgent.get(`/v1/workflows?page=1&limit=3`); expect(page1Limit3Results.data.length).to.equal(3); expect(page1Limit3Results.totalCount).to.equal(6); expect(page1Limit3Results.page).to.equal(1); expect(page1Limit3Results.pageSize).to.equal(3); expect(page1Limit3Results.data[2]._id).to.equal(templates[0]._id); }); it('should paginate and filter workflows based on the name', async () => { const promises: Promise[] = []; const count = 10; for (let i = 0; i < count; i += 1) { promises.push( notificationTemplateService.createTemplate({ name: `Pagination Test ${i}`, }) ); } await Promise.all(promises); const { body } = await session.testAgent.get(`/v1/workflows?page=0&limit=2&query=Pagination+Test`); expect(body.data.length).to.equal(2); expect(body.totalCount).to.equal(count); expect(body.page).to.equal(0); expect(body.pageSize).to.equal(2); for (let i = 0; i < 2; i += 1) { expect(body.data[i].name).to.contain('Pagination Test'); } }); it('should filter workflows based on the name', async () => { const promises: Promise[] = []; const count = 10; for (let i = 0; i < count; i += 1) { promises.push( notificationTemplateService.createTemplate({ name: `Test Template ${i}`, }) ); } await Promise.all(promises); const { body } = await session.testAgent.get(`/v1/workflows?page=0&limit=100&query=Test+Template`); expect(body.data.length).to.equal(count); expect(body.totalCount).to.equal(count); expect(body.page).to.equal(0); expect(body.pageSize).to.equal(100); for (let i = 0; i < count; i += 1) { expect(body.data[i].name).to.contain('Test Template'); } }); it('should filter workflows based on the trigger identifier', async () => { const promises: Promise[] = []; const count = 10; const triggerIdentifier = 'test-trigger-identifier'; for (let i = 0; i < count; i += 1) { promises.push( notificationTemplateService.createTemplate({ triggers: [{ identifier: `${triggerIdentifier}-${i}`, type: TriggerTypeEnum.EVENT, variables: [] }], }) ); } await Promise.all(promises); const { body } = await session.testAgent.get(`/v1/workflows?page=0&limit=100&query=${triggerIdentifier}`); expect(body.data.length).to.equal(count); expect(body.totalCount).to.equal(count); expect(body.page).to.equal(0); expect(body.pageSize).to.equal(100); for (let i = 0; i < count; i += 1) { expect(body.data[i].triggers[0].identifier).to.contain(`${triggerIdentifier}`); } }); it('should filter workflows based on both the name and trigger identifier', async () => { const promises: Promise[] = []; const count = 10; for (let i = 0; i < count; i += 1) { if (i % 2 === 0) { promises.push( notificationTemplateService.createTemplate({ name: Math.random() > 0.5 ? `SMS ${i}` : `sms ${i}`, }) ); continue; } promises.push( notificationTemplateService.createTemplate({ triggers: [{ identifier: `sms-trigger-${i}`, type: TriggerTypeEnum.EVENT, variables: [] }], }) ); } await Promise.all(promises); const { body } = await session.testAgent.get(`/v1/workflows?page=0&limit=100&query=sms`); const nameCount = body.data.filter((i) => i.name.toUpperCase().includes('SMS')).length; const triggerCount = body.data.filter((i) => i.triggers[0].identifier.includes('sms')).length; expect(body.data.length).to.equal(count); expect(body.totalCount).to.equal(count); expect(body.page).to.equal(0); expect(body.pageSize).to.equal(100); expect(nameCount).to.equal(5); expect(triggerCount).to.equal(5); }); }); ================================================ FILE: apps/api/src/app/workflows-v1/e2e/update-notification-template.e2e.ts ================================================ import { ChangeRepository } from '@novu/dal'; import { EmailBlockTypeEnum, FieldLogicalOperatorEnum, FieldOperatorEnum, FilterPartTypeEnum, INotificationTemplate, INotificationTemplateStep, IUpdateNotificationTemplateDto, StepTypeEnum, } from '@novu/shared'; import { NotificationTemplateService, UserSession } from '@novu/testing'; import { expect } from 'chai'; import { CreateWorkflowRequestDto, UpdateWorkflowRequestDto } from '../dtos'; import { WorkflowResponse } from '../dtos/workflow-response.dto'; describe('Update workflow by id - /workflows/:workflowId (PUT) #novu-v0', async () => { let session: UserSession; const changeRepository: ChangeRepository = new ChangeRepository(); before(async () => { session = new UserSession(); await session.initialize(); }); it('should update the workflow', async () => { const notificationTemplateService = new NotificationTemplateService( session.user._id, session.organization._id, session.environment._id ); const template = await notificationTemplateService.createTemplate(); const update: IUpdateNotificationTemplateDto = { name: 'new name for notification', steps: [ { template: { type: StepTypeEnum.IN_APP, content: 'This is new content for notification', }, variants: [ { filters: [ { isNegated: false, type: 'GROUP', value: FieldLogicalOperatorEnum.AND, children: [ { on: FilterPartTypeEnum.TENANT, field: 'name', value: 'Titans', operator: FieldOperatorEnum.EQUAL, }, ], }, ], template: { type: StepTypeEnum.IN_APP, content: 'first content', }, }, { filters: [ { isNegated: false, type: 'GROUP', value: FieldLogicalOperatorEnum.AND, children: [ { on: FilterPartTypeEnum.TENANT, field: 'name', value: 'Titans', operator: FieldOperatorEnum.EQUAL, }, ], }, ], template: { type: StepTypeEnum.IN_APP, content: 'second content', }, }, ], }, ], }; const { body } = await session.testAgent.put(`/v1/workflows/${template._id}`).send(update); const foundTemplate: INotificationTemplate = body.data; expect(foundTemplate._id).to.equal(template._id); expect(foundTemplate.name).to.equal('new name for notification'); expect(foundTemplate.description).to.equal(template.description); expect(foundTemplate.steps.length).to.equal(1); const updateRequestStep = update.steps ? update.steps[0] : undefined; const step = foundTemplate.steps[0] as INotificationTemplateStep; expect(step.template?.content).to.equal(updateRequestStep?.template?.content); const fountVariant = step.variants ? step.variants[0] : undefined; const updateRequestStepVariant = updateRequestStep?.variants ? updateRequestStep?.variants[0] : undefined; expect(fountVariant?.template?.content).to.equal(updateRequestStepVariant?.template?.content); // test variant parent id const firstVariant = step.variants ? step.variants[0] : undefined; expect(firstVariant?._parentId).to.equal(null); const secondVariant = step.variants ? step.variants[1] : undefined; expect(secondVariant?._parentId).to.equal(firstVariant?._id); const change = await changeRepository.findOne({ _environmentId: session.environment._id, _entityId: foundTemplate._id, }); if (!change) { throw new Error('Change not found'); } expect(change._entityId).to.eq(foundTemplate._id); }); it('should throw error if trigger identifier already exists', async () => { const notificationTemplateService = new NotificationTemplateService( session.user._id, session.organization._id, session.environment._id ); const template1 = await notificationTemplateService.createTemplate(); const template2 = await notificationTemplateService.createTemplate(); const update: IUpdateNotificationTemplateDto = { identifier: template1.triggers[0].identifier, }; const { body } = await session.testAgent.put(`/v1/workflows/${template2._id}`).send(update); expect(body.statusCode).to.equal(400); expect(body.message).to.equal(`Workflow with identifier ${template1.triggers[0].identifier} already exists`); expect(body.error).to.equal('Bad Request'); }); it('should update the trigger identifier', async () => { const notificationTemplateService = new NotificationTemplateService( session.user._id, session.organization._id, session.environment._id ); const template = await notificationTemplateService.createTemplate(); const newIdentifier = `${template.triggers[0].identifier}-new`; const update: IUpdateNotificationTemplateDto = { identifier: newIdentifier, }; const { body } = await session.testAgent.put(`/v1/workflows/${template._id}`).send(update); const foundTemplate: INotificationTemplate = body.data; expect(foundTemplate._id).to.equal(template._id); expect(foundTemplate.description).to.equal(template.description); expect(foundTemplate.name).to.equal(template.name); expect(foundTemplate.triggers[0].identifier).to.equal(newIdentifier); const change = await changeRepository.findOne({ _environmentId: session.environment._id, _entityId: foundTemplate._id, }); if (!change) { throw new Error('Change not found'); } expect(change._entityId).to.eq(foundTemplate._id); }); it('should generate new variables on update', async () => { const notificationTemplateService = new NotificationTemplateService( session.user._id, session.organization._id, session.environment._id ); const template = await notificationTemplateService.createTemplate({ steps: [ { type: StepTypeEnum.IN_APP, content: 'This is new content for notification {{otherVariable}}', }, ], }); const update: IUpdateNotificationTemplateDto = { steps: [ { template: { type: StepTypeEnum.IN_APP, content: 'This is new content for notification {{newVariableFromUpdate}}', }, }, ], }; const { body } = await session.testAgent.put(`/v1/workflows/${template._id}`).send(update); const foundTemplate: INotificationTemplate = body.data; expect(foundTemplate._id).to.equal(template._id); expect(foundTemplate.triggers[0].variables[0].name).to.equal('newVariableFromUpdate'); }); it('should update the contentType and active of a message', async () => { const notificationTemplateService = new NotificationTemplateService( session.user._id, session.organization._id, session.environment._id ); const template = await notificationTemplateService.createTemplate({ steps: [ { type: StepTypeEnum.EMAIL, contentType: 'editor', content: 'Content', }, ], }); const update: IUpdateNotificationTemplateDto = { steps: [ { active: false, template: { type: StepTypeEnum.EMAIL, contentType: 'customHtml', content: 'Content', }, }, ], }; const { body } = await session.testAgent.put(`/v1/workflows/${template._id}`).send(update); const foundTemplate: INotificationTemplate = body.data; expect(foundTemplate._id).to.equal(template._id); const step = foundTemplate.steps[0] as INotificationTemplateStep; expect(step.active).to.equal(false); expect(step.template?.contentType).to.equal('customHtml'); }); it('should be able to update empty message content', async () => { const notificationTemplateService = new NotificationTemplateService( session.user._id, session.organization._id, session.environment._id ); const template = await notificationTemplateService.createTemplate({ steps: [ { type: StepTypeEnum.EMAIL, contentType: 'editor', content: [{ type: EmailBlockTypeEnum.TEXT, content: 'This is a sample text block' }], }, { type: StepTypeEnum.EMAIL, contentType: 'customHtml', content: 'This is a sample text block', }, ], }); const update: IUpdateNotificationTemplateDto = { steps: [ ...template.steps.map((step) => { return { _templateId: step._templateId, template: { type: StepTypeEnum.EMAIL, contentType: 'customHtml', content: '', }, } as INotificationTemplateStep; }), ], }; const { body } = await session.testAgent.put(`/v1/workflows/${template._id}`).send(update); const { steps } = body.data; expect(steps[0].template?.contentType).to.equal('customHtml'); expect(steps[0].template?.content).to.equal(''); expect(steps[1].template?.content).to.equal(''); }); it('should update the steps', async () => { const testTemplate: CreateWorkflowRequestDto = { name: 'test email template', description: 'This is a test description', tags: ['test-tag'], notificationGroupId: session.notificationGroups[0]._id, steps: [ { template: { name: 'Message Name', subject: 'Test email subject', preheader: 'Test email preheader', senderName: 'Test email sender name', type: StepTypeEnum.EMAIL, content: [], }, }, { template: { name: 'Message Name', subject: 'Test email subject', type: StepTypeEnum.EMAIL, content: [], }, }, ], }; const { body } = await session.testAgent.post(`/v1/workflows`).send(testTemplate); const template: INotificationTemplate = body.data; const updateData: UpdateWorkflowRequestDto = { name: testTemplate.name, tags: testTemplate.tags, description: testTemplate.description, steps: [ ...template.steps.map((step) => { return { _id: step._id, template: { name: 'Message Name', subject: 'Test email subject', preheader: 'updated preheader', senderName: 'updated sender name', type: StepTypeEnum.EMAIL, content: [], }, _parentId: step._parentId, } as INotificationTemplateStep; }), { template: { name: 'Message Name', subject: 'Test email subject', type: StepTypeEnum.EMAIL, content: [], }, }, ], notificationGroupId: session.notificationGroups[0]._id, }; const { body: updated } = await session.testAgent.put(`/v1/workflows/${template._id}`).send(updateData); const { steps } = updated.data; expect(steps[0]._parentId).to.equal(null); expect(steps[0].template.preheader).to.equal('updated preheader'); expect(steps[0].template.senderName).to.equal('updated sender name'); expect(steps[0]._id).to.equal(steps[1]._parentId); expect(steps[1]._id).to.equal(steps[2]._parentId); }); it('should update reply callbacks', async () => { const testTemplate: Partial = { name: 'test email template', description: 'This is a test description', tags: ['test-tag'], notificationGroupId: session.notificationGroups[0]._id, steps: [ { template: { name: 'Message Name', type: StepTypeEnum.EMAIL, content: [], }, }, ], }; const { body } = await session.testAgent.post(`/v1/workflows`).send(testTemplate); const createdTemplate: WorkflowResponse = body.data; expect(createdTemplate.name).to.equal(testTemplate.name); expect(createdTemplate.steps[0].replyCallback).to.deep.equal({}); const template: INotificationTemplate = body.data; const updateData: UpdateWorkflowRequestDto = { name: 'test email template', tags: ['test-tag'], description: 'This is a test description', steps: [ { template: { name: 'Message Name', type: StepTypeEnum.EMAIL, content: [], }, replyCallback: { active: true, url: 'acme-corp.com/webhook' }, }, ], notificationGroupId: session.notificationGroups[0]._id, }; const { body: updated } = await session.testAgent.put(`/v1/workflows/${template._id}`).send(updateData); const updatedTemplate: WorkflowResponse = updated.data; expect(updatedTemplate.name).to.equal(testTemplate.name); expect(updatedTemplate.steps[0].replyCallback?.active).to.equal(true); expect(updatedTemplate.steps[0].replyCallback?.url).to.equal('acme-corp.com/webhook'); }); it('should not able to update step with invalid action', async () => { const notificationTemplateService = new NotificationTemplateService( session.user._id, session.organization._id, session.environment._id ); const workflow = await notificationTemplateService.createTemplate(); const invalidAction = ''; const update: IUpdateNotificationTemplateDto = { steps: [ { template: { type: StepTypeEnum.IN_APP, cta: { action: invalidAction } as any, content: 'This is new content for notification', }, }, ], }; const { body } = await session.testAgent.put(`/v1/workflows/${workflow._id}`).send(update); expect(body.message).to.equal('Please provide a valid CTA action'); expect(body.statusCode).to.equal(400); }); }); ================================================ FILE: apps/api/src/app/workflows-v1/notification-template.controller.ts ================================================ import { Body, ClassSerializerInterceptor, Controller, Delete, Get, Param, Post, Put, Query, UseGuards, UseInterceptors, } from '@nestjs/common'; import { ApiExcludeController, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateWorkflowCommandV0, CreateWorkflowV0, RequirePermissions, UpdateWorkflowCommandV0, UpdateWorkflowV0, } from '@novu/application-generic'; import { buildWorkflowPreferencesFromPreferenceChannels, DEFAULT_WORKFLOW_PREFERENCES, PermissionsEnum, ResourceOriginEnum, ResourceTypeEnum, UserSessionData, } from '@novu/shared'; import { RequireAuthentication } from '../auth/framework/auth.decorator'; import { ExternalApiAccessible } from '../auth/framework/external-api.decorator'; import { RootEnvironmentGuard } from '../auth/framework/root-environment-guard.service'; import { DataBooleanDto } from '../shared/dtos/data-wrapper-dto'; import { ApiCommonResponses, ApiOkResponse, ApiResponse } from '../shared/framework/response.decorator'; import { UserSession } from '../shared/framework/user.decorator'; import { ChangeWorkflowStatusRequestDto, CreateWorkflowRequestDto, UpdateWorkflowRequestDto } from './dtos'; import { WorkflowResponse } from './dtos/workflow-response.dto'; import { WorkflowsResponseDto } from './dtos/workflows.response.dto'; import { WorkflowsRequestDto } from './dtos/workflows-request.dto'; import { CreateWorkflowQuery } from './queries'; import { ChangeTemplateActiveStatusCommand } from './usecases/change-template-active-status/change-template-active-status.command'; import { ChangeTemplateActiveStatus } from './usecases/change-template-active-status/change-template-active-status.usecase'; import { DeleteNotificationTemplateCommand } from './usecases/delete-notification-template/delete-notification-template.command'; import { DeleteNotificationTemplate } from './usecases/delete-notification-template/delete-notification-template.usecase'; import { GetNotificationTemplateCommand } from './usecases/get-notification-template/get-notification-template.command'; import { GetNotificationTemplate } from './usecases/get-notification-template/get-notification-template.usecase'; import { GetNotificationTemplatesCommand } from './usecases/get-notification-templates/get-notification-templates.command'; import { GetNotificationTemplates } from './usecases/get-notification-templates/get-notification-templates.usecase'; /** * @deprecated use controller in /workflows directory */ @ApiCommonResponses() @ApiExcludeController() @Controller('/notification-templates') @UseInterceptors(ClassSerializerInterceptor) @RequireAuthentication() @ApiTags('Notification Templates') export class NotificationTemplateController { constructor( private createWorkflowUsecaseV0: CreateWorkflowV0, private updateWorkflowUsecaseV0: UpdateWorkflowV0, private getNotificationTemplateUsecase: GetNotificationTemplate, private getNotificationTemplatesUsecase: GetNotificationTemplates, private deleteTemplateByIdUsecase: DeleteNotificationTemplate, private changeTemplateActiveStatusUsecase: ChangeTemplateActiveStatus ) {} @Get('') @ApiResponse(WorkflowResponse) @ApiOperation({ summary: 'Get Notification templates', description: `Notification templates have been renamed to Workflows, Please use the new workflows controller`, deprecated: true, }) @ExternalApiAccessible() @RequirePermissions(PermissionsEnum.WORKFLOW_READ) getNotificationTemplates( @UserSession() user: UserSessionData, @Query() queryParams: WorkflowsRequestDto ): Promise { return this.getNotificationTemplatesUsecase.execute( GetNotificationTemplatesCommand.create({ organizationId: user.organizationId, userId: user._id, environmentId: user.environmentId, page: queryParams.page, limit: queryParams.limit, query: queryParams.query, }) ); } @Put('/:templateId') @ApiResponse(WorkflowResponse) @ApiOperation({ summary: 'Update Notification template', description: `Notification templates have been renamed to Workflows, Please use the new workflows controller`, deprecated: true, }) @ExternalApiAccessible() @RequirePermissions(PermissionsEnum.WORKFLOW_WRITE) async updateTemplateById( @UserSession() user: UserSessionData, @Param('templateId') templateId: string, @Body() body: UpdateWorkflowRequestDto ): Promise { return await this.updateWorkflowUsecaseV0.execute( UpdateWorkflowCommandV0.create({ environmentId: user.environmentId, organizationId: user.organizationId, userId: user._id, id: templateId, name: body.name, tags: body.tags, description: body.description, workflowId: body.identifier, critical: body.critical, defaultPreferences: DEFAULT_WORKFLOW_PREFERENCES, userPreferences: body.preferenceSettings && buildWorkflowPreferencesFromPreferenceChannels(body.critical, body.preferenceSettings), steps: body.steps, notificationGroupId: body.notificationGroupId, data: body.data, type: ResourceTypeEnum.REGULAR, }) ); } @Delete('/:templateId') @UseGuards(RootEnvironmentGuard) @ApiOkResponse({ type: DataBooleanDto, }) @ApiOperation({ summary: 'Delete Notification template', description: `Notification templates have been renamed to Workflows, Please use the new workflows controller`, deprecated: true, }) @ExternalApiAccessible() @RequirePermissions(PermissionsEnum.WORKFLOW_WRITE) deleteTemplateById(@UserSession() user: UserSessionData, @Param('templateId') templateId: string): Promise { return this.deleteTemplateByIdUsecase.execute( DeleteNotificationTemplateCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, userId: user._id, templateId, type: ResourceTypeEnum.REGULAR, }) ); } @Get('/:workflowIdOrIdentifier') @ApiResponse(WorkflowResponse) @ApiOperation({ summary: 'Get Notification template', description: `Notification templates have been renamed to Workflows, Please use the new workflows controller`, deprecated: true, }) @ExternalApiAccessible() @RequirePermissions(PermissionsEnum.WORKFLOW_READ) getNotificationTemplateById( @UserSession() user: UserSessionData, @Param('workflowIdOrIdentifier') workflowIdOrIdentifier: string ): Promise { return this.getNotificationTemplateUsecase.execute( GetNotificationTemplateCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, userId: user._id, workflowIdOrIdentifier, }) ); } @Post('') @ApiResponse(WorkflowResponse, 201) @ApiOperation({ summary: 'Create Notification template', description: `Notification templates have been renamed to Workflows, Please use the new workflows controller`, deprecated: true, }) @ExternalApiAccessible() @UseGuards(RootEnvironmentGuard) @RequirePermissions(PermissionsEnum.WORKFLOW_WRITE) create( @UserSession() user: UserSessionData, @Query() query: CreateWorkflowQuery, @Body() body: CreateWorkflowRequestDto ): Promise { return this.createWorkflowUsecaseV0.execute( CreateWorkflowCommandV0.create({ organizationId: user.organizationId, userId: user._id, environmentId: user.environmentId, name: body.name, tags: body.tags, description: body.description, steps: body.steps, notificationGroupId: body.notificationGroupId, notificationGroup: body.notificationGroup, active: body.active ?? false, draft: !body.active, critical: body.critical ?? false, defaultPreferences: DEFAULT_WORKFLOW_PREFERENCES, userPreferences: body.preferenceSettings && buildWorkflowPreferencesFromPreferenceChannels(body.critical, body.preferenceSettings), blueprintId: body.blueprintId, data: body.data, __source: query?.__source, type: ResourceTypeEnum.REGULAR, origin: ResourceOriginEnum.NOVU_CLOUD, }) ); } @Put('/:templateId/status') @UseGuards(RootEnvironmentGuard) @ApiResponse(WorkflowResponse) @ApiOperation({ summary: 'Update Notification template status', description: `Notification templates have been renamed to Workflows, Please use the new workflows controller`, deprecated: true, }) @ExternalApiAccessible() @RequirePermissions(PermissionsEnum.WORKFLOW_WRITE) changeActiveStatus( @UserSession() user: UserSessionData, @Body() body: ChangeWorkflowStatusRequestDto, @Param('templateId') templateId: string ): Promise { return this.changeTemplateActiveStatusUsecase.execute( ChangeTemplateActiveStatusCommand.create({ organizationId: user.organizationId, userId: user._id, environmentId: user.environmentId, active: body.active, templateId, }) ); } } ================================================ FILE: apps/api/src/app/workflows-v1/queries/CreateWorkflowQuery.ts ================================================ /** * @deprecated use dto's in /workflows directory */ export class CreateWorkflowQuery { __source?: string; } ================================================ FILE: apps/api/src/app/workflows-v1/queries/index.ts ================================================ export * from './CreateWorkflowQuery'; ================================================ FILE: apps/api/src/app/workflows-v1/usecases/change-template-active-status/change-template-active-status.command.ts ================================================ import { IsBoolean, IsDefined, IsMongoId } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; /** * @deprecated use dto's in /workflows directory */ /** * @deprecated * This command is deprecated and will be removed in the future. * Please use the ChangeWorkflowActiveStatusCommand instead. */ export class ChangeTemplateActiveStatusCommand extends EnvironmentWithUserCommand { @IsBoolean() @IsDefined() active: boolean; @IsMongoId() @IsDefined() templateId: string; } ================================================ FILE: apps/api/src/app/workflows-v1/usecases/change-template-active-status/change-template-active-status.usecase.ts ================================================ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { CreateChange, CreateChangeCommand, computeWorkflowStatus, InvalidateCacheService, } from '@novu/application-generic'; import { ChangeRepository, NotificationTemplateEntity, NotificationTemplateRepository } from '@novu/dal'; import { ChangeEntityTypeEnum } from '@novu/shared'; import { ChangeTemplateActiveStatusCommand } from './change-template-active-status.command'; /** * @deprecated * This usecase is deprecated and will be removed in the future. * Please use the ChangeWorkflowActiveStatus usecase instead. */ @Injectable() export class ChangeTemplateActiveStatus { constructor( private invalidateCache: InvalidateCacheService, private notificationTemplateRepository: NotificationTemplateRepository, private createChange: CreateChange, private changeRepository: ChangeRepository ) {} async execute(command: ChangeTemplateActiveStatusCommand): Promise { const foundTemplate = await this.notificationTemplateRepository.findOne({ _environmentId: command.environmentId, _id: command.templateId, }); if (!foundTemplate) { throw new NotFoundException(`Template with id ${command.templateId} not found`); } if (foundTemplate.active === command.active) { throw new BadRequestException('You must provide a different status from the current status'); } await this.notificationTemplateRepository.update( { _id: command.templateId, _environmentId: command.environmentId, }, { $set: { active: command.active, draft: !command.active, status: computeWorkflowStatus(command.active, foundTemplate.steps), }, } ); const item = await this.notificationTemplateRepository.findById(command.templateId, command.environmentId); if (!item) throw new NotFoundException(`Notification template ${command.templateId} is not found`); const parentChangeId: string = await this.changeRepository.getChangeId( command.environmentId, ChangeEntityTypeEnum.NOTIFICATION_TEMPLATE, command.templateId ); await this.createChange.execute( CreateChangeCommand.create({ organizationId: command.organizationId, environmentId: command.environmentId, userId: command.userId, type: ChangeEntityTypeEnum.NOTIFICATION_TEMPLATE, item, changeId: parentChangeId, }) ); return item; } } ================================================ FILE: apps/api/src/app/workflows-v1/usecases/delete-notification-template/delete-notification-template.command.ts ================================================ import { ResourceTypeEnum } from '@novu/shared'; import { IsDefined, IsEnum, IsMongoId } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; /** * @deprecated * This command is deprecated and will be removed in the future. * Please use the GetWorkflowCommand instead. */ export class DeleteNotificationTemplateCommand extends EnvironmentWithUserCommand { @IsDefined() @IsMongoId() templateId: string; @IsEnum(ResourceTypeEnum) @IsDefined() type: ResourceTypeEnum; } ================================================ FILE: apps/api/src/app/workflows-v1/usecases/delete-notification-template/delete-notification-template.usecase.ts ================================================ import { BadRequestException, Injectable } from '@nestjs/common'; import { AnalyticsService, CreateChange, CreateChangeCommand } from '@novu/application-generic'; import { ChangeRepository, DalException, NotificationTemplateEntity, NotificationTemplateRepository } from '@novu/dal'; import { ChangeEntityTypeEnum } from '@novu/shared'; import { DeleteWorkflowCommand } from '../delete-workflow/delete-workflow.command'; import { DeleteWorkflowUseCase } from '../delete-workflow/delete-workflow.usecase'; import { DeleteNotificationTemplateCommand } from './delete-notification-template.command'; /** * @deprecated * This usecase is deprecated and will be removed in the future. * Please use the DeleteWorkflow usecase instead. */ @Injectable() export class DeleteNotificationTemplate { constructor( private createChange: CreateChange, private changeRepository: ChangeRepository, private analyticsService: AnalyticsService, private deleteWorkflowUseCase: DeleteWorkflowUseCase, private notificationTemplateRepository: NotificationTemplateRepository ) {} async execute(command: DeleteNotificationTemplateCommand) { try { await this.deleteWorkflowUseCase.execute( DeleteWorkflowCommand.create({ workflowIdOrInternalId: command.templateId, environmentId: command.environmentId, organizationId: command.organizationId, userId: command.userId, }) ); const parentChangeId: string = await this.changeRepository.getChangeId( command.environmentId, ChangeEntityTypeEnum.NOTIFICATION_TEMPLATE, command.templateId ); const item: NotificationTemplateEntity = ( await this.notificationTemplateRepository.findDeleted({ _environmentId: command.environmentId, _id: command.templateId, }) )?.[0]; await this.createChange.execute( CreateChangeCommand.create({ organizationId: command.organizationId, environmentId: command.environmentId, userId: command.userId, item, type: ChangeEntityTypeEnum.NOTIFICATION_TEMPLATE, changeId: parentChangeId, }) ); this.analyticsService.track(`Removed Notification Template`, command.userId, { _organization: command.organizationId, _environment: command.environmentId, _templateId: command.templateId, data: { draft: item.draft, critical: item.critical, }, }); } catch (e) { if (e instanceof DalException) { throw new BadRequestException(e.message); } throw e; } return true; } } ================================================ FILE: apps/api/src/app/workflows-v1/usecases/delete-workflow/delete-workflow.command.ts ================================================ import { EnvironmentWithUserCommand } from '@novu/application-generic'; import { Exclude } from 'class-transformer'; import { IsDefined, IsOptional, IsString } from 'class-validator'; import { ClientSession } from 'mongoose'; export class DeleteWorkflowCommand extends EnvironmentWithUserCommand { @IsString() @IsDefined() workflowIdOrInternalId: string; /** * Exclude session from the command to avoid serializing it in the response */ @IsOptional() @Exclude() session?: ClientSession | null; } ================================================ FILE: apps/api/src/app/workflows-v1/usecases/delete-workflow/delete-workflow.usecase.ts ================================================ import { Injectable } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import { DeletePreferencesCommand, DeletePreferencesUseCase, GetWorkflowByIdsUseCase, GetWorkflowWithPreferencesCommand, Instrument, InstrumentUsecase, PinoLogger, SendWebhookMessage, } from '@novu/application-generic'; import { ClientSession, ControlValuesRepository, LocalizationResourceEnum, MessageTemplateRepository, NotificationTemplateEntity, NotificationTemplateRepository, } from '@novu/dal'; import { PreferencesTypeEnum, WebhookEventEnum, WebhookObjectTypeEnum } from '@novu/shared'; import { DeleteWorkflowCommand } from './delete-workflow.command'; @Injectable() export class DeleteWorkflowUseCase { constructor( private notificationTemplateRepository: NotificationTemplateRepository, private messageTemplateRepository: MessageTemplateRepository, private getWorkflowByIdsUseCase: GetWorkflowByIdsUseCase, private controlValuesRepository: ControlValuesRepository, private deletePreferencesUsecase: DeletePreferencesUseCase, private moduleRef: ModuleRef, private logger: PinoLogger, private sendWebhookMessage: SendWebhookMessage ) {} @InstrumentUsecase() async execute(command: DeleteWorkflowCommand): Promise { const workflowEntity = await this.getWorkflowByIdsUseCase.execute( GetWorkflowWithPreferencesCommand.create({ ...command, workflowIdOrInternalId: command.workflowIdOrInternalId, }) ); await this.deleteRelatedEntities(command, workflowEntity); await this.sendWebhookMessage.execute({ eventType: WebhookEventEnum.WORKFLOW_DELETED, objectType: WebhookObjectTypeEnum.WORKFLOW, payload: { object: workflowEntity as unknown as Record, }, organizationId: command.organizationId, environmentId: command.environmentId, }); } @Instrument() private async deleteRelatedEntities(command: DeleteWorkflowCommand, workflow: NotificationTemplateEntity) { const deleteOps = async (session: ClientSession) => { const sessionOptions = { session }; await this.controlValuesRepository.deleteMany( { _environmentId: command.environmentId, _organizationId: command.organizationId, _workflowId: workflow._id, }, sessionOptions ); if (workflow.steps.length > 0) { for (const step of workflow.steps) { await this.messageTemplateRepository.deleteById( { _id: step._templateId, _environmentId: command.environmentId, }, sessionOptions ); } } await this.deletePreferencesUsecase.execute( DeletePreferencesCommand.create({ templateId: workflow._id, environmentId: command.environmentId, organizationId: command.organizationId, userId: command.userId, type: PreferencesTypeEnum.USER_WORKFLOW, session, }) ); await this.deletePreferencesUsecase.execute( DeletePreferencesCommand.create({ templateId: workflow._id, environmentId: command.environmentId, organizationId: command.organizationId, userId: command.userId, type: PreferencesTypeEnum.WORKFLOW_RESOURCE, session, }) ); await this.deleteTranslationGroup(command, session); await this.notificationTemplateRepository.delete({ _id: workflow._id, _organizationId: command.organizationId, _environmentId: command.environmentId, }); }; if (command.session) { await deleteOps(command.session); } else { await this.notificationTemplateRepository.withTransaction(deleteOps); } } private async deleteTranslationGroup(command: DeleteWorkflowCommand, session: ClientSession) { const isEnterprise = process.env.NOVU_ENTERPRISE === 'true' || process.env.CI_EE_TEST === 'true'; const isSelfHosted = process.env.IS_SELF_HOSTED === 'true'; if (!isEnterprise || isSelfHosted) { return; } try { const deleteTranslationGroup = this.moduleRef.get(require('@novu/ee-translation')?.DeleteTranslationGroup, { strict: false, }); await deleteTranslationGroup.execute({ resourceId: command.workflowIdOrInternalId, resourceType: LocalizationResourceEnum.WORKFLOW, organizationId: command.organizationId, environmentId: command.environmentId, userId: command.userId, session, }); } catch (error) { this.logger.error(`Failed to delete translations for workflow`, { workflowIdentifier: command.workflowIdOrInternalId, organizationId: command.organizationId, error: error instanceof Error ? error.message : String(error), }); // translation group might not be present, so we can ignore the error } } } ================================================ FILE: apps/api/src/app/workflows-v1/usecases/get-active-integrations-status/get-active-integrations-status.command.ts ================================================ import { NotificationTemplateEntity } from '@novu/dal'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; /** * @deprecated use commands in /workflows directory */ export class GetActiveIntegrationsStatusCommand extends EnvironmentWithUserCommand { workflows: NotificationTemplateEntity | NotificationTemplateEntity[]; } ================================================ FILE: apps/api/src/app/workflows-v1/usecases/get-active-integrations-status/get-active-integrations-status.spec.ts ================================================ import { Test } from '@nestjs/testing'; import { ChannelTypeEnum, EmailProviderIdEnum, InAppProviderIdEnum } from '@novu/shared'; import { IntegrationService, NotificationTemplateService, UserSession } from '@novu/testing'; import { expect } from 'chai'; import { SharedModule } from '../../../shared/shared.module'; import { WorkflowResponse } from '../../dtos/workflow-response.dto'; import { WorkflowModuleV1 } from '../../workflow-v1.module'; import { GetActiveIntegrationsStatusCommand } from './get-active-integrations-status.command'; import { GetActiveIntegrationsStatus } from './get-active-integrations-status.usecase'; describe('Get Active Integrations Status', () => { let useCase: GetActiveIntegrationsStatus; let session: UserSession; beforeEach(async () => { const moduleRef = await Test.createTestingModule({ imports: [WorkflowModuleV1, SharedModule], providers: [], }).compile(); session = new UserSession(); await session.initialize(); useCase = moduleRef.get(GetActiveIntegrationsStatus); }); it('should get the active integrations status for workflow', async () => { const notificationTemplateService = new NotificationTemplateService( session.user._id, session.organization._id, session.environment._id ); const template = await notificationTemplateService.createTemplate(); const integrationService = new IntegrationService(); await integrationService.deleteAllForOrganization(session.organization._id); await integrationService.createIntegration({ environmentId: session.environment._id, organizationId: session.organization._id, providerId: EmailProviderIdEnum.SendGrid, channel: ChannelTypeEnum.EMAIL, }); await integrationService.createIntegration({ environmentId: session.environment._id, organizationId: session.organization._id, providerId: InAppProviderIdEnum.Novu, channel: ChannelTypeEnum.IN_APP, }); const command = GetActiveIntegrationsStatusCommand.create({ userId: session.user._id, organizationId: session.organization._id, environmentId: session.environment._id, workflows: template, }); const result = (await useCase.execute(command)) as WorkflowResponse; expect(result.workflowIntegrationStatus?.hasActiveIntegrations).to.equal(true); expect(result.workflowIntegrationStatus?.channels[ChannelTypeEnum.EMAIL].hasActiveIntegrations).to.equal(true); expect(result.workflowIntegrationStatus?.channels[ChannelTypeEnum.PUSH].hasActiveIntegrations).to.equal(false); }); }); ================================================ FILE: apps/api/src/app/workflows-v1/usecases/get-active-integrations-status/get-active-integrations-status.usecase.ts ================================================ import { Injectable } from '@nestjs/common'; import { CalculateLimitNovuIntegration, CalculateLimitNovuIntegrationCommand, GetActiveIntegrations, GetActiveIntegrationsCommand, IntegrationResponseDto, NotificationStep, } from '@novu/application-generic'; import { ChannelTypeEnum, ChatProviderIdEnum, EmailProviderIdEnum, SmsProviderIdEnum, StepTypeEnum, WorkflowChannelsIntegrationStatus, } from '@novu/shared'; import { WorkflowResponse } from '../../dtos/workflow-response.dto'; import { GetActiveIntegrationsStatusCommand } from './get-active-integrations-status.command'; /** * @deprecated use usecases in /workflows directory */ @Injectable() export class GetActiveIntegrationsStatus { constructor( private getActiveIntegrationUsecase: GetActiveIntegrations, private calculateLimitNovuIntegrationUsecase: CalculateLimitNovuIntegration ) {} async execute(command: GetActiveIntegrationsStatusCommand): Promise { const defaultStateByChannelType = Object.keys(ChannelTypeEnum).reduce((prev, key) => { const channelType = ChannelTypeEnum[key]; prev[channelType] = { hasActiveIntegrations: false }; if (channelType === ChannelTypeEnum.EMAIL || channelType === ChannelTypeEnum.SMS) { prev[channelType] = { ...prev[channelType], hasPrimaryIntegrations: false }; } return prev; }, {} as WorkflowChannelsIntegrationStatus); const activeIntegrations = await this.getActiveIntegrationUsecase.execute( GetActiveIntegrationsCommand.create({ organizationId: command.organizationId, environmentId: command.environmentId, userId: command.userId, }) ); const activeIntegrationsByEnv = activeIntegrations.filter( (activeIntegration) => activeIntegration._environmentId === command.environmentId ); const activeStateByChannelType = this.updateStateByChannelType(activeIntegrationsByEnv, defaultStateByChannelType); const activeStateByChannelTypeWithNovu = await this.processNovuProviders( activeIntegrationsByEnv, command, activeStateByChannelType ); return this.updateActiveIntegrationsStatus(command.workflows, activeStateByChannelTypeWithNovu); } private updateStateByChannelType( activeIntegrations: IntegrationResponseDto[], stateByChannelType: WorkflowChannelsIntegrationStatus ): WorkflowChannelsIntegrationStatus { for (const integration of activeIntegrations) { const channelType = integration.channel; stateByChannelType[channelType].hasActiveIntegrations = integration.active; const isEmailChannel = channelType === ChannelTypeEnum.EMAIL; const isSmsChannel = channelType === ChannelTypeEnum.SMS; if ((isEmailChannel || isSmsChannel) && !stateByChannelType[channelType].hasPrimaryIntegrations) { stateByChannelType[channelType].hasPrimaryIntegrations = integration.primary; } } return stateByChannelType; } private updateActiveIntegrationsStatus( workflows: WorkflowResponse | WorkflowResponse[], activeChannelsStatus: WorkflowChannelsIntegrationStatus ) { if (Array.isArray(workflows)) { return workflows.map((workflow) => { const { hasActive, hasPrimary } = this.handleSteps(workflow.steps, activeChannelsStatus); workflow.workflowIntegrationStatus = { hasActiveIntegrations: hasActive, channels: activeChannelsStatus, hasPrimaryIntegrations: hasPrimary, }; return workflow; }); } else { const { hasActive, hasPrimary } = this.handleSteps(workflows.steps, activeChannelsStatus); return { ...workflows, workflowIntegrationStatus: { hasActiveIntegrations: hasActive, channels: activeChannelsStatus, hasPrimaryIntegrations: hasPrimary, }, }; } } private handleSteps(steps: NotificationStep[], activeChannelsStatus: WorkflowChannelsIntegrationStatus) { let hasActive = true; let hasPrimary: boolean | undefined; const uniqueSteps = Array.from(new Set(steps)); for (const step of uniqueSteps) { const stepType = step.template?.type; const skipStep = stepType === StepTypeEnum.DELAY || stepType === StepTypeEnum.DIGEST || stepType === StepTypeEnum.TRIGGER || stepType === StepTypeEnum.CUSTOM || !activeChannelsStatus[stepType]; const isStepWithPrimaryIntegration = stepType === StepTypeEnum.EMAIL || stepType === StepTypeEnum.SMS; if (stepType && !skipStep) { const { hasActiveIntegrations } = activeChannelsStatus[stepType]; if (!hasActiveIntegrations) { hasActive = false; } if (isStepWithPrimaryIntegration) { const hasPrimaryIntegration = activeChannelsStatus[stepType].hasPrimaryIntegrations; if (!hasPrimaryIntegration) { hasPrimary = false; } } } } return { hasActive, hasPrimary }; } private async processNovuProviders( activeIntegrations: IntegrationResponseDto[], command: GetActiveIntegrationsStatusCommand, stateByChannelType: WorkflowChannelsIntegrationStatus ) { const primaryNovuProviders = activeIntegrations.filter( (integration) => (integration.providerId === EmailProviderIdEnum.Novu || integration.providerId === SmsProviderIdEnum.Novu || integration.providerId === ChatProviderIdEnum.Novu) && integration.primary ); for (const primaryNovuProvider of primaryNovuProviders) { const channelType = primaryNovuProvider.channel; let hasLimitReached = true; const limit = await this.calculateLimitNovuIntegrationUsecase.execute( CalculateLimitNovuIntegrationCommand.create({ channelType, environmentId: command.environmentId, organizationId: command.organizationId, }) ); if (!limit) { hasLimitReached = true; } else { hasLimitReached = limit.limit === limit.count; } stateByChannelType[channelType].hasActiveIntegrations = !hasLimitReached; } return stateByChannelType; } } ================================================ FILE: apps/api/src/app/workflows-v1/usecases/get-notification-template/get-notification-template.command.ts ================================================ import { IsDefined, IsString } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; /** * @deprecated * This command is deprecated and will be removed in the future. * Please use the GetWorkflowCommand instead. */ export class GetNotificationTemplateCommand extends EnvironmentWithUserCommand { @IsDefined() @IsString() workflowIdOrIdentifier: string; } ================================================ FILE: apps/api/src/app/workflows-v1/usecases/get-notification-template/get-notification-template.usecase.ts ================================================ import { Injectable } from '@nestjs/common'; import { GetWorkflowWithPreferencesCommand, GetWorkflowWithPreferencesUseCase } from '@novu/application-generic'; import { NotificationTemplateEntity } from '@novu/dal'; import { GetNotificationTemplateCommand } from './get-notification-template.command'; /** * @deprecated * This usecase is deprecated and will be removed in the future. * Please use the GetWorkflow usecase instead. */ @Injectable() export class GetNotificationTemplate { constructor(private getWorkflowWithPreferencesUseCase: GetWorkflowWithPreferencesUseCase) {} async execute(command: GetNotificationTemplateCommand): Promise { const workflow = await this.getWorkflowWithPreferencesUseCase.execute( GetWorkflowWithPreferencesCommand.create({ workflowIdOrInternalId: command.workflowIdOrIdentifier, environmentId: command.environmentId, organizationId: command.organizationId, }) ); return workflow; } } ================================================ FILE: apps/api/src/app/workflows-v1/usecases/get-notification-templates/get-notification-templates.command.ts ================================================ import { IsNumber, IsOptional, IsString } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; /** * @deprecated * This command is deprecated and will be removed in the future. * Please use the GetWorkflowsCommand instead. */ export class GetNotificationTemplatesCommand extends EnvironmentWithUserCommand { @IsNumber() page: number; @IsNumber() limit: number; @IsOptional() @IsString() query?: string; } ================================================ FILE: apps/api/src/app/workflows-v1/usecases/get-notification-templates/get-notification-templates.usecase.ts ================================================ import { Injectable } from '@nestjs/common'; import { NotificationTemplateEntity, NotificationTemplateRepository } from '@novu/dal'; import { WorkflowResponse } from '../../dtos/workflow-response.dto'; import { WorkflowsResponseDto } from '../../dtos/workflows.response.dto'; import { GetActiveIntegrationsStatusCommand } from '../get-active-integrations-status/get-active-integrations-status.command'; import { GetActiveIntegrationsStatus } from '../get-active-integrations-status/get-active-integrations-status.usecase'; import { GetNotificationTemplatesCommand } from './get-notification-templates.command'; /** * D@deprecated * This usecase is deprecated and will be removed in the future. * Please use the GetWorkflows usecase instead. */ @Injectable() export class GetNotificationTemplates { constructor( private notificationTemplateRepository: NotificationTemplateRepository, private getActiveIntegrationsStatusUsecase: GetActiveIntegrationsStatus ) {} async execute(command: GetNotificationTemplatesCommand): Promise { const { data: list, totalCount } = await this.notificationTemplateRepository.getList( command.organizationId, command.environmentId, command.page * command.limit, command.limit, command.query, true ); const workflows = await this.updateHasActiveIntegrationFlag(list, command); return { page: command.page, data: workflows, totalCount, pageSize: command.limit }; } private async updateHasActiveIntegrationFlag( workflows: NotificationTemplateEntity[], command: GetNotificationTemplatesCommand ): Promise { return (await this.getActiveIntegrationsStatusUsecase.execute( GetActiveIntegrationsStatusCommand.create({ organizationId: command.organizationId, environmentId: command.environmentId, userId: command.userId, workflows, }) )) as WorkflowResponse[]; } } ================================================ FILE: apps/api/src/app/workflows-v1/usecases/get-workflow-variables/get-workflow-variables.command.ts ================================================ import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; export class GetWorkflowVariablesCommand extends EnvironmentWithUserCommand {} ================================================ FILE: apps/api/src/app/workflows-v1/usecases/get-workflow-variables/get-workflow-variables.usecase.ts ================================================ import { BadRequestException, Injectable } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import { buildVariablesKey, CachedResponse, PinoLogger } from '@novu/application-generic'; import { SystemVariablesWithTypes } from '@novu/shared'; import { TRANSLATIONS_SERVICE } from '../../../shared/constants'; import { GetWorkflowVariablesCommand } from './get-workflow-variables.command'; /** * @deprecated use usecases in /workflows directory */ @Injectable() export class GetWorkflowVariables { constructor( private moduleRef: ModuleRef, private logger: PinoLogger ) { this.logger.setContext(this.constructor.name); } async execute(command: GetWorkflowVariablesCommand) { const { environmentId, organizationId } = command; return await this.fetchVariables({ _environmentId: environmentId, _organizationId: organizationId, }); } @CachedResponse({ builder: (command: { _environmentId: string; _organizationId: string }) => buildVariablesKey({ _environmentId: command._environmentId, _organizationId: command._organizationId, }), }) private async fetchVariables({ _environmentId, _organizationId, }: { _environmentId: string; _organizationId: string; }) { let translationVariables = {}; try { if (process.env.NOVU_ENTERPRISE === 'true' || process.env.CI_EE_TEST === 'true') { if (!this.moduleRef.get(TRANSLATIONS_SERVICE, { strict: false })) { throw new BadRequestException('Translation module is not loaded'); } const service = this.moduleRef.get(TRANSLATIONS_SERVICE, { strict: false }); translationVariables = await service.getTranslationVariables(_environmentId, _organizationId); } } catch (e) { this.logger.error({ err: e }, `Unexpected error while importing enterprise modules`, 'TranslationsService'); } return { translations: translationVariables, system: SystemVariablesWithTypes, }; } } ================================================ FILE: apps/api/src/app/workflows-v1/usecases/index.ts ================================================ import { CreateWorkflowV0, GetWorkflowByIdsUseCase, GetWorkflowWithPreferencesUseCase, ResourceValidatorService, UpdateWorkflowV0, } from '@novu/application-generic'; import { CommunityOrganizationRepository } from '@novu/dal'; import { ChangeTemplateActiveStatus } from './change-template-active-status/change-template-active-status.usecase'; import { DeleteNotificationTemplate } from './delete-notification-template/delete-notification-template.usecase'; import { DeleteWorkflowUseCase } from './delete-workflow/delete-workflow.usecase'; import { GetActiveIntegrationsStatus } from './get-active-integrations-status/get-active-integrations-status.usecase'; import { GetNotificationTemplate } from './get-notification-template/get-notification-template.usecase'; import { GetNotificationTemplates } from './get-notification-templates/get-notification-templates.usecase'; import { GetWorkflowVariables } from './get-workflow-variables/get-workflow-variables.usecase'; export const USE_CASES = [ GetActiveIntegrationsStatus, ChangeTemplateActiveStatus, GetWorkflowByIdsUseCase, GetWorkflowWithPreferencesUseCase, CreateWorkflowV0, UpdateWorkflowV0, ResourceValidatorService, DeleteWorkflowUseCase, GetNotificationTemplates, GetNotificationTemplate, DeleteNotificationTemplate, GetWorkflowVariables, CommunityOrganizationRepository, ]; ================================================ FILE: apps/api/src/app/workflows-v1/workflow-v1.controller.ts ================================================ import { Body, ClassSerializerInterceptor, Controller, Delete, Get, Param, Post, Put, Query, UseGuards, UseInterceptors, } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiExcludeController } from '@nestjs/swagger/dist/decorators/api-exclude-controller.decorator'; import { CreateWorkflowCommandV0, CreateWorkflowV0, RequirePermissions, UpdateWorkflowCommandV0, UpdateWorkflowV0, } from '@novu/application-generic'; import { buildWorkflowPreferencesFromPreferenceChannels, DEFAULT_WORKFLOW_PREFERENCES, PermissionsEnum, ResourceOriginEnum, ResourceTypeEnum, UserSessionData, } from '@novu/shared'; import { RequireAuthentication } from '../auth/framework/auth.decorator'; import { ExternalApiAccessible } from '../auth/framework/external-api.decorator'; import { RootEnvironmentGuard } from '../auth/framework/root-environment-guard.service'; import { DataBooleanDto } from '../shared/dtos/data-wrapper-dto'; import { ApiOkResponse, ApiResponse } from '../shared/framework/response.decorator'; import { SdkGroupName } from '../shared/framework/swagger/sdk.decorators'; import { UserSession } from '../shared/framework/user.decorator'; import { ChangeWorkflowStatusRequestDto, CreateWorkflowRequestDto, UpdateWorkflowRequestDto, VariablesResponseDto, } from './dtos'; import { WorkflowResponse } from './dtos/workflow-response.dto'; import { WorkflowsResponseDto } from './dtos/workflows.response.dto'; import { WorkflowsRequestDto } from './dtos/workflows-request.dto'; import { CreateWorkflowQuery } from './queries'; import { ChangeTemplateActiveStatusCommand } from './usecases/change-template-active-status/change-template-active-status.command'; import { ChangeTemplateActiveStatus } from './usecases/change-template-active-status/change-template-active-status.usecase'; import { DeleteNotificationTemplateCommand } from './usecases/delete-notification-template/delete-notification-template.command'; import { DeleteNotificationTemplate } from './usecases/delete-notification-template/delete-notification-template.usecase'; import { GetNotificationTemplateCommand } from './usecases/get-notification-template/get-notification-template.command'; import { GetNotificationTemplate } from './usecases/get-notification-template/get-notification-template.usecase'; import { GetNotificationTemplatesCommand } from './usecases/get-notification-templates/get-notification-templates.command'; import { GetNotificationTemplates } from './usecases/get-notification-templates/get-notification-templates.usecase'; import { GetWorkflowVariablesCommand } from './usecases/get-workflow-variables/get-workflow-variables.command'; import { GetWorkflowVariables } from './usecases/get-workflow-variables/get-workflow-variables.usecase'; /** * @deprecated use controllers in /workflows directory */ @ApiExcludeController() @Controller('/workflows') @UseInterceptors(ClassSerializerInterceptor) @RequireAuthentication() @ApiTags('Workflows') export class WorkflowControllerV1 { constructor( private createWorkflowUsecaseV0: CreateWorkflowV0, private updateWorkflowByIdUsecaseV0: UpdateWorkflowV0, private getWorkflowsUsecase: GetNotificationTemplates, private getWorkflowUsecase: GetNotificationTemplate, private getWorkflowVariablesUsecase: GetWorkflowVariables, private deleteWorkflowByIdUsecase: DeleteNotificationTemplate, private changeWorkflowActiveStatusUsecase: ChangeTemplateActiveStatus ) {} @Get('') @ApiResponse(WorkflowsResponseDto) @ApiOperation({ summary: 'Get workflows', description: `Workflows were previously named notification templates`, }) @ExternalApiAccessible() @RequirePermissions(PermissionsEnum.WORKFLOW_READ) listWorkflows( @UserSession() user: UserSessionData, @Query() queryParams: WorkflowsRequestDto ): Promise { return this.getWorkflowsUsecase.execute( GetNotificationTemplatesCommand.create({ organizationId: user.organizationId, userId: user._id, environmentId: user.environmentId, page: queryParams.page, limit: queryParams.limit, query: queryParams.query, }) ); } @Put('/:workflowId') @ApiResponse(WorkflowResponse) @ApiOperation({ summary: 'Update workflow', description: `Workflow was previously named notification template`, }) @ExternalApiAccessible() @RequirePermissions(PermissionsEnum.WORKFLOW_WRITE) async updateWorkflowById( @UserSession() user: UserSessionData, @Param('workflowId') workflowId: string, @Body() body: UpdateWorkflowRequestDto ): Promise { return await this.updateWorkflowByIdUsecaseV0.execute( UpdateWorkflowCommandV0.create({ environmentId: user.environmentId, organizationId: user.organizationId, userId: user._id, id: workflowId, name: body.name, tags: body.tags, description: body.description, workflowId: body.identifier, critical: body.critical, defaultPreferences: DEFAULT_WORKFLOW_PREFERENCES, userPreferences: body.preferenceSettings && buildWorkflowPreferencesFromPreferenceChannels(body.critical, body.preferenceSettings), steps: body.steps, notificationGroupId: body.notificationGroupId, data: body.data, type: ResourceTypeEnum.REGULAR, }) ); } @Delete('/:workflowId') @UseGuards(RootEnvironmentGuard) @ApiOkResponse({ type: DataBooleanDto, }) @ApiOperation({ summary: 'Delete workflow', description: `Workflow was previously named notification template`, }) @ExternalApiAccessible() @RequirePermissions(PermissionsEnum.WORKFLOW_WRITE) deleteWorkflowById(@UserSession() user: UserSessionData, @Param('workflowId') workflowId: string): Promise { return this.deleteWorkflowByIdUsecase.execute( DeleteNotificationTemplateCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, userId: user._id, templateId: workflowId, type: ResourceTypeEnum.REGULAR, }) ); } @Get('/variables') @ApiResponse(VariablesResponseDto) @ApiOperation({ summary: 'Get available variables', description: 'Get the variables that can be used in the workflow', }) @ExternalApiAccessible() @RequirePermissions(PermissionsEnum.WORKFLOW_READ) @SdkGroupName('Workflows.Variables') getWorkflowVariables(@UserSession() user: UserSessionData): Promise { return this.getWorkflowVariablesUsecase.execute( GetWorkflowVariablesCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, userId: user._id, }) ); } @Get('/:workflowId') @ApiResponse(WorkflowResponse) @ApiOperation({ summary: 'Get workflow', description: `Workflow was previously named notification template`, }) @ExternalApiAccessible() @RequirePermissions(PermissionsEnum.WORKFLOW_READ) getWorkflowById( @UserSession() user: UserSessionData, @Param('workflowId') workflowId: string ): Promise { return this.getWorkflowUsecase.execute( GetNotificationTemplateCommand.create({ environmentId: user.environmentId, organizationId: user.organizationId, userId: user._id, workflowIdOrIdentifier: workflowId, }) ); } @Post('') @ApiResponse(WorkflowResponse, 201) @ApiOperation({ summary: 'Create workflow', description: `Workflow was previously named notification template`, }) @ExternalApiAccessible() @UseGuards(RootEnvironmentGuard) @RequirePermissions(PermissionsEnum.WORKFLOW_WRITE) create( @UserSession() user: UserSessionData, @Query() query: CreateWorkflowQuery, @Body() body: CreateWorkflowRequestDto ): Promise { return this.createWorkflowUsecaseV0.execute( CreateWorkflowCommandV0.create({ organizationId: user.organizationId, userId: user._id, environmentId: user.environmentId, name: body.name, tags: body.tags, description: body.description, steps: body.steps, notificationGroupId: body.notificationGroupId, notificationGroup: body.notificationGroup, active: body.active ?? false, draft: !body.active, critical: body.critical ?? false, defaultPreferences: DEFAULT_WORKFLOW_PREFERENCES, userPreferences: body.preferenceSettings && buildWorkflowPreferencesFromPreferenceChannels(body.critical, body.preferenceSettings), blueprintId: body.blueprintId, data: body.data, __source: query?.__source, type: ResourceTypeEnum.REGULAR, origin: ResourceOriginEnum.NOVU_CLOUD_V1, }) ); } @Put('/:workflowId/status') @UseGuards(RootEnvironmentGuard) @ApiResponse(WorkflowResponse) @ApiOperation({ summary: 'Update workflow status', description: `Workflow was previously named notification template`, }) @ExternalApiAccessible() @RequirePermissions(PermissionsEnum.WORKFLOW_WRITE) @SdkGroupName('Workflows.Status') updateActiveStatus( @UserSession() user: UserSessionData, @Body() body: ChangeWorkflowStatusRequestDto, @Param('workflowId') workflowId: string ): Promise { return this.changeWorkflowActiveStatusUsecase.execute( ChangeTemplateActiveStatusCommand.create({ organizationId: user.organizationId, userId: user._id, environmentId: user.environmentId, active: body.active, templateId: workflowId, }) ); } } ================================================ FILE: apps/api/src/app/workflows-v1/workflow-v1.module.ts ================================================ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; import { ChangeModule } from '../change/change.module'; import { IntegrationModule } from '../integrations/integrations.module'; import { MessageTemplateModule } from '../message-template/message-template.module'; import { OutboundWebhooksModule } from '../outbound-webhooks/outbound-webhooks.module'; import { PreferencesModule } from '../preferences'; import { SharedModule } from '../shared/shared.module'; import { NotificationTemplateController } from './notification-template.controller'; import { USE_CASES } from './usecases'; import { WorkflowControllerV1 } from './workflow-v1.controller'; const MODULES = [ SharedModule, MessageTemplateModule, ChangeModule, AuthModule, IntegrationModule, PreferencesModule, OutboundWebhooksModule.forRoot(), ]; @Module({ imports: MODULES, controllers: [NotificationTemplateController, WorkflowControllerV1], providers: [...USE_CASES], exports: [...USE_CASES], }) export class WorkflowModuleV1 implements NestModule { configure(consumer: MiddlewareConsumer): MiddlewareConsumer | void {} } ================================================ FILE: apps/api/src/app/workflows-v2/dtos/base-step-issue.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { BaseIssueDto } from '@novu/application-generic'; import { ContentIssueEnum, IntegrationIssueEnum } from '@novu/shared'; import { IsEnum } from 'class-validator'; export class StepIssueDto extends BaseIssueDto { @ApiProperty({ description: 'Type of step issue', enum: [...Object.values(ContentIssueEnum), ...Object.values(IntegrationIssueEnum)], enumName: 'ContentIssueEnum | IntegrationIssueEnum', }) @IsEnum([...Object.values(ContentIssueEnum), ...Object.values(IntegrationIssueEnum)]) issueType: ContentIssueEnum | IntegrationIssueEnum; } ================================================ FILE: apps/api/src/app/workflows-v2/dtos/control-schemas.dto.ts ================================================ import { JSONSchemaDto, UiSchema } from '@novu/application-generic'; export class ControlSchemasDto { schema: JSONSchemaDto; uiSchema?: UiSchema; } ================================================ FILE: apps/api/src/app/workflows-v2/dtos/create-step.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger'; import { ChatControlDto, CustomControlDto, DelayControlDto, DigestControlDto, EmailControlDto, HttpRequestControlDto, InAppControlDto, PushControlDto, SmsControlDto, ThrottleControlDto, } from '@novu/application-generic'; import { StepTypeEnum } from '@novu/shared'; import { IsEnum, IsObject, IsOptional, IsString, Matches } from 'class-validator'; // Base DTO for common properties export class BaseStepConfigDto { @ApiProperty({ description: 'Database identifier of the step. Used for updating the step.', type: 'string', required: false, }) @IsString() @IsOptional() _id?: string; @ApiPropertyOptional({ description: 'Unique identifier for the step' }) @IsString() @Matches(/^[a-zA-Z0-9]+(?:[-_.][a-zA-Z0-9]+)*$/, { message: 'stepId must be a valid slug format (letters, numbers, hyphens, dot and underscores only)', }) @IsOptional() stepId?: string; @ApiProperty({ description: 'Name of the step', }) @IsString() name: string; } // Specific DTOs for each step type export class InAppStepUpsertDto extends BaseStepConfigDto { @ApiProperty({ enum: StepTypeEnum, enumName: 'StepTypeEnum', description: 'Type of the step', }) @IsEnum(StepTypeEnum) readonly type: StepTypeEnum = 'in_app' as StepTypeEnum; @ApiPropertyOptional({ description: 'Control values for the In-App step.', oneOf: [{ $ref: getSchemaPath(InAppControlDto) }, { type: 'object', additionalProperties: true }], }) @IsOptional() @IsObject() controlValues?: InAppControlDto | Record | null; } export class EmailStepUpsertDto extends BaseStepConfigDto { @ApiProperty({ enum: StepTypeEnum, enumName: 'StepTypeEnum', default: StepTypeEnum.EMAIL, description: 'Type of the step', }) @IsEnum(StepTypeEnum) readonly type: StepTypeEnum = 'email' as StepTypeEnum; @ApiPropertyOptional({ description: 'Control values for the Email step.', oneOf: [{ $ref: getSchemaPath(EmailControlDto) }, { type: 'object', additionalProperties: true }], }) @IsOptional() @IsObject() controlValues?: EmailControlDto | Record | null; } export class SmsStepUpsertDto extends BaseStepConfigDto { @ApiProperty({ enum: StepTypeEnum, enumName: 'StepTypeEnum', default: StepTypeEnum.SMS, description: 'Type of the step', }) @IsEnum(StepTypeEnum) readonly type: StepTypeEnum = 'sms' as StepTypeEnum; @ApiPropertyOptional({ description: 'Control values for the SMS step.', oneOf: [{ $ref: getSchemaPath(SmsControlDto) }, { type: 'object', additionalProperties: true }], }) @IsOptional() @IsObject() controlValues?: SmsControlDto | Record | null; } export class PushStepUpsertDto extends BaseStepConfigDto { @ApiProperty({ enum: StepTypeEnum, enumName: 'StepTypeEnum', default: StepTypeEnum.PUSH, description: 'Type of the step', }) @IsEnum(StepTypeEnum) readonly type: StepTypeEnum = 'push' as StepTypeEnum; @ApiPropertyOptional({ description: 'Control values for the Push step.', oneOf: [{ $ref: getSchemaPath(PushControlDto) }, { type: 'object', additionalProperties: true }], }) @IsOptional() @IsObject() controlValues?: PushControlDto | Record | null; } export class ChatStepUpsertDto extends BaseStepConfigDto { @ApiProperty({ enum: StepTypeEnum, enumName: 'StepTypeEnum', default: StepTypeEnum.CHAT, description: 'Type of the step', }) @IsEnum(StepTypeEnum) readonly type: StepTypeEnum = 'chat' as StepTypeEnum; @ApiPropertyOptional({ description: 'Control values for the Chat step.', oneOf: [{ $ref: getSchemaPath(ChatControlDto) }, { type: 'object', additionalProperties: true }], }) @IsOptional() @IsObject() controlValues?: ChatControlDto | Record | null; } export class DelayStepUpsertDto extends BaseStepConfigDto { @ApiProperty({ enum: StepTypeEnum, enumName: 'StepTypeEnum', default: StepTypeEnum.DELAY, description: 'Type of the step', }) @IsEnum(StepTypeEnum) readonly type: StepTypeEnum = 'delay' as StepTypeEnum; @ApiPropertyOptional({ description: 'Control values for the Delay step.', oneOf: [{ $ref: getSchemaPath(DelayControlDto) }, { type: 'object', additionalProperties: true }], }) @IsOptional() @IsObject() controlValues?: DelayControlDto | Record | null; } export class DigestStepUpsertDto extends BaseStepConfigDto { @ApiProperty({ enum: StepTypeEnum, enumName: 'StepTypeEnum', default: StepTypeEnum.DIGEST, description: 'Type of the step', }) @IsEnum(StepTypeEnum) readonly type: StepTypeEnum = 'digest' as StepTypeEnum; @ApiPropertyOptional({ description: 'Control values for the Digest step.', oneOf: [{ $ref: getSchemaPath(DigestControlDto) }, { type: 'object', additionalProperties: true }], }) @IsOptional() @IsObject() controlValues?: DigestControlDto | Record | null; } export class ThrottleStepUpsertDto extends BaseStepConfigDto { @ApiProperty({ enum: StepTypeEnum, enumName: 'StepTypeEnum', default: StepTypeEnum.THROTTLE, description: 'Type of the step', }) @IsEnum(StepTypeEnum) readonly type: StepTypeEnum = 'throttle' as StepTypeEnum; @ApiPropertyOptional({ description: 'Control values for the Throttle step.', oneOf: [{ $ref: getSchemaPath(ThrottleControlDto) }, { type: 'object', additionalProperties: true }], }) @IsOptional() @IsObject() controlValues?: ThrottleControlDto | Record | null; } export class CustomStepUpsertDto extends BaseStepConfigDto { @ApiProperty({ enum: StepTypeEnum, enumName: 'StepTypeEnum', default: StepTypeEnum.CUSTOM, description: 'Type of the step', }) @IsEnum(StepTypeEnum) readonly type: StepTypeEnum = 'custom' as StepTypeEnum; @ApiPropertyOptional({ description: 'Control values for the Custom step.', oneOf: [{ $ref: getSchemaPath(CustomControlDto) }, { type: 'object', additionalProperties: true }], }) @IsOptional() @IsObject() controlValues?: CustomControlDto | Record | null; } export class HttpRequestStepUpsertDto extends BaseStepConfigDto { @ApiProperty({ enum: StepTypeEnum, enumName: 'StepTypeEnum', default: StepTypeEnum.HTTP_REQUEST, description: 'Type of the step', }) @IsEnum(StepTypeEnum) readonly type: StepTypeEnum = 'http_request' as StepTypeEnum; @ApiPropertyOptional({ description: 'Control values for the HTTP Request step.', oneOf: [{ $ref: getSchemaPath(HttpRequestControlDto) }, { type: 'object', additionalProperties: true }], }) @IsOptional() @IsObject() controlValues?: HttpRequestControlDto | Record | null; } /* * This export allows using StepUpsertDto as a type for the discriminated union. * The actual DTO used will be one of the specific step DTOs at runtime. */ export type StepUpsertDto = | InAppStepUpsertDto | EmailStepUpsertDto | SmsStepUpsertDto | PushStepUpsertDto | ChatStepUpsertDto | DelayStepUpsertDto | DigestStepUpsertDto | ThrottleStepUpsertDto | CustomStepUpsertDto | HttpRequestStepUpsertDto; ================================================ FILE: apps/api/src/app/workflows-v2/dtos/create-workflow.dto.ts ================================================ import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger'; import { ChatControlDto, CustomControlDto, DelayControlDto, DigestControlDto, EmailControlDto, HttpRequestControlDto, InAppControlDto, PushControlDto, SmsControlDto, ThrottleControlDto, WorkflowCommonsFields, } from '@novu/application-generic'; import { SeverityLevelEnum, StepTypeEnum, WorkflowCreationSourceEnum } from '@novu/shared'; import { Type } from 'class-transformer'; import { IsArray, IsEnum, IsOptional, IsString, Matches, ValidateNested } from 'class-validator'; import { BaseStepConfigDto, ChatStepUpsertDto, CustomStepUpsertDto, DelayStepUpsertDto, DigestStepUpsertDto, EmailStepUpsertDto, HttpRequestStepUpsertDto, InAppStepUpsertDto, PushStepUpsertDto, SmsStepUpsertDto, ThrottleStepUpsertDto, } from './create-step.dto'; import { PreferencesRequestDto } from './preferences.request.dto'; export type StepCreateDto = | InAppStepUpsertDto | EmailStepUpsertDto | SmsStepUpsertDto | PushStepUpsertDto | ChatStepUpsertDto | DelayStepUpsertDto | DigestStepUpsertDto | ThrottleStepUpsertDto | CustomStepUpsertDto | HttpRequestStepUpsertDto; @ApiExtraModels( InAppStepUpsertDto, EmailStepUpsertDto, SmsStepUpsertDto, PushStepUpsertDto, ChatStepUpsertDto, DelayStepUpsertDto, DigestStepUpsertDto, ThrottleStepUpsertDto, CustomStepUpsertDto, HttpRequestStepUpsertDto, InAppControlDto, EmailControlDto, SmsControlDto, PushControlDto, ChatControlDto, DelayControlDto, DigestControlDto, ThrottleControlDto, CustomControlDto, HttpRequestControlDto ) export class CreateWorkflowDto extends WorkflowCommonsFields { @ApiProperty({ description: 'Unique identifier for the workflow' }) @IsString() @Matches(/^[a-zA-Z0-9]+(?:[-_.][a-zA-Z0-9]+)*$/, { message: 'workflowId must be a valid slug format (letters, numbers, hyphens, dot and underscores only)', }) workflowId: string; @ApiProperty({ description: 'Steps of the workflow', type: 'array', items: { oneOf: [ { $ref: getSchemaPath(InAppStepUpsertDto) }, { $ref: getSchemaPath(EmailStepUpsertDto) }, { $ref: getSchemaPath(SmsStepUpsertDto) }, { $ref: getSchemaPath(PushStepUpsertDto) }, { $ref: getSchemaPath(ChatStepUpsertDto) }, { $ref: getSchemaPath(DelayStepUpsertDto) }, { $ref: getSchemaPath(DigestStepUpsertDto) }, { $ref: getSchemaPath(ThrottleStepUpsertDto) }, { $ref: getSchemaPath(CustomStepUpsertDto) }, { $ref: getSchemaPath(HttpRequestStepUpsertDto) }, ], discriminator: { propertyName: 'type', mapping: { [StepTypeEnum.IN_APP]: getSchemaPath(InAppStepUpsertDto), [StepTypeEnum.EMAIL]: getSchemaPath(EmailStepUpsertDto), [StepTypeEnum.SMS]: getSchemaPath(SmsStepUpsertDto), [StepTypeEnum.PUSH]: getSchemaPath(PushStepUpsertDto), [StepTypeEnum.CHAT]: getSchemaPath(ChatStepUpsertDto), [StepTypeEnum.DELAY]: getSchemaPath(DelayStepUpsertDto), [StepTypeEnum.DIGEST]: getSchemaPath(DigestStepUpsertDto), [StepTypeEnum.THROTTLE]: getSchemaPath(ThrottleStepUpsertDto), [StepTypeEnum.CUSTOM]: getSchemaPath(CustomStepUpsertDto), [StepTypeEnum.HTTP_REQUEST]: getSchemaPath(HttpRequestStepUpsertDto), }, }, }, }) @IsArray() @ValidateNested({ each: true }) @Type(() => BaseStepConfigDto, { discriminator: { property: 'type', subTypes: [ { name: StepTypeEnum.IN_APP, value: InAppStepUpsertDto }, { name: StepTypeEnum.EMAIL, value: EmailStepUpsertDto }, { name: StepTypeEnum.SMS, value: SmsStepUpsertDto }, { name: StepTypeEnum.PUSH, value: PushStepUpsertDto }, { name: StepTypeEnum.CHAT, value: ChatStepUpsertDto }, { name: StepTypeEnum.DELAY, value: DelayStepUpsertDto }, { name: StepTypeEnum.DIGEST, value: DigestStepUpsertDto }, { name: StepTypeEnum.THROTTLE, value: ThrottleStepUpsertDto }, { name: StepTypeEnum.CUSTOM, value: CustomStepUpsertDto }, { name: StepTypeEnum.HTTP_REQUEST, value: HttpRequestStepUpsertDto }, ], }, keepDiscriminatorProperty: true, }) steps: ( | InAppStepUpsertDto | EmailStepUpsertDto | SmsStepUpsertDto | PushStepUpsertDto | ChatStepUpsertDto | DelayStepUpsertDto | DigestStepUpsertDto | ThrottleStepUpsertDto | CustomStepUpsertDto | HttpRequestStepUpsertDto )[]; @ApiProperty({ description: 'Source of workflow creation', enum: WorkflowCreationSourceEnum, enumName: 'WorkflowCreationSourceEnum', required: false, default: WorkflowCreationSourceEnum.EDITOR, }) @IsOptional() @IsEnum(WorkflowCreationSourceEnum) __source?: WorkflowCreationSourceEnum; @ApiPropertyOptional({ description: 'Workflow preferences', type: PreferencesRequestDto, required: false, }) @IsOptional() @Type(() => PreferencesRequestDto) preferences?: PreferencesRequestDto; @ApiPropertyOptional({ description: 'Severity of the workflow', required: false, enum: [...Object.values(SeverityLevelEnum)], enumName: 'SeverityLevelEnum', }) @IsOptional() @IsEnum(SeverityLevelEnum) severity?: SeverityLevelEnum; } ================================================ FILE: apps/api/src/app/workflows-v2/dtos/duplicate-workflow.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsArray, IsBoolean, IsOptional, IsString, Matches } from 'class-validator'; export class DuplicateWorkflowDto { @ApiProperty({ description: 'Name of the workflow', required: false, }) @IsOptional() @IsString() name?: string; @ApiPropertyOptional({ description: 'Custom workflow identifier for the duplicated workflow', type: String, }) @IsOptional() @IsString() @Matches(/^[a-zA-Z0-9]+(?:[-_.][a-zA-Z0-9]+)*$/, { message: 'workflowId must be a valid slug format (letters, numbers, hyphens, dot and underscores only)', }) workflowId?: string; @ApiPropertyOptional({ description: 'Tags associated with the workflow', type: [String], }) @IsArray() @IsOptional() tags?: string[]; @ApiProperty({ description: 'Description of the workflow', required: false, }) @IsString() @IsOptional() description?: string; @ApiPropertyOptional({ description: 'Enable or disable translations for this workflow', required: false, default: false, }) @IsOptional() @IsBoolean() isTranslationEnabled?: boolean; } ================================================ FILE: apps/api/src/app/workflows-v2/dtos/get-list-query-params.ts ================================================ import { ApiPropertyOptional } from '@nestjs/swagger'; import { WorkflowResponseDto } from '@novu/application-generic'; import { WorkflowStatusEnum } from '@novu/shared'; import { Transform } from 'class-transformer'; import { IsArray, IsEnum, IsOptional, IsString } from 'class-validator'; import { LimitOffsetPaginationQueryDto } from '../../shared/dtos/limit-offset-pagination.dto'; export class GetListQueryParamsDto extends LimitOffsetPaginationQueryDto(WorkflowResponseDto, [ 'createdAt', 'updatedAt', 'name', 'lastTriggeredAt', ]) { @ApiPropertyOptional({ description: 'Search query to filter workflows', type: 'string', required: false, }) @IsOptional() @IsString() query?: string; @ApiPropertyOptional({ description: 'Filter workflows by tags', type: [String], required: false, }) @IsOptional() @Transform(({ value }) => (value === undefined ? undefined : Array.isArray(value) ? value : [value])) @IsArray() @IsString({ each: true }) tags?: string[]; @ApiPropertyOptional({ description: 'Filter workflows by status', enum: WorkflowStatusEnum, enumName: 'WorkflowStatusEnum', type: [String], required: false, }) @IsOptional() @Transform(({ value }) => (value === undefined ? undefined : Array.isArray(value) ? value : [value])) @IsArray() @IsEnum(WorkflowStatusEnum, { each: true }) status?: WorkflowStatusEnum[]; } ================================================ FILE: apps/api/src/app/workflows-v2/dtos/index.ts ================================================ export * from './control-schemas.dto'; export * from './create-step.dto'; export * from './create-workflow.dto'; export * from './duplicate-workflow.dto'; export * from './get-list-query-params'; export * from './list-workflow.dto'; export * from './patch-step-data.dto'; export * from './patch-workflow.dto'; export * from './sync-workflow.dto'; export * from './test-http-endpoint.dto'; export * from './update-workflow.dto'; export * from './workflow-test-data.dto'; ================================================ FILE: apps/api/src/app/workflows-v2/dtos/list-workflow.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { WorkflowListResponseDto } from '@novu/application-generic'; import { Type } from 'class-transformer'; import { IsArray, IsNumber, ValidateNested } from 'class-validator'; export class ListWorkflowResponse { @ApiProperty({ description: 'List of workflows', type: WorkflowListResponseDto, isArray: true, }) @IsArray() @ValidateNested({ each: true }) @Type(() => WorkflowListResponseDto) workflows: WorkflowListResponseDto[]; @ApiProperty({ description: 'Total number of workflows', type: 'number', }) @IsNumber() totalCount: number; } ================================================ FILE: apps/api/src/app/workflows-v2/dtos/patch-step-data.dto.ts ================================================ import { ApiPropertyOptional } from '@nestjs/swagger'; import { IsObject, IsOptional, IsString } from 'class-validator'; export class PatchStepDataDto { @ApiPropertyOptional({ description: 'New name for the step', type: 'string', }) @IsOptional() @IsString() name?: string; @ApiPropertyOptional({ description: 'Control values for the step', type: 'object', nullable: true, additionalProperties: true, }) @IsOptional() @IsObject() controlValues?: Record | null; } ================================================ FILE: apps/api/src/app/workflows-v2/dtos/patch-workflow.dto.ts ================================================ import { ApiPropertyOptional } from '@nestjs/swagger'; import { IsArray, IsBoolean, IsObject, IsOptional, IsString } from 'class-validator'; import { IsValidJsonSchema } from '../../shared/validators/json-schema.validator'; export class PatchWorkflowDto { @ApiPropertyOptional({ description: 'Activate or deactivate the workflow', type: 'boolean', }) @IsOptional() @IsBoolean() active?: boolean; @ApiPropertyOptional({ description: 'New name for the workflow', type: 'string', }) @IsOptional() @IsString() name?: string; @ApiPropertyOptional({ description: 'Updated description of the workflow', type: 'string', }) @IsOptional() @IsString() description?: string; @ApiPropertyOptional({ description: 'Tags associated with the workflow', type: 'array', items: { type: 'string' }, }) @IsOptional() @IsArray() @IsString({ each: true }) tags?: string[]; @ApiPropertyOptional({ description: 'The payload JSON Schema for the workflow', type: 'object', additionalProperties: true, nullable: true, }) @IsOptional() @IsValidJsonSchema({ message: 'payloadSchema must be a valid JSON schema', nullable: true, }) payloadSchema?: object; @ApiPropertyOptional({ description: 'Enable or disable payload schema validation', type: 'boolean', }) @IsOptional() @IsBoolean() validatePayload?: boolean; @ApiPropertyOptional({ description: 'Enable or disable translations for this workflow', type: 'boolean', }) @IsOptional() @IsBoolean() isTranslationEnabled?: boolean; } ================================================ FILE: apps/api/src/app/workflows-v2/dtos/preferences.request.dto.ts ================================================ import { ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger'; import { WorkflowPreferencesDto } from '@novu/application-generic'; import { Type } from 'class-transformer'; import { IsOptional, ValidateNested } from 'class-validator'; export class PreferencesRequestDto { @ApiPropertyOptional({ description: 'User workflow preferences', oneOf: [{ $ref: getSchemaPath(WorkflowPreferencesDto) }], nullable: true, }) @IsOptional() @ValidateNested() @Type(() => WorkflowPreferencesDto) user: WorkflowPreferencesDto | null; @ApiPropertyOptional({ description: 'Workflow-specific preferences', type: () => WorkflowPreferencesDto, nullable: true, required: false, }) @IsOptional() @ValidateNested() @Type(() => WorkflowPreferencesDto) workflow?: WorkflowPreferencesDto | null; } ================================================ FILE: apps/api/src/app/workflows-v2/dtos/sync-workflow.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { IsString } from 'class-validator'; export class SyncWorkflowDto { @ApiProperty({ description: 'Target environment identifier to sync the workflow to', type: 'string', }) @IsString() targetEnvironmentId: string; } ================================================ FILE: apps/api/src/app/workflows-v2/dtos/test-http-endpoint.dto.ts ================================================ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { PreviewPayloadDto } from '@novu/application-generic'; import { Type } from 'class-transformer'; import { IsNumber, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator'; export class TestHttpEndpointRequestDto { @ApiPropertyOptional({ description: 'HTTP request control values (url, method, headers, body)', type: 'object', additionalProperties: true, }) @IsOptional() @IsObject() controlValues?: Record; @ApiPropertyOptional({ description: 'Preview payload for variable resolution (subscriber, payload, steps, context)', type: () => PreviewPayloadDto, }) @IsOptional() @ValidateNested() @Type(() => PreviewPayloadDto) previewPayload?: PreviewPayloadDto; } export class ResolvedRequestDto { @ApiProperty({ description: 'Resolved URL after template compilation' }) @IsString() url: string; @ApiProperty({ description: 'HTTP method' }) @IsString() method: string; @ApiPropertyOptional({ description: 'Resolved headers after template compilation', type: 'object', additionalProperties: true, }) @IsOptional() @IsObject() headers?: Record; @ApiPropertyOptional({ description: 'Resolved body after template compilation', type: 'object', additionalProperties: true, }) @IsOptional() @IsObject() body?: Record; } export class TestHttpEndpointResponseDto { @ApiProperty({ description: 'HTTP response status code' }) @IsNumber() statusCode: number; @ApiPropertyOptional({ description: 'Parsed response body', nullable: true, }) body: unknown; @ApiProperty({ description: 'Response headers', type: 'object', additionalProperties: true, }) @IsObject() headers: Record; @ApiProperty({ description: 'Request duration in milliseconds' }) @IsNumber() durationMs: number; @ApiProperty({ description: 'The compiled request that was sent', type: () => ResolvedRequestDto }) @ValidateNested() @Type(() => ResolvedRequestDto) resolvedRequest: ResolvedRequestDto; } ================================================ FILE: apps/api/src/app/workflows-v2/dtos/update-workflow.dto.ts ================================================ import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger'; import { WorkflowCommonsFields } from '@novu/application-generic'; import { ResourceOriginEnum, SeverityLevelEnum, StepTypeEnum } from '@novu/shared'; import { Type } from 'class-transformer'; import { IsArray, IsEnum, IsOptional, ValidateNested } from 'class-validator'; import { BaseStepConfigDto, ChatStepUpsertDto, CustomStepUpsertDto, DelayStepUpsertDto, DigestStepUpsertDto, EmailStepUpsertDto, HttpRequestStepUpsertDto, InAppStepUpsertDto, PushStepUpsertDto, SmsStepUpsertDto, } from './create-step.dto'; import { PreferencesRequestDto } from './preferences.request.dto'; @ApiExtraModels( InAppStepUpsertDto, EmailStepUpsertDto, SmsStepUpsertDto, PushStepUpsertDto, ChatStepUpsertDto, DelayStepUpsertDto, DigestStepUpsertDto, CustomStepUpsertDto, HttpRequestStepUpsertDto ) export class UpdateWorkflowDto extends WorkflowCommonsFields { @ApiPropertyOptional({ description: 'Workflow ID (allowed only for code-first workflows)', type: 'string', }) @IsOptional() workflowId?: string; @ApiProperty({ description: 'Steps of the workflow', type: 'array', items: { oneOf: [ { $ref: getSchemaPath(InAppStepUpsertDto) }, { $ref: getSchemaPath(EmailStepUpsertDto) }, { $ref: getSchemaPath(SmsStepUpsertDto) }, { $ref: getSchemaPath(PushStepUpsertDto) }, { $ref: getSchemaPath(ChatStepUpsertDto) }, { $ref: getSchemaPath(DelayStepUpsertDto) }, { $ref: getSchemaPath(DigestStepUpsertDto) }, { $ref: getSchemaPath(CustomStepUpsertDto) }, { $ref: getSchemaPath(HttpRequestStepUpsertDto) }, ], discriminator: { propertyName: 'type', mapping: { [StepTypeEnum.IN_APP]: getSchemaPath(InAppStepUpsertDto), [StepTypeEnum.EMAIL]: getSchemaPath(EmailStepUpsertDto), [StepTypeEnum.SMS]: getSchemaPath(SmsStepUpsertDto), [StepTypeEnum.PUSH]: getSchemaPath(PushStepUpsertDto), [StepTypeEnum.CHAT]: getSchemaPath(ChatStepUpsertDto), [StepTypeEnum.DELAY]: getSchemaPath(DelayStepUpsertDto), [StepTypeEnum.DIGEST]: getSchemaPath(DigestStepUpsertDto), [StepTypeEnum.CUSTOM]: getSchemaPath(CustomStepUpsertDto), [StepTypeEnum.HTTP_REQUEST]: getSchemaPath(HttpRequestStepUpsertDto), }, }, }, }) @IsArray() @ValidateNested({ each: true }) @Type(() => BaseStepConfigDto, { discriminator: { property: 'type', subTypes: [ { name: StepTypeEnum.IN_APP, value: InAppStepUpsertDto }, { name: StepTypeEnum.EMAIL, value: EmailStepUpsertDto }, { name: StepTypeEnum.SMS, value: SmsStepUpsertDto }, { name: StepTypeEnum.PUSH, value: PushStepUpsertDto }, { name: StepTypeEnum.CHAT, value: ChatStepUpsertDto }, { name: StepTypeEnum.DELAY, value: DelayStepUpsertDto }, { name: StepTypeEnum.DIGEST, value: DigestStepUpsertDto }, { name: StepTypeEnum.CUSTOM, value: CustomStepUpsertDto }, { name: StepTypeEnum.HTTP_REQUEST, value: HttpRequestStepUpsertDto }, ], }, keepDiscriminatorProperty: true, }) steps: ( | InAppStepUpsertDto | EmailStepUpsertDto | SmsStepUpsertDto | PushStepUpsertDto | ChatStepUpsertDto | DelayStepUpsertDto | DigestStepUpsertDto | CustomStepUpsertDto | HttpRequestStepUpsertDto )[]; @ApiProperty({ description: 'Workflow preferences', type: () => PreferencesRequestDto, }) @ValidateNested() @Type(() => PreferencesRequestDto) preferences: PreferencesRequestDto; @ApiProperty({ description: 'Origin of the workflow', enum: [...Object.values(ResourceOriginEnum)], enumName: 'ResourceOriginEnum', }) @IsEnum(ResourceOriginEnum) origin: ResourceOriginEnum; @ApiPropertyOptional({ description: 'Severity of the workflow', required: false, enum: [...Object.values(SeverityLevelEnum)], enumName: 'SeverityLevelEnum', }) @IsOptional() @IsEnum(SeverityLevelEnum) severity?: SeverityLevelEnum; } ================================================ FILE: apps/api/src/app/workflows-v2/dtos/workflow-test-data.dto.ts ================================================ import { ApiProperty } from '@nestjs/swagger'; import { JSONSchemaDto } from '@novu/application-generic'; import { Type } from 'class-transformer'; import { ValidateNested } from 'class-validator'; export class WorkflowTestDataResponseDto { @ApiProperty({ description: 'JSON Schema for recipient data', type: () => JSONSchemaDto, }) @ValidateNested() @Type(() => JSONSchemaDto) to: JSONSchemaDto; @ApiProperty({ description: 'JSON Schema for payload data', type: () => JSONSchemaDto, }) @ValidateNested() @Type(() => JSONSchemaDto) payload: JSONSchemaDto; } ================================================ FILE: apps/api/src/app/workflows-v2/e2e/generate-preview.e2e.ts ================================================ import { randomUUID } from 'node:crypto'; import { Novu } from '@novu/api'; import { ChannelTypeEnum, CreateWorkflowDto, EmailRenderOutput, GeneratePreviewRequestDto, GeneratePreviewResponseDto, PreviewPayloadDto, ResourceOriginEnum, UpdateWorkflowDto, UpdateWorkflowDtoSteps, WorkflowCreationSourceEnum, WorkflowResponseDto, } from '@novu/api/models/components'; import { buildWorkflowSchema, DEFAULT_ARRAY_ELEMENTS, EmailControlType } from '@novu/application-generic'; import { EnvironmentRepository, NotificationTemplateEntity, NotificationTemplateRepository } from '@novu/dal'; import { CronExpressionEnum, RedirectTargetEnum, StepTypeEnum, slugify } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { beforeEach } from 'mocha'; import { initNovuClassSdkInternalAuth } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; import { fullCodeSnippet, previewPayloadExample } from '../maily-test-data'; import { buildWorkflow } from '../workflow.controller.e2e'; const TEST_WORKFLOW_NAME = 'Test Workflow Name'; const SUBJECT_TEST_PAYLOAD = '{{payload.subject.test.payload}}'; const PLACEHOLDER_SUBJECT_INAPP = '{{payload.subject}}'; const PLACEHOLDER_SUBJECT_INAPP_PAYLOAD_VALUE = 'this is the replacement text for the placeholder'; describe('Workflow Step Preview - POST /:workflowId/step/:stepId/preview #novu-v2', async () => { let session: UserSession; const notificationTemplateRepository = new NotificationTemplateRepository(); const environmentRepository = new EnvironmentRepository(); let novuClient: Novu; beforeEach(async () => { session = new UserSession(); await session.initialize(); novuClient = initNovuClassSdkInternalAuth(session); }); it('should generate preview for in-app step', async () => { const payloadSchema = { type: 'object', properties: { placeholder: { type: 'object', properties: { body: { type: 'string', }, }, }, primaryUrlLabel: { type: 'string', }, }, }; const workflow = await createWorkflow({}, payloadSchema); await emulateExternalOrigin(workflow.id); const stepId = workflow.steps[0].id; const controlValues = { subject: `{{subscriber.firstName}} Hello, World! `, body: `Hello, World! {{payload.placeholder.body}}`, avatar: 'https://www.example.com/avatar.png', primaryAction: { label: '{{payload.primaryUrlLabel}}', redirect: { target: RedirectTargetEnum.BLANK, url: '/home/primary-action', }, }, secondaryAction: { label: 'Secondary Action', redirect: { target: RedirectTargetEnum.BLANK, url: '/home/secondary-action', }, }, data: { key: 'value', }, redirect: { target: RedirectTargetEnum.BLANK, url: 'https://www.example.com/redirect', }, }; const previewPayload: PreviewPayloadDto = { subscriber: { firstName: 'John', }, payload: { placeholder: { body: 'This is a body', }, primaryUrlLabel: 'https://example.com', }, }; const { result } = await novuClient.workflows.steps.generatePreview({ workflowId: workflow.id, stepId, generatePreviewRequestDto: { controlValues, previewPayload }, }); expect(result).to.deep.equal({ schema: { type: 'object', properties: { payload: { type: 'object', properties: { placeholder: { type: 'object', properties: { body: { type: 'string', }, }, }, primaryUrlLabel: { type: 'string', }, }, }, subscriber: { type: 'object', description: 'Schema representing the subscriber entity', properties: { firstName: { type: 'string', description: "Subscriber's first name", }, lastName: { type: 'string', description: "Subscriber's last name", }, email: { type: 'string', description: "Subscriber's email address", }, phone: { type: 'string', description: "Subscriber's phone number (optional)", }, avatar: { type: 'string', description: "URL to the subscriber's avatar image (optional)", }, locale: { type: 'string', description: 'Locale for the subscriber (optional)', }, timezone: { type: 'string', description: 'Timezone for the subscriber (optional)', }, subscriberId: { type: 'string', description: 'Unique identifier for the subscriber', }, isOnline: { type: 'boolean', description: 'Indicates if the subscriber is online (optional)', }, lastOnlineAt: { type: 'string', format: 'date-time', description: 'The last time the subscriber was online (optional)', }, data: { type: 'object', properties: {}, required: [], additionalProperties: true, }, }, required: ['subscriberId'], additionalProperties: false, }, steps: { type: 'object', properties: {}, required: [], additionalProperties: false, description: 'Previous Steps Results', }, workflow: buildWorkflowSchema(), context: { type: 'object', description: 'Context data passed at trigger time following ContextPayload structure', properties: {}, required: [], additionalProperties: { type: 'object', description: 'Context value - can be accessed as string or object', properties: { id: { type: 'string', description: 'Context identifier', }, data: { type: 'object', description: 'Additional context data', properties: {}, additionalProperties: true, }, }, required: [], additionalProperties: false, }, }, env: { type: 'object', description: 'Environment variables accessible in workflow templates', properties: { name: { type: 'string', description: 'Environment variable: name', }, type: { type: 'string', description: 'Environment variable: type', }, }, required: [], additionalProperties: false, }, }, additionalProperties: false, }, result: { preview: { subject: 'John Hello, World! ', body: 'Hello, World! This is a body', avatar: 'https://www.example.com/avatar.png', primaryAction: { label: 'https://example.com', redirect: { url: '/home/primary-action', target: '_blank', }, }, secondaryAction: { label: 'Secondary Action', redirect: { url: '/home/secondary-action', target: '_blank', }, }, redirect: { url: 'https://www.example.com/redirect', target: '_blank', }, data: { key: 'value', }, }, type: 'in_app', }, previewPayloadExample: { subscriber: { firstName: 'John', lastName: 'Doe', email: 'user@example.com', phone: '+1234567890', avatar: 'https://example.com/avatar.png', locale: 'en_US', timezone: 'America/New_York', data: {}, }, payload: { placeholder: { body: 'This is a body', }, primaryUrlLabel: 'https://example.com', }, steps: {}, }, }); }); it('should generate preview for in-app step, based on stored payload schema', async () => { const payloadSchema = { type: 'object', properties: { placeholder: { type: 'object', properties: { body: { type: 'string', default: 'Default body text', }, random: { type: 'string', }, }, }, primaryUrlLabel: { type: 'string', default: 'Click here', }, organizationName: { type: 'string', default: 'Pokemon Organization', }, }, }; const workflow = await createWorkflow({}, payloadSchema); await emulateExternalOrigin(workflow.id); const stepId = workflow.steps[0].id; const controlValues = { subject: `{{subscriber.firstName}} Hello, World! `, body: `Hello, World! {{payload.placeholder.body}} {{payload.placeholder.random}}`, avatar: 'https://www.example.com/avatar.png', primaryAction: { label: '{{payload.primaryUrlLabel}}', redirect: { target: RedirectTargetEnum.BLANK, url: '/home/primary-action', }, }, secondaryAction: { label: 'Secondary Action', redirect: { target: RedirectTargetEnum.BLANK, url: '/home/secondary-action', }, }, data: { key: 'value', }, redirect: { target: RedirectTargetEnum.BLANK, url: 'https://www.example.com/redirect', }, }; const clientVariablesExample = { subscriber: { firstName: 'First Name', }, payload: { primaryUrlLabel: 'New Click Here', placeholder: { random: 'random', }, }, }; const { result } = await novuClient.workflows.steps.generatePreview({ generatePreviewRequestDto: { controlValues, previewPayload: clientVariablesExample, }, stepId, workflowId: workflow.id, }); expect(result).to.deep.equal({ result: { preview: { subject: 'First Name Hello, World! ', body: 'Hello, World! Default body text random', avatar: 'https://www.example.com/avatar.png', primaryAction: { label: 'New Click Here', redirect: { url: '/home/primary-action', target: '_blank', }, }, secondaryAction: { label: 'Secondary Action', redirect: { url: '/home/secondary-action', target: '_blank', }, }, redirect: { url: 'https://www.example.com/redirect', target: '_blank', }, data: { key: 'value', }, }, type: 'in_app', }, schema: { additionalProperties: false, properties: { payload: { properties: { organizationName: { default: 'Pokemon Organization', type: 'string', }, placeholder: { properties: { body: { default: 'Default body text', type: 'string', }, random: { type: 'string', }, }, type: 'object', }, primaryUrlLabel: { default: 'Click here', type: 'string', }, }, type: 'object', }, subscriber: { additionalProperties: false, description: 'Schema representing the subscriber entity', properties: { firstName: { type: 'string', description: "Subscriber's first name", }, lastName: { type: 'string', description: "Subscriber's last name", }, email: { type: 'string', description: "Subscriber's email address", }, phone: { type: 'string', description: "Subscriber's phone number (optional)", }, avatar: { type: 'string', description: "URL to the subscriber's avatar image (optional)", }, locale: { type: 'string', description: 'Locale for the subscriber (optional)', }, timezone: { type: 'string', description: 'Timezone for the subscriber (optional)', }, subscriberId: { type: 'string', description: 'Unique identifier for the subscriber', }, isOnline: { type: 'boolean', description: 'Indicates if the subscriber is online (optional)', }, lastOnlineAt: { type: 'string', format: 'date-time', description: 'The last time the subscriber was online (optional)', }, data: { additionalProperties: true, properties: {}, required: [], type: 'object', }, }, required: ['subscriberId'], type: 'object', }, steps: { type: 'object', properties: {}, required: [], additionalProperties: false, description: 'Previous Steps Results', }, workflow: buildWorkflowSchema(), context: { type: 'object', description: 'Context data passed at trigger time following ContextPayload structure', properties: {}, required: [], additionalProperties: { type: 'object', description: 'Context value - can be accessed as string or object', properties: { id: { type: 'string', description: 'Context identifier', }, data: { type: 'object', description: 'Additional context data', properties: {}, additionalProperties: true, }, }, required: [], additionalProperties: false, }, }, env: { type: 'object', description: 'Environment variables accessible in workflow templates', properties: { name: { type: 'string', description: 'Environment variable: name', }, type: { type: 'string', description: 'Environment variable: type', }, }, required: [], additionalProperties: false, }, }, type: 'object', }, previewPayloadExample: { subscriber: { firstName: 'First Name', lastName: 'Doe', email: 'user@example.com', phone: '+1234567890', avatar: 'https://example.com/avatar.png', locale: 'en_US', timezone: 'America/New_York', data: {}, }, payload: { placeholder: { body: 'Default body text', random: 'random', }, primaryUrlLabel: 'New Click Here', organizationName: 'Pokemon Organization', }, steps: {}, }, }); }); it('should return 201 for non-existent workflow', async () => { const pay = { type: 'object', properties: { firstName: { type: 'string', }, lastName: { type: 'string', }, organizationName: { type: 'string', }, }, }; const workflow = await createWorkflow({ payloadSchema: pay }); const nonExistentWorkflowId = 'non-existent-id'; const stepId = workflow.steps[0].id; const { result } = await novuClient.workflows.steps.generatePreview({ generatePreviewRequestDto: { controlValues: {}, }, stepId, workflowId: nonExistentWorkflowId, }); expect(result).to.deep.equal({ schema: null, result: { preview: {}, }, previewPayloadExample: {}, }); }); it('should return 201 for non-existent step', async () => { const pay = { type: 'object', properties: { firstName: { type: 'string', }, lastName: { type: 'string', }, organizationName: { type: 'string', }, }, }; const workflow = await createWorkflow({ payloadSchema: pay }); const nonExistentStepId = 'non-existent-step-id'; const { result } = await novuClient.workflows.steps.generatePreview({ generatePreviewRequestDto: { controlValues: {}, }, stepId: nonExistentStepId, workflowId: workflow.id, }); expect(result).to.deep.equal({ schema: null, result: { preview: {}, }, previewPayloadExample: {}, }); }); it('should generate preview for email step with subscriber variables', async () => { const createWorkflowDto: CreateWorkflowDto = { tags: [], source: WorkflowCreationSourceEnum.Editor, name: 'Email Test Workflow', workflowId: `email-test-workflow-${randomUUID()}`, description: 'This is a test workflow', active: true, steps: [ { name: 'Email Test Step', type: StepTypeEnum.EMAIL, controlValues: { subject: 'Test Email Subject', body: 'Hello, {{subscriber.firstName}}!', disableOutputSanitization: false, }, }, ], }; const { result: workflow } = await novuClient.workflows.create(createWorkflowDto); const stepId = workflow.steps[0].id; const controlValues = { subject: 'Test Email Subject', body: 'Hello, {{subscriber.firstName}}!', disableOutputSanitization: false, }; const previewPayload: PreviewPayloadDto = { subscriber: { firstName: 'John', }, }; const { result } = await novuClient.workflows.steps.generatePreview({ workflowId: workflow.id, stepId, generatePreviewRequestDto: { controlValues, previewPayload }, }); expect(result.result.preview.subject).to.contain('Test Email Subject'); expect(result.result.preview.body).to.contain('Hello, John!'); }); it.skip('should generate preview for the email step with digest variables', async () => { const { workflowId, emailStepDatabaseId } = await createWorkflowWithEmailLookingAtDigestResult(); // Helper function to validate digest event structure const validateDigestEvents = (events: any[], expectedPayload: any) => { expect(events).to.have.length(DEFAULT_ARRAY_ELEMENTS); events.forEach((event) => { expect(event).to.have.property('id').that.is.a('string'); expect(event).to.have.property('time').that.is.a('string'); expect(event).to.have.property('payload').that.deep.equals(expectedPayload); }); }; // testing the steps.digest-step.events.length variable const controlValues1 = { body: '{"type":"doc","content":[{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","text":"events length "},{"type":"variable","attrs":{"id":"steps.digest-step.events.length","label":null,"fallback":null,"required":false,"aliasFor":null}},{"type":"text","text":" "}]},{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","text":" "}]}]}', subject: 'events length', }; const previewResponse1 = await novuClient.workflows.steps.generatePreview({ generatePreviewRequestDto: { controlValues: controlValues1, previewPayload: {} }, stepId: emailStepDatabaseId, workflowId, }); expect(previewResponse1.result.result.preview.body).to.contain(`events length ${DEFAULT_ARRAY_ELEMENTS}`); validateDigestEvents(previewResponse1.result.previewPayloadExample.steps?.['digest-step'].events, { foo: { bar: { first: 'example text', baz: { second: 'example text', }, }, }, name: 'John Doe', items: [ { foo: 'example text', bar: 'example text' }, { foo: 'example text', bar: 'example text' }, { foo: 'example text', bar: 'example text' }, ], baz: 'example text', paragraph_link: 'https://example.com', heading_link: 'https://example.com', blockquote_link: 'https://example.com', bullet_link: 'https://example.com', button_link: 'https://example.com', image_variable: 'example text', image_link: 'https://example.com', inline_image_link: 'https://example.com', inline_image_url: 'https://example.com', numbered_link: 'https://example.com', third: 'example text', }); // testing the steps.digest-step.eventCount variable const controlValues2 = { body: '{"type":"doc","content":[{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","text":"eventCount "},{"type":"variable","attrs":{"id":"steps.digest-step.eventCount","label":null,"fallback":null,"required":false,"aliasFor":null}},{"type":"text","text":" "}]}]}', subject: 'eventCount', }; const previewResponse2 = await novuClient.workflows.steps.generatePreview({ generatePreviewRequestDto: { controlValues: controlValues2, previewPayload: {} }, stepId: emailStepDatabaseId, workflowId, }); expect(previewResponse2.result.result.preview.body).to.contain(`eventCount ${DEFAULT_ARRAY_ELEMENTS}`); validateDigestEvents(previewResponse2.result.previewPayloadExample.steps?.['digest-step'].events, { foo: { bar: { first: 'example text', baz: { second: 'example text', }, }, }, name: 'John Doe', items: [ { foo: 'example text', bar: 'example text' }, { foo: 'example text', bar: 'example text' }, { foo: 'example text', bar: 'example text' }, ], baz: 'example text', paragraph_link: 'https://example.com', heading_link: 'https://example.com', blockquote_link: 'https://example.com', bullet_link: 'https://example.com', button_link: 'https://example.com', image_variable: 'example text', image_link: 'https://example.com', inline_image_link: 'https://example.com', inline_image_url: 'https://example.com', numbered_link: 'https://example.com', third: 'example text', }); // testing the steps.digest-step.events array and direct access to the first item const controlValues3 = { body: '{"type":"doc","content":[{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"variable","attrs":{"id":"steps.digest-step.events","label":null,"fallback":null,"required":false,"aliasFor":null}},{"type":"text","text":" "}]},{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","text":"single variable: {{steps.digest-step.events[0].payload.foo.bar.first}}"}]}]}', subject: 'events', }; const previewResponse3 = await novuClient.workflows.steps.generatePreview({ generatePreviewRequestDto: { controlValues: controlValues3, previewPayload: {} }, stepId: emailStepDatabaseId, workflowId, }); // Check that the body contains the digest events array structure without asserting exact times expect(previewResponse3.result.result.preview.body).to.contain("'id':'example-id-1'"); expect(previewResponse3.result.result.preview.body).to.contain("'foo':{"); expect(previewResponse3.result.result.preview.body).to.contain("'time':"); // Count the number of events in the rendered output const eventMatches = previewResponse3.result.result.preview.body.match(/'id':'example-id-\d+'/g); expect(eventMatches).to.have.length(DEFAULT_ARRAY_ELEMENTS); expect(previewResponse3.result.result.preview.body).to.contain('single variable: example text'); validateDigestEvents(previewResponse3.result.previewPayloadExample.steps?.['digest-step'].events, { foo: { bar: { first: 'example text', baz: { second: 'example text', }, }, }, name: 'John Doe', items: [ { foo: 'example text', bar: 'example text' }, { foo: 'example text', bar: 'example text' }, { foo: 'example text', bar: 'example text' }, ], baz: 'example text', paragraph_link: 'https://example.com', heading_link: 'https://example.com', blockquote_link: 'https://example.com', bullet_link: 'https://example.com', button_link: 'https://example.com', image_variable: 'example text', image_link: 'https://example.com', inline_image_link: 'https://example.com', inline_image_url: 'https://example.com', numbered_link: 'https://example.com', third: 'example text', }); // testing the steps.digest-step.events[0].payload.foo variable const controlValues4 = { body: '{"type":"doc","content":[{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","text":"single variable: {{steps.digest-step.events[0].payload.foo}} "}]}]}', subject: 'events', }; const previewResponse4 = await novuClient.workflows.steps.generatePreview({ generatePreviewRequestDto: { controlValues: controlValues4, previewPayload: {} }, stepId: emailStepDatabaseId, workflowId, }); expect(previewResponse4.result.result.preview.body).to.contain( "single variable: {'bar':{'first':'example text','baz':{'second':'example text'}}}" ); validateDigestEvents(previewResponse4.result.previewPayloadExample.steps?.['digest-step'].events, { foo: { bar: { first: 'example text', baz: { second: 'example text', }, }, }, name: 'John Doe', items: [ { foo: 'example text', bar: 'example text' }, { foo: 'example text', bar: 'example text' }, { foo: 'example text', bar: 'example text' }, ], baz: 'example text', paragraph_link: 'https://example.com', heading_link: 'https://example.com', blockquote_link: 'https://example.com', bullet_link: 'https://example.com', button_link: 'https://example.com', image_variable: 'example text', image_link: 'https://example.com', inline_image_link: 'https://example.com', inline_image_url: 'https://example.com', numbered_link: 'https://example.com', third: 'example text', }); // testing the countSummary and sentenceSummary variables const controlValues5 = { body: `{"type":"doc","content":[{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"variable","attrs":{"id":"steps.digest-step.eventCount | pluralize: 'notification', 'notifications'","label":null,"fallback":null,"required":false,"aliasFor":null}},{"type":"text","text":" "}]},{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"variable","attrs":{"id":"steps.digest-step.events | toSentence: 'payload.name', 2, 'other'","label":null,"fallback":null,"required":false,"aliasFor":null}},{"type":"text","text":" "}]}]}`, subject: 'countSummary and sentenceSummary', }; const previewResponse5 = await novuClient.workflows.steps.generatePreview({ generatePreviewRequestDto: { controlValues: controlValues5, previewPayload: {} }, stepId: emailStepDatabaseId, workflowId, }); expect(previewResponse5.result.result.preview.body).to.contain(`${DEFAULT_ARRAY_ELEMENTS} notifications`); expect(previewResponse5.result.result.preview.body).to.contain( `John Doe, John Doe, and ${DEFAULT_ARRAY_ELEMENTS - 2} other` ); validateDigestEvents(previewResponse5.result.previewPayloadExample.steps?.['digest-step'].events, { foo: { bar: { first: 'example text', baz: { second: 'example text', }, }, }, name: 'John Doe', items: [ { foo: 'example text', bar: 'example text' }, { foo: 'example text', bar: 'example text' }, { foo: 'example text', bar: 'example text' }, ], baz: 'example text', paragraph_link: 'https://example.com', heading_link: 'https://example.com', blockquote_link: 'https://example.com', bullet_link: 'https://example.com', button_link: 'https://example.com', image_variable: 'example text', image_link: 'https://example.com', inline_image_link: 'https://example.com', inline_image_url: 'https://example.com', numbered_link: 'https://example.com', third: 'example text', }); // testing the digest block with 3 variables combining current and full variable const controlValues6 = { body: `{"type":"doc","content":[{"type":"section","attrs":{"borderRadius":0,"backgroundColor":"#FFFFFF","align":"left","borderWidth":0,"borderColor":"#e2e2e2","paddingTop":0,"paddingRight":0,"paddingBottom":0,"paddingLeft":0,"marginTop":0,"marginRight":0,"marginBottom":0,"marginLeft":0,"showIfKey":null},"content":[{"type":"repeat","attrs":{"each":"steps.digest-step.events","isUpdatingKey":false,"showIfKey":null,"iterations":5},"content":[{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"variable","attrs":{"id":"steps.digest-step.events.payload.foo.bar.first","label":null,"fallback":null,"required":false,"aliasFor":null}},{"type":"text","text":" "}]},{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"variable","attrs":{"id":"steps.digest-step.events.payload.foo.bar.baz.second","label":null,"fallback":null,"required":false,"aliasFor":null}},{"type":"text","text":" "}]},{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"variable","attrs":{"id":"current.payload.third","label":null,"fallback":null,"required":false,"aliasFor":"steps.digest-step.events.payload.third"}},{"type":"text","text":" "}]}]},{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"variable","attrs":{"id":"steps.digest-step.eventCount | minus: 5 | pluralize: 'more comment', ''","label":null,"fallback":null,"required":false,"aliasFor":null}}]}]}]}`, subject: 'digest block', }; const previewResponse6 = await novuClient.workflows.steps.generatePreview({ generatePreviewRequestDto: { controlValues: controlValues6, previewPayload: {} }, stepId: emailStepDatabaseId, workflowId, }); const countOccurrences = (str: string, searchStr: string) => (str.match(new RegExp(searchStr, 'g')) || []).length; expect(countOccurrences(previewResponse6.result.result.preview.body, 'first')).to.equal(DEFAULT_ARRAY_ELEMENTS); expect(countOccurrences(previewResponse6.result.result.preview.body, 'second')).to.equal(DEFAULT_ARRAY_ELEMENTS); expect(countOccurrences(previewResponse6.result.result.preview.body, 'third')).to.equal(DEFAULT_ARRAY_ELEMENTS); validateDigestEvents(previewResponse6.result.previewPayloadExample.steps?.['digest-step'].events, { foo: { bar: { first: 'example text', baz: { second: 'example text', }, }, }, name: 'John Doe', items: [ { foo: 'example text', bar: 'example text' }, { foo: 'example text', bar: 'example text' }, { foo: 'example text', bar: 'example text' }, ], baz: 'example text', paragraph_link: 'https://example.com', heading_link: 'https://example.com', blockquote_link: 'https://example.com', bullet_link: 'https://example.com', button_link: 'https://example.com', image_variable: 'example text', image_link: 'https://example.com', inline_image_link: 'https://example.com', inline_image_url: 'https://example.com', numbered_link: 'https://example.com', third: 'example text', }); }); it('should allow using the static text and variables as a link on the email editor components', async () => { const { workflowId, emailStepDatabaseId } = await createWorkflowWithEmailLookingAtDigestResult( linkPayloadSchemaWithExamples as any ); const controlValues = { body: '{"type":"doc","content":[{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","text":"Just the paragraph"}]},{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","marks":[{"type":"link","attrs":{"href":"payload.paragraph_link","target":"_blank","rel":"noopener noreferrer nofollow","class":null,"isUrlVariable":true,"aliasFor":null}},{"type":"underline"}],"text":"Paragraph variable link"}]},{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","marks":[{"type":"link","attrs":{"href":"https://paragraph.static.link","target":"_blank","rel":"noopener noreferrer nofollow","class":null,"isUrlVariable":false,"aliasFor":null}},{"type":"underline"}],"text":"Paragraph static link"}]},{"type":"heading","attrs":{"textAlign":null,"level":1,"showIfKey":null},"content":[{"type":"text","text":"Just the heading"}]},{"type":"heading","attrs":{"textAlign":null,"level":1,"showIfKey":null},"content":[{"type":"text","marks":[{"type":"link","attrs":{"href":"payload.heading_link","target":"_blank","rel":"noopener noreferrer nofollow","class":null,"isUrlVariable":true,"aliasFor":null}},{"type":"underline"}],"text":"Heading text link"}]},{"type":"heading","attrs":{"textAlign":null,"level":1,"showIfKey":null},"content":[{"type":"text","marks":[{"type":"link","attrs":{"href":"https://heading.static.link","target":"_blank","rel":"noopener noreferrer nofollow","class":null,"isUrlVariable":false,"aliasFor":null}},{"type":"underline"}],"text":"Heading static link"}]},{"type":"blockquote","content":[{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","text":"Just the blockquote"}]}]},{"type":"blockquote","content":[{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","marks":[{"type":"link","attrs":{"href":"payload.blockquote_link","target":"_blank","rel":"noopener noreferrer nofollow","class":null,"isUrlVariable":true,"aliasFor":null}},{"type":"underline"}],"text":"Blockquote text link"}]}]},{"type":"blockquote","content":[{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","marks":[{"type":"link","attrs":{"href":"https://blockquote.static.link","target":"_blank","rel":"noopener noreferrer nofollow","class":null,"isUrlVariable":false,"aliasFor":null}},{"type":"underline"}],"text":"Blockquote static link"}]}]},{"type":"bulletList","content":[{"type":"listItem","attrs":{"color":null},"content":[{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","text":"Just the bullet"}]}]},{"type":"listItem","attrs":{"color":null},"content":[{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","marks":[{"type":"link","attrs":{"href":"payload.bullet_link","target":"_blank","rel":"noopener noreferrer nofollow","class":null,"isUrlVariable":true,"aliasFor":null}},{"type":"underline"}],"text":"Bullet text link"}]}]},{"type":"listItem","attrs":{"color":null},"content":[{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","marks":[{"type":"link","attrs":{"href":"https://bullet.static.link","target":"_blank","rel":"noopener noreferrer nofollow","class":null,"isUrlVariable":false,"aliasFor":null}},{"type":"underline"}],"text":"Bullet static link"}]}]}]},{"type":"button","attrs":{"text":"Just the button","isTextVariable":false,"url":"","isUrlVariable":false,"alignment":"left","variant":"filled","borderRadius":"smooth","buttonColor":"#000000","textColor":"#ffffff","showIfKey":null,"paddingTop":10,"paddingRight":32,"paddingBottom":10,"paddingLeft":32,"width":"auto","aliasFor":null}},{"type":"button","attrs":{"text":"Button link","isTextVariable":false,"url":"payload.button_link","isUrlVariable":true,"alignment":"left","variant":"filled","borderRadius":"smooth","buttonColor":"#000000","textColor":"#ffffff","showIfKey":null,"paddingTop":10,"paddingRight":32,"paddingBottom":10,"paddingLeft":32,"width":"auto","aliasFor":null}},{"type":"button","attrs":{"text":"Button static link","isTextVariable":false,"url":"https://button.static.link","isUrlVariable":false,"alignment":"left","variant":"filled","borderRadius":"smooth","buttonColor":"#000000","textColor":"#ffffff","showIfKey":null,"paddingTop":10,"paddingRight":32,"paddingBottom":10,"paddingLeft":32,"width":"auto","aliasFor":null}},{"type":"image","attrs":{"src":"https://prod-novu-app-bucket.s3.us-east-1.amazonaws.com/assets/email-editor/header-hero-image.webp","alt":null,"title":null,"width":568,"height":153.79061371841155,"alignment":"center","externalLink":null,"isExternalLinkVariable":false,"borderRadius":0,"isSrcVariable":false,"aspectRatio":3.6933333333333334,"lockAspectRatio":true,"showIfKey":null,"aliasFor":null}},{"type":"image","attrs":{"src":"payload.image_variable","alt":null,"title":null,"width":"auto","height":"auto","alignment":"center","externalLink":null,"isExternalLinkVariable":false,"borderRadius":0,"isSrcVariable":true,"aspectRatio":null,"lockAspectRatio":true,"showIfKey":null,"aliasFor":null}},{"type":"image","attrs":{"src":"https://prod-novu-app-bucket.s3.us-east-1.amazonaws.com/assets/email-editor/header-hero-image.webp","alt":null,"title":null,"width":568,"height":153.79061371841155,"alignment":"center","externalLink":"payload.image_link","isExternalLinkVariable":true,"borderRadius":0,"isSrcVariable":false,"aspectRatio":3.6933333333333334,"lockAspectRatio":true,"showIfKey":null,"aliasFor":null}},{"type":"image","attrs":{"src":"https://prod-novu-app-bucket.s3.us-east-1.amazonaws.com/assets/email-editor/header-hero-image.webp","alt":null,"title":null,"width":568,"height":153.79061371841155,"alignment":"center","externalLink":"https://image.static.link","isExternalLinkVariable":false,"borderRadius":0,"isSrcVariable":false,"aspectRatio":3.6933333333333334,"lockAspectRatio":true,"showIfKey":null,"aliasFor":null}},{"type":"horizontalRule"},{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"inlineImage","attrs":{"height":20,"width":20,"src":"https://maily.to/brand/logo.png","isSrcVariable":false,"alt":null,"title":null,"externalLink":null,"isExternalLinkVariable":false,"aliasFor":null}}]},{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"inlineImage","attrs":{"height":20,"width":20,"src":"https://maily.to/brand/logo.png","isSrcVariable":false,"alt":null,"title":null,"externalLink":"payload.inline_image_link","isExternalLinkVariable":true,"aliasFor":null}}]},{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"inlineImage","attrs":{"height":20,"width":20,"src":"https://maily.to/brand/logo.png","isSrcVariable":false,"alt":null,"title":null,"externalLink":"https://inline_image.static.link","isExternalLinkVariable":false,"aliasFor":null}}]},{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"inlineImage","attrs":{"height":20,"width":20,"src":"payload.inline_image_url","isSrcVariable":true,"alt":null,"title":null,"externalLink":null,"isExternalLinkVariable":false,"aliasFor":null}}]},{"type":"orderedList","attrs":{"start":1},"content":[{"type":"listItem","attrs":{"color":null},"content":[{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","text":"Just the numbered list"}]}]},{"type":"listItem","attrs":{"color":null},"content":[{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","marks":[{"type":"link","attrs":{"href":"payload.numbered_link","target":"_blank","rel":"noopener noreferrer nofollow","class":null,"isUrlVariable":true,"aliasFor":null}},{"type":"underline"}],"text":"Numbered text link"}]}]},{"type":"listItem","attrs":{"color":null},"content":[{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","marks":[{"type":"link","attrs":{"href":"https://numbered.static.link","target":"_blank","rel":"noopener noreferrer nofollow","class":null,"isUrlVariable":false,"aliasFor":null}},{"type":"underline"}],"text":"Numbered static link"}]}]}]}]}', subject: 'all email editor components that support links', }; const previewResponse = await novuClient.workflows.steps.generatePreview({ generatePreviewRequestDto: { controlValues, previewPayload: {} }, stepId: emailStepDatabaseId, workflowId, }); // paragraph expect(previewResponse.result.result.preview.body).to.contain('Just the paragraph'); expect(previewResponse.result.result.preview.body).to.contain('Paragraph variable link'); expect(previewResponse.result.result.preview.body).to.contain('href="paragraph_link"'); expect(previewResponse.result.result.preview.body).to.contain('Paragraph static link'); expect(previewResponse.result.result.preview.body).to.contain('href="https://paragraph.static.link"'); // heading expect(previewResponse.result.result.preview.body).to.contain('Just the heading'); expect(previewResponse.result.result.preview.body).to.contain('Heading text link'); expect(previewResponse.result.result.preview.body).to.contain('href="heading_link"'); expect(previewResponse.result.result.preview.body).to.contain('Heading static link'); expect(previewResponse.result.result.preview.body).to.contain('href="https://heading.static.link"'); // blockquote expect(previewResponse.result.result.preview.body).to.contain('Just the blockquote'); expect(previewResponse.result.result.preview.body).to.contain('Blockquote text link'); expect(previewResponse.result.result.preview.body).to.contain('href="blockquote_link"'); expect(previewResponse.result.result.preview.body).to.contain('Blockquote static link'); expect(previewResponse.result.result.preview.body).to.contain('href="https://blockquote.static.link"'); // bullet expect(previewResponse.result.result.preview.body).to.contain('Just the bullet'); expect(previewResponse.result.result.preview.body).to.contain('Bullet text link'); expect(previewResponse.result.result.preview.body).to.contain('href="bullet_link"'); expect(previewResponse.result.result.preview.body).to.contain('Bullet static link'); expect(previewResponse.result.result.preview.body).to.contain('href="https://bullet.static.link"'); // button expect(previewResponse.result.result.preview.body).to.contain('Just the button'); expect(previewResponse.result.result.preview.body).to.contain('Button link'); expect(previewResponse.result.result.preview.body).to.contain('href="button_link"'); expect(previewResponse.result.result.preview.body).to.contain('Button static link'); expect(previewResponse.result.result.preview.body).to.contain('href="https://button.static.link"'); // image expect(previewResponse.result.result.preview.body).to.contain( 'ImageImageImage { expect(event).to.have.property('id', `example-id-${index + 1}`); expect(event).to.have.property('time').that.is.a('string'); expect(event) .to.have.property('payload') .that.deep.equals({ foo: 'example text', name: 'John Doe', items: [ { foo: 'example text', bar: 'example text', }, { foo: 'example text', bar: 'example text', }, { foo: 'example text', bar: 'example text', }, ], baz: 'example text', paragraph_link: 'paragraph_link', heading_link: 'heading_link', blockquote_link: 'blockquote_link', bullet_link: 'bullet_link', button_link: 'button_link', image_variable: 'image_variable', image_link: 'image_link', inline_image_link: 'inline_image_link', inline_image_url: 'inline_image_url', numbered_link: 'numbered_link', }); // Validate that time is a valid ISO string expect(new Date(event.time)).to.be.a('date'); }); const previewResponse2 = await novuClient.workflows.steps.generatePreview({ generatePreviewRequestDto: { controlValues, previewPayload: { payload: { paragraph_link: 'https://paragraph_link.com', heading_link: 'https://heading_link.com', blockquote_link: 'https://blockquote_link.com', bullet_link: 'https://bullet_link.com', button_link: 'https://button_link.com', image_variable: 'https://image_variable.com', image_link: 'https://image_link.com', inline_image_link: 'https://inline_image_link.com', inline_image_url: 'https://inline_image_url.com', numbered_link: 'https://numbered_link.com', }, }, }, stepId: emailStepDatabaseId, workflowId, }); expect(previewResponse2.result.result.preview.body).to.contain('href="https://paragraph_link.com"'); expect(previewResponse2.result.result.preview.body).to.contain('href="https://heading_link.com"'); expect(previewResponse2.result.result.preview.body).to.contain('href="https://blockquote_link.com"'); expect(previewResponse2.result.result.preview.body).to.contain('href="https://bullet_link.com"'); expect(previewResponse2.result.result.preview.body).to.contain('href="https://button_link.com"'); expect(previewResponse2.result.result.preview.body).to.contain('src="https://image_variable.com"'); expect(previewResponse2.result.result.preview.body).to.contain('href="https://image_link.com"'); expect(previewResponse2.result.result.preview.body).to.contain('href="https://inline_image_link.com"'); expect(previewResponse2.result.result.preview.body).to.contain('src="https://inline_image_url.com"'); expect(previewResponse2.result.result.preview.body).to.contain('href="https://numbered_link.com"'); }); it('should allow using the static text, variables, current alias, as a link on the email editor components inside the repeat block', async () => { const enhancedPayloadSchema = { type: 'object', properties: { foo: { type: 'string', }, name: { type: 'string', }, items: { type: 'array', items: { type: 'object', properties: { foo: { type: 'string', }, bar: { type: 'string', }, paragraph_link: { type: 'string', }, heading_link: { type: 'string', }, blockquote_link: { type: 'string', }, bullet_link: { type: 'string', }, button_link: { type: 'string', }, image: { type: 'string', }, image_link: { type: 'string', }, inline_image: { type: 'string', }, inline_image_link: { type: 'string', }, numbered_link: { type: 'string', }, }, }, }, baz: { type: 'string', }, paragraph_link: { type: 'string', }, heading_link: { type: 'string', }, blockquote_link: { type: 'string', }, bullet_link: { type: 'string', }, button_link: { type: 'string', }, image_variable: { type: 'string', }, image_link: { type: 'string', }, inline_image_link: { type: 'string', }, inline_image_url: { type: 'string', }, numbered_link: { type: 'string', }, }, }; const { workflowId, emailStepDatabaseId } = await createWorkflowWithEmailLookingAtDigestResult( enhancedPayloadSchema as any ); const controlValues = { body: '{"type":"doc","content":[{"type":"repeat","attrs":{"each":"payload.items","isUpdatingKey":false,"showIfKey":null,"iterations":0},"content":[{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","text":"Just the paragraph"}]},{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","marks":[{"type":"link","attrs":{"href":"payload.items.paragraph_link","target":"_blank","rel":"noopener noreferrer nofollow","class":null,"isUrlVariable":true,"aliasFor":null}},{"type":"underline"}],"text":"Paragraph variable link"}]},{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","marks":[{"type":"link","attrs":{"href":"current.paragraph_link","target":"_blank","rel":"noopener noreferrer nofollow","class":null,"isUrlVariable":true,"aliasFor":"payload.items.paragraph_link"}},{"type":"underline"}],"text":"Paragraph current variable link"}]},{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","marks":[{"type":"link","attrs":{"href":"https://paragraph.static.link","target":"_blank","rel":"noopener noreferrer nofollow","class":null,"isUrlVariable":false,"aliasFor":null}},{"type":"underline"}],"text":"Paragraph static link"}]},{"type":"heading","attrs":{"textAlign":null,"level":1,"showIfKey":null},"content":[{"type":"text","text":"Just the heading"}]},{"type":"heading","attrs":{"textAlign":null,"level":1,"showIfKey":null},"content":[{"type":"text","marks":[{"type":"link","attrs":{"href":"payload.items.heading_link","target":"_blank","rel":"noopener noreferrer nofollow","class":null,"isUrlVariable":true,"aliasFor":null}},{"type":"underline"}],"text":"Heading variable link"}]},{"type":"heading","attrs":{"textAlign":null,"level":1,"showIfKey":null},"content":[{"type":"text","marks":[{"type":"link","attrs":{"href":"current.heading_link","target":"_blank","rel":"noopener noreferrer nofollow","class":null,"isUrlVariable":true,"aliasFor":"payload.items.heading_link"}},{"type":"underline"}],"text":"Heading current variable link"}]},{"type":"heading","attrs":{"textAlign":null,"level":1,"showIfKey":null},"content":[{"type":"text","marks":[{"type":"link","attrs":{"href":"https://heading.static.link","target":"_blank","rel":"noopener noreferrer nofollow","class":null,"isUrlVariable":false,"aliasFor":null}},{"type":"underline"}],"text":"Heading static link"}]},{"type":"blockquote","content":[{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","text":"Just the blockquote"}]}]},{"type":"blockquote","content":[{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","marks":[{"type":"link","attrs":{"href":"payload.items.blockquote_link","target":"_blank","rel":"noopener noreferrer nofollow","class":null,"isUrlVariable":true,"aliasFor":null}},{"type":"underline"}],"text":"Blockquote variable link"}]}]},{"type":"blockquote","content":[{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","marks":[{"type":"link","attrs":{"href":"current.blockquote_link","target":"_blank","rel":"noopener noreferrer nofollow","class":null,"isUrlVariable":true,"aliasFor":"payload.items.blockquote_link"}},{"type":"underline"}],"text":"Blockquote current variable link"}]}]},{"type":"blockquote","content":[{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","marks":[{"type":"link","attrs":{"href":"https://blockquote.static.link","target":"_blank","rel":"noopener noreferrer nofollow","class":null,"isUrlVariable":false,"aliasFor":null}},{"type":"underline"}],"text":"Blockquote static link"}]}]},{"type":"bulletList","content":[{"type":"listItem","attrs":{"color":""},"content":[{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","text":"Just the bullet"}]}]},{"type":"listItem","attrs":{"color":""},"content":[{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","marks":[{"type":"link","attrs":{"href":"payload.items.bullet_link","target":"_blank","rel":"noopener noreferrer nofollow","class":null,"isUrlVariable":true,"aliasFor":null}},{"type":"underline"}],"text":"Bullet variable link"}]}]},{"type":"listItem","attrs":{"color":""},"content":[{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","marks":[{"type":"link","attrs":{"href":"current.bullet_link","target":"_blank","rel":"noopener noreferrer nofollow","class":null,"isUrlVariable":true,"aliasFor":"payload.items.bullet_link"}},{"type":"underline"}],"text":"Bullet current variable link"}]}]},{"type":"listItem","attrs":{"color":""},"content":[{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","marks":[{"type":"link","attrs":{"href":"https://bullet.static.link","target":"_blank","rel":"noopener noreferrer nofollow","class":null,"isUrlVariable":false,"aliasFor":null}},{"type":"underline"}],"text":"Bullet static link"}]}]}]},{"type":"button","attrs":{"text":"Just the button","isTextVariable":false,"url":"","isUrlVariable":false,"alignment":"left","variant":"filled","borderRadius":"smooth","buttonColor":"#000000","textColor":"#ffffff","showIfKey":null,"paddingTop":10,"paddingRight":32,"paddingBottom":10,"paddingLeft":32,"width":"auto","aliasFor":null}},{"type":"button","attrs":{"text":"Button variable link","isTextVariable":false,"url":"payload.items.button_link","isUrlVariable":true,"alignment":"left","variant":"filled","borderRadius":"smooth","buttonColor":"#000000","textColor":"#ffffff","showIfKey":null,"paddingTop":10,"paddingRight":32,"paddingBottom":10,"paddingLeft":32,"width":"auto","aliasFor":null}},{"type":"button","attrs":{"text":"Button current variable link","isTextVariable":false,"url":"current.button_link","isUrlVariable":true,"alignment":"left","variant":"filled","borderRadius":"smooth","buttonColor":"#000000","textColor":"#ffffff","showIfKey":null,"paddingTop":10,"paddingRight":32,"paddingBottom":10,"paddingLeft":32,"width":"auto","aliasFor":"payload.items.button_link"}},{"type":"button","attrs":{"text":"Button static link","isTextVariable":false,"url":"https://button.static.link","isUrlVariable":false,"alignment":"left","variant":"filled","borderRadius":"smooth","buttonColor":"#000000","textColor":"#ffffff","showIfKey":null,"paddingTop":10,"paddingRight":32,"paddingBottom":10,"paddingLeft":32,"width":"auto","aliasFor":null}},{"type":"horizontalRule"},{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","text":"Just the image"}]},{"type":"image","attrs":{"src":"https://prod-novu-app-bucket.s3.us-east-1.amazonaws.com/assets/email-editor/header-hero-image.webp","alt":null,"title":null,"width":566,"height":153.24909747292418,"alignment":"center","externalLink":null,"isExternalLinkVariable":false,"borderRadius":0,"isSrcVariable":false,"aspectRatio":3.6933333333333334,"lockAspectRatio":true,"showIfKey":null,"aliasFor":null}},{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","text":"Image variable"}]},{"type":"image","attrs":{"src":"payload.items.image","alt":null,"title":null,"width":"auto","height":"auto","alignment":"center","externalLink":null,"isExternalLinkVariable":false,"borderRadius":0,"isSrcVariable":true,"aspectRatio":null,"lockAspectRatio":true,"showIfKey":null,"aliasFor":null}},{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","text":"Image current variable"}]},{"type":"image","attrs":{"src":"current.image","alt":null,"title":null,"width":"auto","height":"auto","alignment":"center","externalLink":null,"isExternalLinkVariable":false,"borderRadius":0,"isSrcVariable":true,"aspectRatio":null,"lockAspectRatio":true,"showIfKey":null,"aliasFor":"payload.items.image"}},{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","text":"Image link variable"}]},{"type":"image","attrs":{"src":"https://prod-novu-app-bucket.s3.us-east-1.amazonaws.com/assets/email-editor/header-hero-image.webp","alt":null,"title":null,"width":566,"height":153.24909747292418,"alignment":"center","externalLink":"payload.items.image_link","isExternalLinkVariable":true,"borderRadius":0,"isSrcVariable":false,"aspectRatio":3.6933333333333334,"lockAspectRatio":true,"showIfKey":null,"aliasFor":null}},{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","text":"Image current link variable"}]},{"type":"image","attrs":{"src":"https://prod-novu-app-bucket.s3.us-east-1.amazonaws.com/assets/email-editor/header-hero-image.webp","alt":null,"title":null,"width":566,"height":153.24909747292418,"alignment":"center","externalLink":"current.image_link","isExternalLinkVariable":true,"borderRadius":0,"isSrcVariable":false,"aspectRatio":3.6933333333333334,"lockAspectRatio":true,"showIfKey":null,"aliasFor":"payload.items.image_link"}},{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","text":"Image static link"}]},{"type":"image","attrs":{"src":"https://prod-novu-app-bucket.s3.us-east-1.amazonaws.com/assets/email-editor/header-hero-image.webp","alt":null,"title":null,"width":566,"height":153.24909747292418,"alignment":"center","externalLink":"https://image.static.link","isExternalLinkVariable":false,"borderRadius":0,"isSrcVariable":false,"aspectRatio":3.6933333333333334,"lockAspectRatio":true,"showIfKey":null,"aliasFor":null}},{"type":"horizontalRule"},{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","text":"Inline image"}]},{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"inlineImage","attrs":{"height":20,"width":20,"src":"https://maily.to/brand/logo.png","isSrcVariable":false,"alt":null,"title":null,"externalLink":null,"isExternalLinkVariable":false,"aliasFor":null}}]},{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","text":"Inline image variable"}]},{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"inlineImage","attrs":{"height":20,"width":20,"src":"payload.items.inline_image","isSrcVariable":true,"alt":null,"title":null,"externalLink":null,"isExternalLinkVariable":false,"aliasFor":null}}]},{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","text":"Inline image current variable"}]},{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"inlineImage","attrs":{"height":20,"width":20,"src":"current.inline_image","isSrcVariable":true,"alt":null,"title":null,"externalLink":null,"isExternalLinkVariable":false,"aliasFor":"payload.items.inline_image"}}]},{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","text":"Inline image link variable"}]},{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"inlineImage","attrs":{"height":20,"width":20,"src":"https://maily.to/brand/logo.png","isSrcVariable":false,"alt":null,"title":null,"externalLink":"payload.items.inline_image_link","isExternalLinkVariable":true,"aliasFor":null}}]},{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","text":"Inline image current link variable"}]},{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"inlineImage","attrs":{"height":20,"width":20,"src":"https://maily.to/brand/logo.png","isSrcVariable":false,"alt":null,"title":null,"externalLink":"current.inline_image_link","isExternalLinkVariable":true,"aliasFor":"payload.items.inline_image_link"}}]},{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","text":"Inline image static link"}]},{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"inlineImage","attrs":{"height":20,"width":20,"src":"https://maily.to/brand/logo.png","isSrcVariable":false,"alt":null,"title":null,"externalLink":"https://inline_image.static.link","isExternalLinkVariable":false,"aliasFor":null}}]},{"type":"horizontalRule"},{"type":"orderedList","attrs":{"start":1},"content":[{"type":"listItem","attrs":{"color":null},"content":[{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","text":"Just the numbered list"}]}]},{"type":"listItem","attrs":{"color":null},"content":[{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","marks":[{"type":"link","attrs":{"href":"payload.items.numbered_link","target":"_blank","rel":"noopener noreferrer nofollow","class":null,"isUrlVariable":true,"aliasFor":null}},{"type":"underline"}],"text":"Numbered variable link"}]}]},{"type":"listItem","attrs":{"color":null},"content":[{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","marks":[{"type":"link","attrs":{"href":"current.numbered_link","target":"_blank","rel":"noopener noreferrer nofollow","class":null,"isUrlVariable":true,"aliasFor":"payload.items.numbered_link"}},{"type":"underline"}],"text":"Numbered current variable link"}]}]},{"type":"listItem","attrs":{"color":null},"content":[{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","marks":[{"type":"link","attrs":{"href":"https://numbered.static.link","target":"_blank","rel":"noopener noreferrer nofollow","class":null,"isUrlVariable":false,"aliasFor":null}},{"type":"underline"}],"text":"Numbered static link"}]}]}]},{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null}}]},{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null}}]}', subject: 'all email editor components that support links inside the repeat block', }; const previewResponse = await novuClient.workflows.steps.generatePreview({ generatePreviewRequestDto: { controlValues, previewPayload: { payload: { items: Array(6).fill({ paragraph_link: 'paragraph_link' }) } }, }, stepId: emailStepDatabaseId, workflowId, }); const countOccurrences = (str: string, searchStr: string) => (str.match(new RegExp(searchStr, 'g')) || []).length; expect(previewResponse.result.result.preview.body).to.contain('Paragraph variable link'); expect(previewResponse.result.result.preview.body).to.contain('Paragraph current variable link'); expect(countOccurrences(previewResponse.result.result.preview.body, 'href="paragraph_link"')).to.equal( DEFAULT_ARRAY_ELEMENTS * 4 ); expect(previewResponse.result.result.preview.body).to.contain('Paragraph static link'); expect(previewResponse.result.result.preview.body).to.contain('href="https://paragraph.static.link"'); // blockquote expect(previewResponse.result.result.preview.body).to.contain('Just the blockquote'); expect(previewResponse.result.result.preview.body).to.contain('Blockquote variable link'); expect(previewResponse.result.result.preview.body).to.contain('Blockquote current variable link'); expect(previewResponse.result.result.preview.body).to.contain('Blockquote static link'); expect(previewResponse.result.result.preview.body).to.contain('href="https://blockquote.static.link"'); // bullet expect(previewResponse.result.result.preview.body).to.contain('Just the bullet'); expect(previewResponse.result.result.preview.body).to.contain('Bullet variable link'); expect(previewResponse.result.result.preview.body).to.contain('Bullet current variable link'); expect(previewResponse.result.result.preview.body).to.contain('Bullet static link'); expect(previewResponse.result.result.preview.body).to.contain('href="https://bullet.static.link"'); // button expect(previewResponse.result.result.preview.body).to.contain('Just the button'); expect(previewResponse.result.result.preview.body).to.contain('Button variable link'); expect(previewResponse.result.result.preview.body).to.contain('Button current variable link'); expect(previewResponse.result.result.preview.body).to.contain('Button static link'); expect(previewResponse.result.result.preview.body).to.contain('href="https://button.static.link"'); // image expect(previewResponse.result.result.preview.body).to.contain( 'ImageImage { it.skip(` should hydrate previous step in iterator email --> digest`, async () => { const { workflowId, emailStepDatabaseId, digestStepId } = await createWorkflowWithEmailLookingAtDigestResult(); const requestDto = { controlValues: getTestControlValues(digestStepId)[StepTypeEnum.EMAIL], previewPayload: { payload: { subject: PLACEHOLDER_SUBJECT_INAPP_PAYLOAD_VALUE } }, }; const previewResponseDto = await generatePreview(novuClient, workflowId, emailStepDatabaseId, requestDto); expect(previewResponseDto.result!.preview).to.exist; expect(previewResponseDto.previewPayloadExample).to.exist; expect(previewResponseDto.previewPayloadExample?.steps?.[digestStepId]).to.be.ok; if (previewResponseDto.result!.type !== ChannelTypeEnum.Email) { throw new Error('Expected email'); } const preview = previewResponseDto.result!.preview.body; expect(previewResponseDto.result!.preview.body).to.contain('{{item.payload.country}}'); }); it(` should hydrate previous step in iterator sms looking at inApp`, async () => { const { workflowId, smsDatabaseStepId, inAppStepId } = await createWorkflowWithSmsLookingAtInAppResult(); const requestDto = buildDtoNoPayload(StepTypeEnum.SMS, inAppStepId); const previewResponseDto = await generatePreview(novuClient, workflowId, smsDatabaseStepId, requestDto); expect(previewResponseDto.result!.preview).to.exist; expect(previewResponseDto.previewPayloadExample).to.exist; expect(previewResponseDto.previewPayloadExample?.steps).to.be.ok; if (previewResponseDto.result?.type === 'sms' && previewResponseDto.result?.preview.body) { expect(previewResponseDto.result!.preview.body).to.contain(`[[true]]`); } }); }); it(`IN_APP :should match the body in the preview response`, async () => { const { stepDatabaseId, workflowId, stepId } = await createWorkflowAndReturnId(novuClient, StepTypeEnum.IN_APP); const controlValues = buildInAppControlValues(); const requestDto = { controlValues, previewPayload: { payload: { subject: PLACEHOLDER_SUBJECT_INAPP_PAYLOAD_VALUE } }, }; const previewResponseDto = await generatePreview(novuClient, workflowId, stepDatabaseId, requestDto); expect(previewResponseDto.result!.preview).to.exist; controlValues.subject = controlValues.subject!.replace( PLACEHOLDER_SUBJECT_INAPP, PLACEHOLDER_SUBJECT_INAPP_PAYLOAD_VALUE ); if (previewResponseDto.result?.type !== 'in_app') { throw new Error('should have a in-app preview '); } expect(previewResponseDto.result.preview.subject).to.deep.equal( 'John Hello, World! this is the replacement text for the placeholder' ); }); describe('Happy Path, no payload, expected same response as requested', () => { // TODO: this test is not working as expected it('in_app: should match the body in the preview response', async () => { const previewResponseDto = await createWorkflowAndPreview(StepTypeEnum.IN_APP, 'InApp'); expect(previewResponseDto.result).to.exist; if (!previewResponseDto.result) { throw new Error('missing preview'); } if (previewResponseDto.result!.type !== 'in_app') { throw new Error('should be in app preview type'); } const inApp = getTestControlValues().in_app; const previewRequestWithoutTheRedirect = { ...inApp, subject: "John Hello, World! {'test':{'payload':'example text'}}", body: 'Hello, World! This is an example message.', primaryAction: { label: 'https://example.com' }, }; expect(previewResponseDto.result!.preview).to.deep.equal(previewRequestWithoutTheRedirect); }); it('sms: should match the body in the preview response', async () => { const previewResponseDto = await createWorkflowAndPreview(StepTypeEnum.SMS, 'SMS'); expect(previewResponseDto.result!.preview).to.exist; expect(previewResponseDto.previewPayloadExample).to.exist; expect(previewResponseDto.previewPayloadExample.subscriber, 'Expecting to find subscriber in the payload').to .exist; expect(previewResponseDto.result!.preview).to.deep.equal({ body: ' Hello, World! John' }); }); it('push: should match the body in the preview response', async () => { const previewResponseDto = await createWorkflowAndPreview(StepTypeEnum.PUSH, 'Push'); expect(previewResponseDto.result!.preview).to.exist; expect(previewResponseDto.previewPayloadExample).to.exist; expect(previewResponseDto.previewPayloadExample.subscriber, 'Expecting to find subscriber in the payload').to .exist; expect(previewResponseDto.result!.preview).to.deep.equal({ subject: 'Hello, World!', body: 'Hello, World! John', }); }); it('chat: should match the body in the preview response', async () => { const previewResponseDto = await createWorkflowAndPreview(StepTypeEnum.CHAT, 'Chat'); expect(previewResponseDto.result!.preview).to.exist; expect(previewResponseDto.previewPayloadExample).to.exist; expect(previewResponseDto.previewPayloadExample.subscriber, 'Expecting to find subscriber in the payload').to .exist; expect(previewResponseDto.result!.preview).to.deep.equal({ body: 'Hello, World! John' }); }); it('email: should match the body in the preview response', async () => { const previewResponseDto = await createWorkflowAndPreview(StepTypeEnum.EMAIL, 'Email'); const preview = previewResponseDto.result.preview as EmailRenderOutput; expect(previewResponseDto.result.type).to.equal(StepTypeEnum.EMAIL); expect(preview).to.exist; expect(preview.body).to.exist; expect(preview.subject).to.exist; expect(preview.body).to.contain(previewPayloadExample().payload.body); expect(preview.subject).to.contain(`Hello, World! example text`); expect(previewResponseDto.previewPayloadExample).to.exist; expect(previewResponseDto.previewPayloadExample).to.have.property('payload'); expect(previewResponseDto.previewPayloadExample).to.have.property('subscriber'); expect(previewResponseDto.previewPayloadExample.payload).to.have.property('subject'); expect(previewResponseDto.previewPayloadExample.payload?.subject.test).to.have.property('payload'); }); it('email: should render HTML without escaping quotes in attributes', async () => { const { stepDatabaseId, workflowId } = await createWorkflowAndReturnId(novuClient, StepTypeEnum.EMAIL); const controlValues = { subject: 'Test HTML Rendering', body: JSON.stringify({ type: 'doc', content: [ { type: 'button', attrs: { text: 'Click Me', isTextVariable: false, url: 'https://example.com', isUrlVariable: false, alignment: 'center', variant: 'filled', borderRadius: 'smooth', buttonColor: '#FF5733', textColor: '#FFFFFF', showIfKey: null, paddingTop: 12, paddingRight: 24, paddingBottom: 12, paddingLeft: 24, width: 'auto', aliasFor: null, }, }, { type: 'paragraph', attrs: { textAlign: 'center', showIfKey: null }, content: [ { type: 'text', text: 'Test content with special characters: "quotes" & symbols', }, ], }, ], }), }; const previewResponseDto = await generatePreview(novuClient, workflowId, stepDatabaseId, { controlValues, }); expect(previewResponseDto.result).to.exist; if (!previewResponseDto.result || previewResponseDto.result.type !== 'email') { throw new Error('Expected email preview'); } const preview = previewResponseDto.result.preview as EmailRenderOutput; expect(preview.body).to.exist; expect(preview.body).to.not.contain('\\"'); expect(preview.body).to.not.contain('\\"'); expect(preview.body).to.not.contain('"center"'); expect(preview.body).to.not.contain('align=\\"center\\"'); expect(preview.body).to.contain('#FF5733'); expect(preview.body).to.contain('#FFFFFF'); expect(preview.body).to.contain('Click Me'); expect(preview.body).to.contain('Test content with special characters'); expect(preview.body).to.match(/style="[^"]*color[^"]*"/); expect(preview.body).to.match(/style="[^"]*background-color[^"]*"/); expect(preview.body).to.match(/align="center"/); }); async function createWorkflowAndPreview(type: StepTypeEnum, description: string) { const { stepDatabaseId, workflowId } = await createWorkflowAndReturnId(novuClient, type); const requestDto = buildDtoNoPayload(type); return await generatePreview(novuClient, workflowId, stepDatabaseId, requestDto); } }); describe('payload sanitation', () => { it('Should produce a correct payload when pipe is used etc {{payload.variable | upper}}', async () => { const { stepDatabaseId, workflowId } = await createWorkflowAndReturnId(novuClient, StepTypeEnum.SMS); const requestDto = { controlValues: { body: 'This is a legal placeholder with a pipe [{{payload.variableName | upcase}}the pipe should show in the preview]', }, }; const previewResponseDto = await generatePreview(novuClient, workflowId, stepDatabaseId, requestDto); expect(previewResponseDto.result!.preview).to.exist; if (previewResponseDto.result!.type !== 'sms') { throw new Error('Expected sms'); } expect(previewResponseDto.result!.preview.body).to.contain('JOHN DOE'); expect(previewResponseDto.previewPayloadExample).to.exist; }); it('Should not fail if inApp is providing partial URL in redirect', async () => { const steps = [{ name: 'IN_APP_STEP_SHOULD_NOT_FAIL', type: 'in_app' as const }]; const createDto = buildWorkflow({ steps, payloadSchema: { type: 'object', properties: { placeholder: { type: 'object', properties: { body: { type: 'string' }, }, }, secondaryUrl: { type: 'string' }, subject: { type: 'string' }, }, required: [], additionalProperties: false, }, }); const novuRestResult = await novuClient.workflows.create(createDto); const controlValues = { subject: `{{subscriber.firstName}} Hello, World! ${PLACEHOLDER_SUBJECT_INAPP}`, body: `Hello, World! {{payload.placeholder.body}}`, avatar: 'https://www.example.com/avatar.png', primaryAction: { label: '{{payload.secondaryUrl}}', redirect: { target: RedirectTargetEnum.BLANK, }, }, secondaryAction: null, redirect: { target: RedirectTargetEnum.BLANK, url: ' ', }, }; const workflowSlug = novuRestResult.result?.slug; const stepSlug = novuRestResult.result?.steps[0].slug; const stepDataDto = await updateWorkflow(workflowSlug, { ...mapResponseToUpdateDto(novuRestResult.result), steps: [ { type: novuRestResult.result.steps[0].type as any, name: novuRestResult.result.steps[0].name, id: novuRestResult.result.steps[0].id, ...buildInAppControlValueWithAPlaceholderInTheUrl(), }, ], }); const generatePreviewResponseDto = await generatePreview(novuClient, workflowSlug, stepSlug, { controlValues, }); if (generatePreviewResponseDto.result?.type === ChannelTypeEnum.InApp) { expect(generatePreviewResponseDto.result.preview.body).to.equal( { subject: `{{subscriber.firstName}} Hello, World! ${PLACEHOLDER_SUBJECT_INAPP}`, body: `Hello, World! This is an example message.`, avatar: 'https://www.example.com/avatar.png', primaryAction: { label: '{{payload.secondaryUrl}}', redirect: { target: RedirectTargetEnum.BLANK, }, }, secondaryAction: null, redirect: { target: RedirectTargetEnum.BLANK, url: ' ', }, }.body ); } }); it('should merge the user provided payload with the BE generated payload', async () => { const { workflowId, emailStepDatabaseId } = await createWorkflowWithEmailLookingAtDigestResult(); // Helper function to validate digest event structure (reused from above) const validateDigestEventsInMergeTest = (events: any[], expectedPayload: any) => { expect(events).to.have.length(DEFAULT_ARRAY_ELEMENTS); events.forEach((event, index) => { expect(event).to.have.property('id').that.is.a('string'); expect(event).to.have.property('time').that.is.a('string'); expect(event).to.have.property('payload').that.deep.equals(expectedPayload); // Validate that IDs are unique and follow the pattern expect(event.id).to.equal(`example-id-${index + 1}`); // Validate that times are ISO strings and incrementing expect(new Date(event.time)).to.be.a('date'); }); }; // testing the default preview payload is generated when no user payload is provided const controlValues1 = { body: '{"type":"doc","content":[{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","text":"events length "},{"type":"variable","attrs":{"id":"steps.digest-step.events.length","label":null,"fallback":null,"required":false,"aliasFor":null}},{"type":"text","text":" "}]},{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","text":" "}]}]}', subject: 'events length', }; const previewResponse1 = await novuClient.workflows.steps.generatePreview({ generatePreviewRequestDto: { controlValues: controlValues1, previewPayload: {} }, stepId: emailStepDatabaseId, workflowId, }); validateDigestEventsInMergeTest(previewResponse1.result.previewPayloadExample.steps?.['digest-step'].events, { third: 'example text', name: 'John Doe', items: [ { foo: 'example text', bar: 'example text' }, { foo: 'example text', bar: 'example text' }, { foo: 'example text', bar: 'example text' }, ], foo: { bar: { first: 'example text', baz: { second: 'example text', }, }, }, baz: 'example text', paragraph_link: 'https://example.com', heading_link: 'https://example.com', blockquote_link: 'https://example.com', bullet_link: 'https://example.com', button_link: 'https://example.com', image_variable: 'example text', image_link: 'https://example.com', inline_image_link: 'https://example.com', inline_image_url: 'https://example.com', numbered_link: 'https://example.com', }); }); }); describe('Missing Required ControlValues', () => { const channelTypes = [{ type: StepTypeEnum.IN_APP, description: 'InApp' }]; channelTypes.forEach(({ type }) => { // TODO: We need to get back to the drawing board on this one to make the preview action of the framework more forgiving it(`[${type}] will generate gracefully the preview if the control values are missing`, async () => { const { stepDatabaseId, workflowId, stepId } = await createWorkflowAndReturnId(novuClient, type); const requestDto = buildDtoWithMissingControlValues(type, stepId); const previewResponseDto = await generatePreview(novuClient, workflowId, stepDatabaseId, requestDto); expect(previewResponseDto.result).to.not.eql({ preview: {} }); }); }); }); async function updateWorkflow(id: string, workflow: UpdateWorkflowDto): Promise { const res = await novuClient.workflows.update(workflow, id); return res.result; } function mapResponseToUpdateDto(workflowResponse: WorkflowResponseDto): UpdateWorkflowDto { return { ...workflowResponse, steps: workflowResponse.steps.map( (step) => ({ id: step.id, type: step.type, name: step.name, controlValues: step.controls?.values || {}, }) as UpdateWorkflowDtoSteps ), }; } const defaultPayloadSchema = { type: 'object', properties: { foo: { type: 'object', properties: { bar: { type: 'object', properties: { first: { type: 'string', }, baz: { type: 'object', properties: { second: { type: 'string', }, }, }, }, }, }, }, name: { type: 'string', }, items: { type: 'array', items: { type: 'object', properties: { foo: { type: 'string', }, bar: { type: 'string', }, }, }, }, baz: { type: 'string', }, paragraph_link: { type: 'string', }, heading_link: { type: 'string', }, blockquote_link: { type: 'string', }, bullet_link: { type: 'string', }, button_link: { type: 'string', }, image_variable: { type: 'string', }, image_link: { type: 'string', }, inline_image_link: { type: 'string', }, inline_image_url: { type: 'string', }, numbered_link: { type: 'string', }, third: { type: 'string', }, }, }; const linkPayloadSchemaWithExamples = { type: 'object', properties: { foo: { type: 'string', }, name: { type: 'string', }, items: { type: 'array', items: { type: 'object', properties: { foo: { type: 'string', }, bar: { type: 'string', }, }, }, }, baz: { type: 'string', }, paragraph_link: { type: 'string', example: 'paragraph_link', }, heading_link: { type: 'string', example: 'heading_link', }, blockquote_link: { type: 'string', example: 'blockquote_link', }, bullet_link: { type: 'string', example: 'bullet_link', }, button_link: { type: 'string', example: 'button_link', }, image_variable: { type: 'string', example: 'image_variable', }, image_link: { type: 'string', example: 'image_link', }, inline_image_link: { type: 'string', example: 'inline_image_link', }, inline_image_url: { type: 'string', example: 'inline_image_url', }, numbered_link: { type: 'string', example: 'numbered_link', }, }, }; async function createWorkflowWithEmailLookingAtDigestResult(payloadSchema = defaultPayloadSchema) { const createWorkflowDto: CreateWorkflowDto = { tags: [], source: WorkflowCreationSourceEnum.Editor, name: 'John', workflowId: `john-${randomUUID()}`, description: 'This is a test workflow', active: true, payloadSchema, steps: [ { name: 'DigestStep', type: StepTypeEnum.DIGEST, controlValues: { amount: 1, unit: 'hours', }, }, { name: 'Email Test Step', type: StepTypeEnum.EMAIL, controlValues: { subject: 'Test Email Subject', body: 'Test Email Body', disableOutputSanitization: false, }, }, ], }; const workflowResult = await novuClient.workflows.create(createWorkflowDto); return { workflowId: workflowResult.result.id, emailStepDatabaseId: workflowResult.result.steps[1].id, digestStepId: workflowResult.result.steps[0].stepId, }; } async function createWorkflowWithSmsLookingAtInAppResult() { const createWorkflowDto: CreateWorkflowDto = { tags: [], source: WorkflowCreationSourceEnum.Editor, name: 'John', workflowId: `john-${randomUUID()}`, description: 'This is a test workflow', active: true, steps: [ { name: 'InAppStep', type: StepTypeEnum.IN_APP, controlValues: { subject: 'Test Subject', body: 'Test Body', }, }, { name: 'SmsStep', type: StepTypeEnum.SMS, controlValues: { body: 'Test SMS Body', }, }, ], }; const workflowResult = await novuClient.workflows.create(createWorkflowDto); return { workflowId: workflowResult.result.id, smsDatabaseStepId: workflowResult.result.steps[1].id, inAppStepId: workflowResult.result.steps[0].stepId, }; } async function createWorkflow( overrides: Partial = {}, payloadSchema?: any ): Promise { const createWorkflowDto: CreateWorkflowDto = { source: WorkflowCreationSourceEnum.Editor, name: TEST_WORKFLOW_NAME, workflowId: `${slugify(TEST_WORKFLOW_NAME)}`, description: 'This is a test workflow', active: true, payloadSchema, steps: [ { name: 'In-App Test Step', type: StepTypeEnum.IN_APP, controlValues: { subject: 'Test Subject', body: 'Test Body', }, }, { name: 'Email Test Step', type: StepTypeEnum.EMAIL, controlValues: { subject: 'Test Email Subject', body: 'Test Email Body', }, }, ], }; const res = await novuClient.workflows.create(createWorkflowDto); await notificationTemplateRepository.updateOne( { _organizationId: session.organization._id, _environmentId: session.environment._id, _id: res.result.id, }, { ...overrides, } ); return res.result; } /** * Emulate external origin bridge with the local bridge */ async function emulateExternalOrigin(_workflowId: string) { await notificationTemplateRepository.updateOne( { _organizationId: session.organization._id, _environmentId: session.environment._id, _id: _workflowId, }, { origin: ResourceOriginEnum.External, } ); await environmentRepository.updateOne( { _id: session.environment._id, }, { bridge: { url: `http://localhost:${process.env.PORT}/v1/environments/${session.environment._id}/bridge` }, } ); } }); function buildDtoNoPayload(stepTypeEnum: StepTypeEnum, stepId?: string): GeneratePreviewRequestDto { return { controlValues: getTestControlValues(stepId)[stepTypeEnum], }; } function buildEmailControlValuesPayload(): EmailControlType { return { subject: `Hello, World! ${SUBJECT_TEST_PAYLOAD}`, body: JSON.stringify(fullCodeSnippet()), disableOutputSanitization: false, }; } function buildInAppControlValues() { return { subject: `{{subscriber.firstName}} Hello, World! ${PLACEHOLDER_SUBJECT_INAPP}`, body: `Hello, World! {{payload.placeholder.body}}`, avatar: 'https://www.example.com/avatar.png', primaryAction: { label: '{{payload.primaryUrlLabel}}', redirect: { target: RedirectTargetEnum.BLANK, }, }, secondaryAction: { label: 'Secondary Action', redirect: { target: RedirectTargetEnum.BLANK, url: '/home/secondary-action', }, }, data: { key: 'value', }, redirect: { target: RedirectTargetEnum.BLANK, url: 'https://www.example.com/redirect', }, }; } function buildInAppControlValueWithAPlaceholderInTheUrl() { return { subject: `{{subscriber.firstName}} Hello, World! ${PLACEHOLDER_SUBJECT_INAPP}`, body: `Hello, World! {{payload.placeholder.body}}`, avatar: 'https://www.example.com/avatar.png', primaryAction: { label: '{{payload.secondaryUrlLabel}}', redirect: { url: '{{payload.secondaryUrl}}', target: RedirectTargetEnum.BLANK, }, }, secondaryAction: { label: 'Secondary Action', redirect: { target: RedirectTargetEnum.BLANK, url: '', }, }, redirect: { target: RedirectTargetEnum.BLANK, url: ' ', }, }; } function buildSmsControlValuesPayload(stepId: string | undefined) { return { body: `${stepId ? ` [[{{steps.${stepId}.seen}}]]` : ''} Hello, World! {{subscriber.firstName}}`, }; } function buildPushControlValuesPayload() { return { subject: 'Hello, World!', body: 'Hello, World! {{subscriber.firstName}}', }; } function buildChatControlValuesPayload() { return { body: 'Hello, World! {{subscriber.firstName}}', }; } function buildDigestControlValuesPayload() { return { cron: CronExpressionEnum.EVERY_DAY_AT_8AM, }; } export const getTestControlValues = (stepId?: string) => ({ [StepTypeEnum.SMS]: buildSmsControlValuesPayload(stepId), [StepTypeEnum.EMAIL]: buildEmailControlValuesPayload(), [StepTypeEnum.PUSH]: buildPushControlValuesPayload(), [StepTypeEnum.CHAT]: buildChatControlValuesPayload(), [StepTypeEnum.IN_APP]: buildInAppControlValues(), [StepTypeEnum.DIGEST]: buildDigestControlValuesPayload(), }); export async function createWorkflowAndReturnId(workflowsClient: Novu, type: StepTypeEnum) { const createWorkflowDto = buildWorkflow({ payloadSchema: { type: 'object', properties: { variableName: { type: 'string' }, placeholder: { type: 'object', properties: { body: { type: 'string' }, random: { type: 'string' }, }, }, primaryUrlLabel: { type: 'string' }, secondaryUrl: { type: 'string' }, organizationName: { type: 'string' }, firstName: { type: 'string' }, lastName: { type: 'string' }, orderId: { type: 'string' }, subject: { type: 'object', properties: { test: { type: 'object', properties: { payload: { type: 'string' }, }, }, }, }, params: { type: 'object', properties: { isPayedUser: { type: 'boolean' }, }, }, hidden: { type: 'object', properties: { section: { type: 'string' }, }, }, body: { type: 'string' }, food: { type: 'object', properties: { items: { type: 'array', items: { type: 'object', properties: { name: { type: 'string' }, }, }, }, }, }, origins: { type: 'array', items: { type: 'object', properties: { country: { type: 'string' }, id: { type: 'string' }, time: { type: 'string' }, }, }, }, students: { type: 'array', items: { type: 'object', properties: { id: { type: 'string' }, name: { type: 'string' }, }, }, }, }, required: [], additionalProperties: false, }, }); createWorkflowDto.steps[0].type = type as any; const workflowResult = await workflowsClient.workflows.create(createWorkflowDto); return { workflowId: workflowResult.result.id, stepDatabaseId: workflowResult.result.steps[0].id, stepId: workflowResult.result.steps[0].stepId, }; } export async function generatePreview( workflowsClient: Novu, workflowId: string, stepDatabaseId: string, dto: GeneratePreviewRequestDto ): Promise { return ( await workflowsClient.workflows.steps.generatePreview({ workflowId, stepId: stepDatabaseId, generatePreviewRequestDto: dto, }) ).result; } function buildDtoWithMissingControlValues(stepTypeEnum: StepTypeEnum, stepId: string): GeneratePreviewRequestDto { const stepTypeToElement = getTestControlValues(stepId)[stepTypeEnum]; if (stepTypeEnum === StepTypeEnum.EMAIL) { delete stepTypeToElement.subject; } else { delete stepTypeToElement.body; } return { controlValues: stepTypeToElement, previewPayload: { payload: { subject: PLACEHOLDER_SUBJECT_INAPP_PAYLOAD_VALUE } }, }; } ================================================ FILE: apps/api/src/app/workflows-v2/e2e/list-workflows.e2e.ts ================================================ import { Novu } from '@novu/api'; import { CreateWorkflowDto, DirectionEnum, WorkflowCreationSourceEnum, WorkflowResponseDto, WorkflowResponseDtoSortField, WorkflowStatusEnum, } from '@novu/api/models/components'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { initNovuClassSdkInternalAuth } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; describe('List Workflows - /workflows (GET) #novu-v2', () => { let session: UserSession; let novuClient: Novu; beforeEach(async () => { session = new UserSession(); await session.initialize(); novuClient = initNovuClassSdkInternalAuth(session); }); describe('Pagination and Search', () => { it('should correctly paginate workflows', async () => { const workflowIds: string[] = []; for (let i = 0; i < 15; i += 1) { const workflow = await createWorkflow(`Test Workflow ${i}`); workflowIds.push(workflow.id); } const { result: firstPage } = await novuClient.workflows.list({ limit: 10, offset: 0 }); expect(firstPage.workflows).to.have.length(10); expect(firstPage.totalCount).to.equal(15); const { result: secondPage } = await novuClient.workflows.list({ limit: 10, offset: 10 }); expect(secondPage.workflows).to.have.length(5); expect(secondPage.totalCount).to.equal(15); const firstPageIds = firstPage.workflows.map((workflow) => workflow.id); const secondPageIds = secondPage.workflows.map((workflow) => workflow.id); const uniqueIds = new Set([...firstPageIds, ...secondPageIds]); expect(uniqueIds.size).to.equal(15); }); it('should correctly search workflows by name', async () => { const searchTerm = 'SEARCHABLE-WORKFLOW'; // Create workflows with different names await createWorkflow(`${searchTerm}-1`); await createWorkflow(`${searchTerm}-2`); await createWorkflow('Different Workflow'); const { result } = await novuClient.workflows.list({ query: searchTerm }); expect(result.workflows).to.have.length(2); expect(result.workflows[0].name).to.include(searchTerm); expect(result.workflows[1].name).to.include(searchTerm); }); }); describe('Sorting', () => { it('should sort workflows by creation date in descending order by default', async () => { await createWorkflow('First Workflow'); await delay(100); // Ensure different creation times await createWorkflow('Second Workflow'); const { result } = await novuClient.workflows.list({}); expect(result.workflows[0].name).to.equal('Second Workflow'); expect(result.workflows[1].name).to.equal('First Workflow'); }); it('should sort workflows by creation date in ascending order when specified', async () => { await createWorkflow('First Workflow'); await delay(100); // Ensure different creation times await createWorkflow('Second Workflow'); const { result } = await novuClient.workflows.list({ orderDirection: DirectionEnum.Asc, orderBy: WorkflowResponseDtoSortField.Name, }); expect(result.workflows[0].name).to.equal('First Workflow'); expect(result.workflows[1].name).to.equal('Second Workflow'); }); }); describe('Response Structure', () => { it('should return correct workflow fields in response', async () => { const workflowName = 'Test Workflow Structure'; const createdWorkflow = await createWorkflow(workflowName); const { result } = await novuClient.workflows.list({}); const returnedWorkflow = result.workflows[0]; expect(returnedWorkflow).to.include({ id: createdWorkflow.id, name: workflowName, workflowId: createdWorkflow.workflowId, status: WorkflowStatusEnum.Active, }); expect(returnedWorkflow.createdAt).to.be.a('string'); expect(returnedWorkflow.updatedAt).to.be.a('string'); }); }); async function createWorkflow(name: string): Promise { const createWorkflowDto: CreateWorkflowDto = { name, workflowId: name.toLowerCase().replace(/\s+/g, '-'), source: WorkflowCreationSourceEnum.Editor, active: true, steps: [], }; const { result } = await novuClient.workflows.create(createWorkflowDto); return result; } function delay(ms: number): Promise { return new Promise((resolve) => { setTimeout(resolve, ms); }); } }); ================================================ FILE: apps/api/src/app/workflows-v2/e2e/upsert-workflow.e2e.ts ================================================ import { Novu } from '@novu/api'; import { CreateLayoutDto, CreateWorkflowDto, EmailStepResponseDto, InAppControlDto, LayoutCreationSourceEnum, LayoutResponseDto, UpdateWorkflowDto, WorkflowCreationSourceEnum, WorkflowResponseDto, } from '@novu/api/models/components'; import { StepTypeEnum } from '@novu/shared'; import { UserSession } from '@novu/testing'; import { expect } from 'chai'; import { JSONSchemaDto } from '../../shared/dtos/json-schema.dto'; import { initNovuClassSdkInternalAuth } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper'; interface ITestStepConfig { type: StepTypeEnum; controlValues: Record; } describe('Upsert Workflow #novu-v2', () => { let session: UserSession; let novuClient: Novu; beforeEach(async () => { session = new UserSession(); await session.initialize(); novuClient = initNovuClassSdkInternalAuth(session); }); describe('POST /v2/workflows/:workflowId', () => { it('should throw error when workflowId is not a valid slug', async () => { try { await createWorkflow({ name: 'Test Workflow', workflowId: '_test-workflow-123_', steps: [], }); // Should not reach this point expect.fail('Expected BadRequestException to be thrown'); } catch (error) { expect(error.statusCode).to.equal(422); expect(error.message).to.contain('Validation Error'); expect(error.errors).to.exist; expect(error.errors.general.messages[0]).to.contain( 'must be a valid slug format (letters, numbers, hyphens, dot and underscores only)' ); } }); it('should create a workflow with a preserved workflowId', async () => { const workflow = await createWorkflow({ name: 'Test Workflow', workflowId: 'test-workflow-123', steps: [], }); expect(workflow.name).to.equal('Test Workflow'); expect(workflow.workflowId).to.equal('test-workflow-123'); }); it('should create a workflow and preserve stepId', async () => { const workflow = await createWorkflow({ name: 'Test Workflow', workflowId: 'test-workflow-123', steps: [ { name: 'Test Step', stepId: 'test-step-123', type: StepTypeEnum.IN_APP, controlValues: { body: 'Test Body', }, }, ], }); expect(workflow.name).to.equal('Test Workflow'); expect(workflow.workflowId).to.equal('test-workflow-123'); expect(workflow.steps.length).to.equal(1); expect(workflow.steps[0].id).to.exist; expect(workflow.steps[0].type).to.equal(StepTypeEnum.IN_APP); expect(workflow.steps[0].stepId).to.equal('test-step-123'); expect(workflow.steps[0].controls).to.exist; expect(workflow.steps[0].controls.values).to.exist; expect((workflow.steps[0].controls.values as InAppControlDto).body).to.equal('Test Body'); }); }); describe('PUT /v2/workflows/:workflowId', () => { describe('single step workflows', () => { it('when step is deleted it should not remove variable if it is used in another step', async () => { const workflow = await createWorkflow({ name: 'Test Workflow', workflowId: `test-workflow-${Date.now()}`, source: WorkflowCreationSourceEnum.Editor, active: true, payloadSchema: { type: 'object', properties: { first_variable: { type: 'string' }, second_variable: { type: 'string' }, }, required: [], additionalProperties: false, }, steps: [ { name: `IN_APP 1`, type: StepTypeEnum.IN_APP, controlValues: { body: '{{payload.first_variable}}', }, }, { name: `IN_APP 2`, type: StepTypeEnum.IN_APP, controlValues: { body: '{{payload.second_variable}}', }, }, { name: `CHAT 1`, type: StepTypeEnum.CHAT, controlValues: { body: '{{payload.first_variable}}', }, }, ], }); const chatStep = workflow.steps[2]; const chatPayloadVariables = chatStep.variables.properties?.payload; expect(chatPayloadVariables).to.exist; expect((chatPayloadVariables as JSONSchemaDto)?.properties).to.have.property('first_variable'); expect((chatPayloadVariables as JSONSchemaDto)?.properties).to.have.property('second_variable'); // delete the first step const updatedWorkflow = await updateWorkflow(workflow.slug, { ...mapResponseToUpdateDto(workflow), steps: mapResponseToUpdateDto(workflow).steps.slice(1), }); const updatedChatStep = updatedWorkflow.steps[0]; const updatedChatPayloadVariables = updatedChatStep.variables.properties?.payload; expect(updatedChatPayloadVariables).to.exist; expect((updatedChatPayloadVariables as JSONSchemaDto)?.properties).to.have.property('first_variable'); expect((updatedChatPayloadVariables as JSONSchemaDto)?.properties).to.have.property('second_variable'); }); }); describe('email step layoutId functionality', () => { it('should skip layout rendering when converting Maily JSON to HTML with assigned layoutId', async () => { // First create a layout with distinctive HTML content const layout = await createLayout({ name: 'Test Layout for skipLayoutRendering', layoutId: 'test-layout-skip-rendering', source: LayoutCreationSourceEnum.Dashboard, }); const mailyJsonContent = JSON.stringify({ type: 'doc', content: [ { type: 'paragraph', content: [ { type: 'text', text: 'This is email content that should not include layout HTML.', }, ], }, ], }); // Create workflow with email step that has layoutId assigned const workflow = await createWorkflow({ name: 'Test Workflow with Layout', workflowId: `test-workflow-layout-${Date.now()}`, source: WorkflowCreationSourceEnum.Editor, active: true, steps: [ { name: `Email Step with Layout`, type: StepTypeEnum.EMAIL, controlValues: { subject: 'Test Email with Layout', body: mailyJsonContent, editorType: 'block', layoutId: layout.layoutId, }, }, ], }); // Switch to HTML editor - this should trigger skipLayoutRendering const updatedWorkflow = await updateWorkflow(workflow.slug, { ...workflow, steps: [ { ...workflow.steps[0], controlValues: { ...workflow.steps[0].controls.values, editorType: 'html', }, }, ], } as UpdateWorkflowDto); const updatedEmailStep = updatedWorkflow.steps[0] as EmailStepResponseDto; expect(updatedEmailStep.controls.values.editorType).to.equal('html'); expect(updatedEmailStep.controls.values.layoutId).to.equal(layout.layoutId); // The body should contain the converted HTML from Maily JSON expect(updatedEmailStep.controls.values.body).to.not.contain(' { await createLayout({ name: 'Test Layout', layoutId: 'test-layout', source: LayoutCreationSourceEnum.Dashboard, }); const workflow = await createWorkflow({ name: 'Test Email Workflow', workflowId: `test-email-workflow-${Date.now()}`, source: WorkflowCreationSourceEnum.Editor, active: true, steps: [ { name: `Email Step`, type: StepTypeEnum.EMAIL, controlValues: { subject: 'Test Subject', body: 'Test Body', layoutId: null, }, }, ], }); const emailStep = workflow.steps[0] as EmailStepResponseDto; expect(emailStep.type).to.equal(StepTypeEnum.EMAIL); expect(emailStep.controls.values.layoutId).to.equal(null); }); it('should keep layoutId as undefined when not specified and there is no default layout', async () => { const workflow = await createWorkflow({ name: 'Test Email Workflow', workflowId: `test-email-workflow-${Date.now()}`, source: WorkflowCreationSourceEnum.Editor, active: true, steps: [ { name: `Email Step`, type: StepTypeEnum.EMAIL, controlValues: { subject: 'Test Subject', body: 'Test Body', }, }, ], }); const emailStep = workflow.steps[0] as EmailStepResponseDto; expect(emailStep.type).to.equal(StepTypeEnum.EMAIL); expect(emailStep.controls.values.layoutId).to.be.undefined; }); it('should keep layoutId as undefined when not specified and there is a default layout', async () => { await createLayout({ name: 'Test Layout', layoutId: 'test-layout-id', source: LayoutCreationSourceEnum.Dashboard, }); const workflow = await createWorkflow({ name: 'Test Email Workflow', workflowId: `test-email-workflow-${Date.now()}`, source: WorkflowCreationSourceEnum.Editor, active: true, steps: [ { name: `Email Step`, type: StepTypeEnum.EMAIL, controlValues: { subject: 'Test Subject', body: 'Test Body', }, }, ], }); const emailStep = workflow.steps[0] as EmailStepResponseDto; expect(emailStep.type).to.equal(StepTypeEnum.EMAIL); expect(emailStep.controls.values.layoutId).to.be.undefined; }); it('should throw error when creating email step with invalid layoutId', async () => { try { await createWorkflow({ name: 'Test Email Workflow Invalid', workflowId: `test-email-workflow-invalid-${Date.now()}`, source: WorkflowCreationSourceEnum.Editor, active: true, steps: [ { name: `Email Step`, type: StepTypeEnum.EMAIL, controlValues: { subject: 'Test Subject', body: 'Test Body', layoutId: 'non-existent-layout-id-12345', }, }, ], }); // Should not reach this point expect.fail('Expected BadRequestException to be thrown'); } catch (error) { expect(error.message).to.contain('Layout not found'); } }); it('should throw error when updating email step with invalid layoutId', async () => { try { const workflow = await createWorkflow({ name: 'Test Email Workflow Update Invalid', workflowId: `test-email-workflow-update-invalid-${Date.now()}`, source: WorkflowCreationSourceEnum.Editor, active: true, steps: [ { name: `Email Step`, type: StepTypeEnum.EMAIL, controlValues: { subject: 'Test Subject', body: 'Test Body', }, }, ], }); await updateWorkflow(workflow.slug, { ...mapResponseToUpdateDto(workflow), steps: [ { ...mapResponseToUpdateDto(workflow).steps[0], type: StepTypeEnum.EMAIL, controlValues: { subject: 'Test Subject', body: 'Test Body', layoutId: 'invalid-layout-id-67890', }, }, ], }); // Should not reach this point expect.fail('Expected BadRequestException to be thrown'); } catch (error) { expect(error.message).to.contain('Layout not found for id'); } }); it('should allow updating layoutId to specific value', async () => { const layout = await createLayout({ name: 'Custom Layout', layoutId: 'custom-layout', source: LayoutCreationSourceEnum.Dashboard, }); const workflow = await createWorkflow({ name: 'Test Email Workflow', workflowId: `test-email-workflow-${Date.now()}`, source: WorkflowCreationSourceEnum.Editor, active: true, steps: [ { name: `Email Step`, type: StepTypeEnum.EMAIL, controlValues: { subject: 'Test Subject', body: 'Test Body', }, }, ], }); // Update the workflow with a specific layoutId const updatedWorkflow = await updateWorkflow(workflow.slug, { ...mapResponseToUpdateDto(workflow), steps: [ { ...mapResponseToUpdateDto(workflow).steps[0], type: StepTypeEnum.EMAIL, controlValues: { subject: 'Test Subject', body: 'Test Body', layoutId: layout.layoutId, }, }, ], }); const emailStep = updatedWorkflow.steps[0] as EmailStepResponseDto; expect(emailStep.type).to.equal(StepTypeEnum.EMAIL); expect(emailStep.controls.values.layoutId).to.equal(layout.layoutId); }); it('should allow updating layoutId to undefined to remove layout', async () => { const layout = await createLayout({ name: 'Custom Layout', layoutId: 'custom-layout', source: LayoutCreationSourceEnum.Dashboard, }); const workflow = await createWorkflow({ name: 'Test Email Workflow', workflowId: `test-email-workflow-${Date.now()}`, source: WorkflowCreationSourceEnum.Editor, active: true, steps: [ { name: `Email Step`, type: StepTypeEnum.EMAIL, controlValues: { subject: 'Test Subject', body: 'Test Body', layoutId: layout.layoutId, }, }, ], }); // Update the workflow to remove layout const updatedWorkflow = await updateWorkflow(workflow.slug, { ...mapResponseToUpdateDto(workflow), steps: [ { ...mapResponseToUpdateDto(workflow).steps[0], type: StepTypeEnum.EMAIL, controlValues: { subject: 'Test Subject', body: 'Test Body', layoutId: undefined, }, }, ], }); const emailStep = updatedWorkflow.steps[0] as EmailStepResponseDto; expect(emailStep.type).to.equal(StepTypeEnum.EMAIL); expect(emailStep.controls.values.layoutId).to.be.undefined; }); }); it('when switching the editor type it should convert the body value', async () => { const workflow = await createWorkflow({ name: 'Test Workflow', workflowId: `test-workflow-${Date.now()}`, source: WorkflowCreationSourceEnum.Editor, active: true, steps: [ { name: `Email`, type: StepTypeEnum.EMAIL, controlValues: { disableOutputSanitization: false, editorType: 'block', body: '{"type":"doc","content":[{"type":"paragraph","attrs":{"textAlign":null,"showIfKey":null},"content":[{"type":"text","text":"test"}]}]}', subject: 'subject', }, }, ], }); const updatedWorkflow = await updateWorkflow(workflow.slug, { ...workflow, steps: [ { ...workflow.steps[0], controlValues: { ...workflow.steps[0].controls.values, editorType: 'html', }, }, ], } as UpdateWorkflowDto); const updatedEmailStep = updatedWorkflow.steps[0] as EmailStepResponseDto; expect(updatedEmailStep.controls.values.editorType).to.equal('html'); expect(updatedEmailStep.controls.values.body).to.contain(' test

`); expect(updatedEmailStep.controls.values.body).to.contain(''); expect(updatedEmailStep.controls.values.body).to.contain(''); const updatedWorkflow2 = await updateWorkflow(workflow.slug, { ...workflow, steps: [ { ...workflow.steps[0], controlValues: { ...updatedEmailStep.controls.values, editorType: 'block', }, }, ], } as UpdateWorkflowDto); const updatedEmailStep2 = updatedWorkflow2.steps[0] as EmailStepResponseDto; expect(updatedEmailStep2.controls.values.editorType).to.equal('block'); expect(updatedEmailStep2.controls.values.body).to.equal(''); }); }); async function createLayout(layout: CreateLayoutDto): Promise { const { result: createLayoutBody } = await novuClient.layouts.create(layout); return createLayoutBody; } async function createWorkflow(workflow: CreateWorkflowDto): Promise { const { result: createWorkflowBody } = await novuClient.workflows.create(workflow); return createWorkflowBody; } async function updateWorkflow(workflowSlug: string, workflow: UpdateWorkflowDto): Promise { const { result: updateWorkflowBody } = await novuClient.workflows.update(workflow, workflowSlug); return updateWorkflowBody; } function mapResponseToUpdateDto(workflowResponse: WorkflowResponseDto): UpdateWorkflowDto { return { ...workflowResponse, steps: workflowResponse.steps.map((step) => ({ id: step.id, type: step.type, name: step.name, controlValues: step.controls?.values || {}, })), } as UpdateWorkflowDto; } }); ================================================ FILE: apps/api/src/app/workflows-v2/exceptions/workflow-not-duplicable-exception.ts ================================================ import { BadRequestException } from '@nestjs/common'; import { WorkflowResponseDto } from '@novu/shared'; import { DUPLICABLE_WORKFLOW_ORIGINS } from '../usecases'; export class WorkflowNotDuplicableException extends BadRequestException { constructor(workflow: Pick) { const reason = `origin '${workflow.origin}' is not allowed (must be one of: ${DUPLICABLE_WORKFLOW_ORIGINS.join(', ')})`; super({ message: `Cannot duplicate workflow: ${reason}`, workflowId: workflow.workflowId, origin: workflow.origin, allowedOrigins: DUPLICABLE_WORKFLOW_ORIGINS, }); } } ================================================ FILE: apps/api/src/app/workflows-v2/exceptions/workflow-not-syncable-exception.ts ================================================ import { BadRequestException } from '@nestjs/common'; import { WorkflowResponseDto } from '@novu/shared'; import { SYNCABLE_WORKFLOW_ORIGINS } from '../usecases/sync-to-environment/sync-to-environment.usecase'; export class WorkflowNotSyncableException extends BadRequestException { constructor(workflow: Pick) { const reason = `origin '${workflow.origin}' is not allowed (must be one of: ${SYNCABLE_WORKFLOW_ORIGINS.join(', ')})`; super({ message: `Cannot sync workflow: ${reason}`, workflowId: workflow.workflowId, status: workflow.status, origin: workflow.origin, allowedOrigins: SYNCABLE_WORKFLOW_ORIGINS, }); } } ================================================ FILE: apps/api/src/app/workflows-v2/maily-test-data.ts ================================================ import { DEFAULT_ARRAY_ELEMENTS } from '@novu/application-generic'; export function fullCodeSnippet() { return { type: 'doc', content: [ { type: 'logo', attrs: { src: 'https://maily.to/brand/logo.png', alt: null, title: null, 'maily-component': 'logo', size: 'md', alignment: 'left', }, }, { type: 'spacer', attrs: { height: 'xl', }, }, { type: 'heading', attrs: { textAlign: 'left', level: 2, }, content: [ { type: 'text', marks: [ { type: 'bold', }, ], text: 'Discover Maily', }, ], }, { type: 'paragraph', attrs: { textAlign: 'left', }, content: [ { type: 'text', text: 'Are you ready to transform your email communication? Introducing Maily, the powerful emaly.', }, ], }, { type: 'paragraph', attrs: { textAlign: 'left', }, content: [ { type: 'text', text: 'Elevate your email communication with Maily! Click below to try it out:', }, ], }, { type: 'button', attrs: { text: 'Try Maily Now →', url: '', alignment: 'left', variant: 'filled', borderRadius: 'round', buttonColor: '#000000', textColor: '#ffffff', }, }, { type: 'section', attrs: { showIfKey: 'payload.params.isPayedUser', borderRadius: 0, backgroundColor: '#f7f7f7', align: 'left', borderWidth: 1, borderColor: '#e2e2e2', paddingTop: 5, paddingRight: 5, paddingBottom: 5, paddingLeft: 5, marginTop: 0, marginRight: 0, marginBottom: 0, marginLeft: 0, }, content: [ { type: 'paragraph', attrs: { textAlign: 'left', }, content: [ { type: 'variable', attrs: { id: 'payload.hidden.section', label: null, fallback: 'should be the fallback value', }, }, { type: 'text', text: ' ', }, { type: 'variable', attrs: { id: 'subscriber.firstName', label: null, fallback: 'should be the fallback value', }, }, ], }, ], }, { type: 'paragraph', attrs: { textAlign: 'left', }, content: [ { type: 'text', text: 'Join our vibrant community of users and developers on GitHub, where Maily is an ', }, { type: 'text', marks: [ { type: 'link', attrs: { href: 'https://github.com/arikchakma/maily.to', target: '_blank', rel: 'noopener noreferrer nofollow', class: null, }, }, { type: 'italic', }, ], text: 'open-source', }, { type: 'text', text: " project. Together, we'll shape the future of email editing.", }, ], }, { type: 'paragraph', attrs: { textAlign: 'left', }, content: [ { type: 'text', text: '@this is a placeholder value of name payload.body|| ', }, { type: 'variable', attrs: { id: 'payload.body', label: null, fallback: null, }, }, { type: 'text', text: ' |||the value should have been here', }, ], }, { type: 'paragraph', attrs: { textAlign: 'left', }, content: [ { type: 'text', text: 'this is a regular for block showing multiple comments:', }, ], }, { type: 'paragraph', attrs: { textAlign: 'left', }, content: [ { type: 'text', text: 'This will be two for each one in another column: ', }, ], }, { type: 'columns', attrs: { width: '100%', }, content: [ { type: 'column', attrs: { columnId: '394bcc6f-c674-4d56-aced-f3f54434482e', width: 50, verticalAlign: 'top', borderRadius: 0, backgroundColor: 'transparent', borderWidth: 0, borderColor: 'transparent', paddingTop: 0, paddingRight: 0, paddingBottom: 0, paddingLeft: 0, }, content: [ { type: 'repeat', attrs: { each: 'payload.origins', isUpdatingKey: false, }, content: [ { type: 'orderedList', attrs: { start: 1, }, content: [ { type: 'listItem', attrs: { color: null, }, content: [ { type: 'paragraph', attrs: { textAlign: 'left', }, content: [ { type: 'text', text: 'a list item: ', }, { type: 'variable', attrs: { id: 'payload.origins.country', label: null, }, }, { type: 'variable', attrs: { id: 'payload.origins.id', label: null, }, }, { type: 'variable', attrs: { id: 'payload.origins.time', label: null, }, }, { type: 'text', text: ' ', }, ], }, ], }, ], }, ], }, ], }, { type: 'column', attrs: { columnId: 'a61ae45e-ea27-4a2b-a356-bfad769ea50f', width: 50, verticalAlign: 'top', borderRadius: 0, backgroundColor: 'transparent', borderWidth: 0, borderColor: 'transparent', paddingTop: 0, paddingRight: 0, paddingBottom: 0, paddingLeft: 0, }, content: [ { type: 'repeat', attrs: { each: 'payload.students', isUpdatingKey: false, }, content: [ { type: 'bulletList', content: [ { type: 'listItem', attrs: { color: null, }, content: [ { type: 'paragraph', attrs: { textAlign: 'left', }, content: [ { type: 'text', text: 'bulleted list item: ', }, { type: 'variable', attrs: { id: 'payload.students.id', label: null, }, }, { type: 'text', text: ' and name: ', }, { type: 'variable', attrs: { id: 'payload.students.name', label: null, }, }, { type: 'text', text: ' ', }, ], }, ], }, { type: 'listItem', attrs: { color: null, }, content: [ { type: 'paragraph', attrs: { textAlign: 'left', }, content: [ { type: 'text', text: 'buffer bullet item', }, ], }, ], }, ], }, ], }, ], }, ], }, { type: 'paragraph', attrs: { textAlign: 'left', }, }, { type: 'paragraph', attrs: { textAlign: 'left', }, content: [ { type: 'text', text: 'This will be a nested repeat block', }, ], }, { type: 'repeat', attrs: { each: 'payload.food.items', isUpdatingKey: false, }, content: [ { type: 'paragraph', attrs: { textAlign: 'left', }, content: [ { type: 'text', text: 'this is a food item with name ', }, { type: 'variable', attrs: { id: 'payload.food.items.name', label: null, }, }, { type: 'text', text: ' ', }, ], }, ], }, { type: 'paragraph', attrs: { textAlign: 'left', }, }, { type: 'paragraph', attrs: { textAlign: 'left', }, content: [ { type: 'text', text: 'Regards,', }, { type: 'hardBreak', }, { type: 'text', text: 'Arikko', }, ], }, ], }; } export function previewPayloadExample() { return { payload: { subject: { test: { payload: 'payload', }, }, params: { isPayedUser: true, }, hidden: { section: 'section', }, body: 'body', origins: Array(DEFAULT_ARRAY_ELEMENTS).fill({ country: 'country', id: 'id', time: 'time', }), students: Array(DEFAULT_ARRAY_ELEMENTS).fill({ id: 'id', name: 'name', }), food: { items: Array(DEFAULT_ARRAY_ELEMENTS).fill({ name: 'name', }), }, }, subscriber: { firstName: 'John', lastName: 'Doe', email: 'user@example.com', phone: '+1234567890', avatar: 'https://example.com/avatar.png', locale: 'en_US', timezone: 'America/New_York', data: {}, }, steps: {}, }; } ================================================ FILE: apps/api/src/app/workflows-v2/usecases/build-test-data/build-workflow-test-data.command.ts ================================================ import { EnvironmentWithUserObjectCommand } from '@novu/application-generic'; import { IsDefined, IsString } from 'class-validator'; export class WorkflowTestDataCommand extends EnvironmentWithUserObjectCommand { @IsString() @IsDefined() workflowIdOrInternalId: string; } ================================================ FILE: apps/api/src/app/workflows-v2/usecases/build-test-data/build-workflow-test-data.usecase.ts ================================================ import { Injectable } from '@nestjs/common'; import { buildVariablesSchema, CreateVariablesObject, CreateVariablesObjectCommand, GetWorkflowByIdsCommand, GetWorkflowByIdsUseCase, Instrument, InstrumentUsecase, JSONSchemaDto, mockSchemaDefaults, parsePayloadSchema, } from '@novu/application-generic'; import { ControlValuesRepository, JsonSchemaFormatEnum, JsonSchemaTypeEnum, NotificationStepEntity, NotificationTemplateEntity, } from '@novu/dal'; import { ControlValuesLevelEnum, StepTypeEnum, UserSessionData } from '@novu/shared'; import { WorkflowTestDataResponseDto } from '../../dtos'; import { WorkflowTestDataCommand } from './build-workflow-test-data.command'; @Injectable() export class BuildWorkflowTestDataUseCase { constructor( private readonly getWorkflowByIdsUseCase: GetWorkflowByIdsUseCase, private readonly createVariablesObject: CreateVariablesObject, private readonly controlValuesRepository: ControlValuesRepository ) {} @InstrumentUsecase() async execute(command: WorkflowTestDataCommand): Promise { const workflow = await this.fetchWorkflow(command); const toSchema = this.buildToFieldSchema({ user: command.user, steps: workflow.steps }); const payloadSchema = await this.resolvePayloadSchema(workflow, command); const payloadSchemaMock = this.generatePayloadMock(payloadSchema); return { to: toSchema, payload: payloadSchemaMock, }; } @Instrument() private async resolvePayloadSchema( workflow: NotificationTemplateEntity, command: WorkflowTestDataCommand ): Promise { if (workflow.payloadSchema) { return parsePayloadSchema(workflow.payloadSchema, { safe: true }) || {}; } const controls = await this.controlValuesRepository.find( { _environmentId: command.user.environmentId, _organizationId: command.user.organizationId, _workflowId: workflow._id, level: ControlValuesLevelEnum.STEP_CONTROLS, controls: { $ne: null }, }, { controls: 1, _id: 0, } ); const allControlValuesFlat = controls .flatMap((item) => item.controls) .flatMap((obj) => Object.values(obj as Record)); const { payload } = await this.createVariablesObject.execute( CreateVariablesObjectCommand.create({ environmentId: command.user.environmentId, organizationId: command.user.organizationId, controlValues: allControlValuesFlat, }) ); return buildVariablesSchema(payload); } private generatePayloadMock(schema: JSONSchemaDto): JSONSchemaDto { if (!schema?.properties || Object.keys(schema.properties).length === 0) { return {}; } return mockSchemaDefaults(schema); } @Instrument() private async fetchWorkflow(command: WorkflowTestDataCommand): Promise { return this.getWorkflowByIdsUseCase.execute( GetWorkflowByIdsCommand.create({ environmentId: command.user.environmentId, organizationId: command.user.organizationId, workflowIdOrInternalId: command.workflowIdOrInternalId, }) ); } private buildToFieldSchema({ user, steps, }: { user: UserSessionData; steps: NotificationStepEntity[]; }): JSONSchemaDto { const hasEmailStep = this.hasStepType(steps, StepTypeEnum.EMAIL); const hasSmsStep = this.hasStepType(steps, StepTypeEnum.SMS); const properties: { [key: string]: JSONSchemaDto } = { subscriberId: { type: JsonSchemaTypeEnum.STRING, default: user._id }, }; const required: string[] = ['subscriberId']; if (hasEmailStep) { properties.email = { type: JsonSchemaTypeEnum.STRING, default: user.email ?? '', format: JsonSchemaFormatEnum.EMAIL, }; required.push('email'); } if (hasSmsStep) { properties.phone = { type: JsonSchemaTypeEnum.STRING, default: '' }; required.push('phone'); } return { type: JsonSchemaTypeEnum.OBJECT, properties, required, additionalProperties: false, } satisfies JSONSchemaDto; } private hasStepType(steps: NotificationStepEntity[], type: StepTypeEnum): boolean { return steps.some((step) => step.template?.type === type); } } ================================================ FILE: apps/api/src/app/workflows-v2/usecases/build-test-data/index.ts ================================================ export * from './build-workflow-test-data.command'; export * from './build-workflow-test-data.usecase'; ================================================ FILE: apps/api/src/app/workflows-v2/usecases/duplicate-workflow/duplicate-workflow.command.ts ================================================ import { EnvironmentWithUserObjectCommand } from '@novu/application-generic'; import { Type } from 'class-transformer'; import { IsDefined, IsString, ValidateNested } from 'class-validator'; import { DuplicateWorkflowDto } from '../../dtos/duplicate-workflow.dto'; export class DuplicateWorkflowCommand extends EnvironmentWithUserObjectCommand { @IsString() @IsDefined() workflowIdOrInternalId: string; @ValidateNested() @Type(() => DuplicateWorkflowDto) overrides: DuplicateWorkflowDto; } ================================================ FILE: apps/api/src/app/workflows-v2/usecases/duplicate-workflow/duplicate-workflow.usecase.ts ================================================ import { Injectable } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import { GetWorkflowCommand, GetWorkflowUseCase, InstrumentUsecase, PinoLogger, StepResponseDto, UpsertStepDataCommand, UpsertWorkflowCommand, UpsertWorkflowDataCommand, UpsertWorkflowUseCase, WorkflowPreferencesDto, WorkflowResponseDto, } from '@novu/application-generic'; import { LocalizationResourceEnum, PreferencesEntity, PreferencesRepository } from '@novu/dal'; import { PreferencesTypeEnum, ResourceOriginEnum, WorkflowCreationSourceEnum } from '@novu/shared'; import { DuplicateWorkflowDto } from '../../dtos'; import { WorkflowNotDuplicableException } from '../../exceptions/workflow-not-duplicable-exception'; import { DuplicateWorkflowCommand } from './duplicate-workflow.command'; export const DUPLICABLE_WORKFLOW_ORIGINS = [ResourceOriginEnum.NOVU_CLOUD]; @Injectable() export class DuplicateWorkflowUseCase { constructor( private getWorkflowUseCase: GetWorkflowUseCase, private preferencesRepository: PreferencesRepository, private upsertWorkflowUseCase: UpsertWorkflowUseCase, private moduleRef: ModuleRef, private logger: PinoLogger ) {} @InstrumentUsecase() async execute(command: DuplicateWorkflowCommand): Promise { const workflow = await this.getWorkflowUseCase.execute( GetWorkflowCommand.create({ workflowIdOrInternalId: command.workflowIdOrInternalId, user: command.user, }) ); if (!this.isDuplicable(workflow)) { throw new WorkflowNotDuplicableException(workflow); } const preferences = await this.getWorkflowPreferences(workflow._id, command.user.environmentId); const duplicateWorkflowDto = await this.buildDuplicateWorkflowDto(workflow, command.overrides, preferences); const duplicatedWorkflow = await this.upsertWorkflowUseCase.execute( UpsertWorkflowCommand.create({ workflowDto: duplicateWorkflowDto, user: command.user, preserveWorkflowId: !!command.overrides.workflowId, }) ); if (duplicatedWorkflow.isTranslationEnabled) { await this.duplicateTranslationsForWorkflow({ sourceResourceId: workflow.workflowId, targetResourceId: duplicatedWorkflow.workflowId, command, }); } return duplicatedWorkflow; } private isDuplicable(workflow: WorkflowResponseDto): boolean { return DUPLICABLE_WORKFLOW_ORIGINS.includes(workflow.origin); } private async buildDuplicateWorkflowDto( originWorkflow: WorkflowResponseDto, overrides: DuplicateWorkflowDto, preferences: PreferencesEntity[] ): Promise { return { workflowId: overrides.workflowId, name: overrides.name ?? `${originWorkflow.name} (Copy)`, description: overrides.description ?? originWorkflow.description, tags: overrides.tags ?? originWorkflow.tags, active: false, origin: ResourceOriginEnum.NOVU_CLOUD, __source: WorkflowCreationSourceEnum.DASHBOARD, steps: this.mapStepsToDuplicate(originWorkflow.steps), preferences: this.mapPreferences(preferences), isTranslationEnabled: overrides.isTranslationEnabled ?? originWorkflow.isTranslationEnabled, payloadSchema: originWorkflow.payloadSchema || null, validatePayload: originWorkflow.validatePayload, severity: originWorkflow.severity, }; } private mapStepsToDuplicate(steps: StepResponseDto[]): UpsertStepDataCommand[] { return steps.map((step) => ({ name: step.name ?? '', type: step.type, controlValues: step.controls?.values ?? null, stepId: step.stepId, slug: step.slug, })); } private mapPreferences(preferences: PreferencesEntity[]): { user: WorkflowPreferencesDto | null; workflow: WorkflowPreferencesDto | null; } { return { user: preferences.find((pref) => pref.type === PreferencesTypeEnum.USER_WORKFLOW) ?.preferences as WorkflowPreferencesDto | null, workflow: preferences.find((pref) => pref.type === PreferencesTypeEnum.WORKFLOW_RESOURCE) ?.preferences as WorkflowPreferencesDto | null, }; } private async getWorkflowPreferences(workflowId: string, environmentId: string): Promise { return await this.preferencesRepository.find({ _templateId: workflowId, _environmentId: environmentId, type: { $in: [PreferencesTypeEnum.WORKFLOW_RESOURCE, PreferencesTypeEnum.USER_WORKFLOW], }, }); } private async duplicateTranslationsForWorkflow({ sourceResourceId, targetResourceId, command, }: { sourceResourceId: string; targetResourceId: string; command: DuplicateWorkflowCommand; }) { const isEnterprise = process.env.NOVU_ENTERPRISE === 'true' || process.env.CI_EE_TEST === 'true'; const isSelfHosted = process.env.IS_SELF_HOSTED === 'true'; if (!isEnterprise || isSelfHosted) { return; } try { const duplicateLocales = this.moduleRef.get(require('@novu/ee-translation')?.DuplicateLocales, { strict: false, }); await duplicateLocales.execute({ sourceResourceId, sourceResourceType: LocalizationResourceEnum.WORKFLOW, targetResourceId, organizationId: command.user.organizationId, environmentId: command.user.environmentId, userId: command.user._id, }); } catch (error) { this.logger.error(`Failed to duplicate translations for workflow`, { sourceResourceId, targetResourceId, organizationId: command.user.organizationId, error: error instanceof Error ? error.message : String(error), }); throw error; } } } ================================================ FILE: apps/api/src/app/workflows-v2/usecases/duplicate-workflow/index.ts ================================================ export * from './duplicate-workflow.command'; export * from './duplicate-workflow.usecase'; ================================================ FILE: apps/api/src/app/workflows-v2/usecases/index.ts ================================================ export * from './build-test-data'; export * from './duplicate-workflow'; export * from './list-workflows'; export * from './sync-to-environment'; export * from './test-http-endpoint'; ================================================ FILE: apps/api/src/app/workflows-v2/usecases/list-workflows/index.ts ================================================ export * from './list-workflow.usecase'; export * from './list-workflows.command'; ================================================ FILE: apps/api/src/app/workflows-v2/usecases/list-workflows/list-workflow.usecase.ts ================================================ import { Injectable } from '@nestjs/common'; import { InstrumentUsecase, toWorkflowsMinifiedDtos } from '@novu/application-generic'; import { NotificationTemplateRepository } from '@novu/dal'; import { ListWorkflowResponse } from '../../dtos'; import { ListWorkflowsCommand } from './list-workflows.command'; @Injectable() export class ListWorkflowsUseCase { constructor(private notificationTemplateRepository: NotificationTemplateRepository) {} @InstrumentUsecase() async execute(command: ListWorkflowsCommand): Promise { const res = await this.notificationTemplateRepository.getList( command.user.organizationId, command.user.environmentId, command.offset, command.limit, command.searchQuery, false, command.orderBy, command.orderDirection, command.tags, command.status ); if (res.data === null || res.data === undefined) { return { workflows: [], totalCount: 0 }; } return { workflows: toWorkflowsMinifiedDtos(res.data), totalCount: res.totalCount, }; } } ================================================ FILE: apps/api/src/app/workflows-v2/usecases/list-workflows/list-workflows.command.ts ================================================ import { PaginatedListCommand } from '@novu/application-generic'; import { StepTypeEnum, WorkflowStatusEnum } from '@novu/shared'; import { IsArray, IsEnum, IsOptional, IsString } from 'class-validator'; export class ListWorkflowsCommand extends PaginatedListCommand { @IsOptional() searchQuery?: string; @IsOptional() @IsArray() @IsString({ each: true }) tags?: string[]; @IsOptional() @IsArray() @IsEnum(WorkflowStatusEnum, { each: true }) status?: WorkflowStatusEnum[]; } ================================================ FILE: apps/api/src/app/workflows-v2/usecases/patch-workflow/index.ts ================================================ export * from './patch-workflow.command'; export * from './patch-workflow.usecase'; ================================================ FILE: apps/api/src/app/workflows-v2/usecases/patch-workflow/patch-workflow.command.ts ================================================ import { EnvironmentWithUserObjectCommand } from '@novu/application-generic'; import { ClientSession } from '@novu/dal'; import { Exclude } from 'class-transformer'; import { IsArray, IsBoolean, IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator'; export class PatchWorkflowCommand extends EnvironmentWithUserObjectCommand { @IsString() @IsNotEmpty() workflowIdOrInternalId: string; @IsBoolean() @IsOptional() active?: boolean; @IsString() @IsOptional() name?: string; @IsString() @IsOptional() description?: string; @IsArray() @IsOptional() tags?: string[]; @IsObject() @IsOptional() payloadSchema?: object; @IsBoolean() @IsOptional() validatePayload?: boolean; @IsBoolean() @IsOptional() isTranslationEnabled?: boolean; @IsOptional() @IsString() @Exclude() session?: ClientSession | null; } ================================================ FILE: apps/api/src/app/workflows-v2/usecases/patch-workflow/patch-workflow.usecase.ts ================================================ import { Injectable } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import { BuildStepIssuesUsecase, GetWorkflowUseCase, GetWorkflowWithPreferencesUseCase, Instrument, InstrumentUsecase, PinoLogger, SendWebhookMessage, stepTypeToControlSchema, WorkflowResponseDto, WorkflowWithPreferencesResponseDto, } from '@novu/application-generic'; import { LocalizationResourceEnum, NotificationTemplateEntity, NotificationTemplateRepository } from '@novu/dal'; import { UserSessionData, WebhookEventEnum, WebhookObjectTypeEnum, WorkflowStatusEnum } from '@novu/shared'; import { MANAGE_TRANSLATIONS } from '../../../shared/constants'; import { PatchWorkflowCommand } from './patch-workflow.command'; @Injectable() export class PatchWorkflowUsecase { constructor( private getWorkflowWithPreferencesUseCase: GetWorkflowWithPreferencesUseCase, private notificationTemplateRepository: NotificationTemplateRepository, private getWorkflowUseCase: GetWorkflowUseCase, private buildStepIssuesUsecase: BuildStepIssuesUsecase, private moduleRef: ModuleRef, private logger: PinoLogger, private sendWebhookMessage: SendWebhookMessage ) {} @InstrumentUsecase() async execute(command: PatchWorkflowCommand): Promise { const persistedWorkflow = await this.fetchWorkflow(command); const transientWorkflow = this.patchWorkflowFields(persistedWorkflow, command); const hasPayloadSchemaChanged = this.hasPayloadSchemaChanged(persistedWorkflow, command); if (hasPayloadSchemaChanged) { await this.recalculateStepIssues(transientWorkflow, command.user); } if (command.isTranslationEnabled !== undefined) { await this.toggleV2TranslationsForWorkflow(persistedWorkflow.triggers[0].identifier, command); } await this.persistWorkflow(transientWorkflow, command.user); const updatedWorkflow = await this.getWorkflowUseCase.execute({ workflowIdOrInternalId: command.workflowIdOrInternalId, user: command.user, }); await this.sendWebhookMessage.execute({ eventType: WebhookEventEnum.WORKFLOW_UPDATED, objectType: WebhookObjectTypeEnum.WORKFLOW, payload: { object: updatedWorkflow as unknown as Record, previousObject: persistedWorkflow as unknown as Record, }, organizationId: command.user.organizationId, environmentId: command.user.environmentId, }); return updatedWorkflow; } private hasPayloadSchemaChanged( persistedWorkflow: NotificationTemplateEntity, command: PatchWorkflowCommand ): boolean { return ( command.payloadSchema !== undefined && command.payloadSchema !== null && JSON.stringify(persistedWorkflow.payloadSchema) !== JSON.stringify(command.payloadSchema) ); } @Instrument() private async recalculateStepIssues( workflow: NotificationTemplateEntity, userSessionData: UserSessionData ): Promise { for (const step of workflow.steps) { if (!step._templateId || !step.template?.type) continue; const controlSchemas = step.template?.controls || stepTypeToControlSchema[step.template.type]; const stepIssues = await this.buildStepIssuesUsecase.execute({ workflowOrigin: workflow.origin!, user: userSessionData, stepInternalId: step._templateId, workflow, controlSchema: controlSchemas.schema, stepType: step.template.type, }); step.issues = stepIssues; } } private patchWorkflowFields( persistedWorkflow: NotificationTemplateEntity, command: PatchWorkflowCommand ): NotificationTemplateEntity { const transientWorkflow = { ...persistedWorkflow }; if (command.active !== undefined && command.active !== null) { transientWorkflow.active = command.active; } if (command.payloadSchema !== undefined && command.payloadSchema !== null) { transientWorkflow.payloadSchema = command.payloadSchema; } if (command.validatePayload !== undefined && command.validatePayload !== null) { transientWorkflow.validatePayload = command.validatePayload; } if (command.name !== undefined && command.name !== null) { transientWorkflow.name = command.name; } if (command.description !== undefined && command.description !== null) { transientWorkflow.description = command.description; } if (command.tags !== undefined && command.tags !== null) { transientWorkflow.tags = command.tags; } if (command.active !== undefined && command.active !== null) { transientWorkflow.status = command.active ? WorkflowStatusEnum.ACTIVE : WorkflowStatusEnum.INACTIVE; } return transientWorkflow; } private async persistWorkflow(workflowWithIssues: NotificationTemplateEntity, userSessionData: UserSessionData) { await this.notificationTemplateRepository.update( { _id: workflowWithIssues._id, _environmentId: userSessionData.environmentId, }, { ...workflowWithIssues, } ); } private async fetchWorkflow(command: PatchWorkflowCommand): Promise { return await this.getWorkflowWithPreferencesUseCase.execute({ workflowIdOrInternalId: command.workflowIdOrInternalId, environmentId: command.user.environmentId, organizationId: command.user.organizationId, session: command.session, }); } private async toggleV2TranslationsForWorkflow(workflowIdentifier: string, command: PatchWorkflowCommand) { const isEnterprise = process.env.NOVU_ENTERPRISE === 'true' || process.env.CI_EE_TEST === 'true'; const isSelfHosted = process.env.IS_SELF_HOSTED === 'true'; if (!isEnterprise || isSelfHosted) { return; } try { const manageTranslations = this.moduleRef.get(MANAGE_TRANSLATIONS, { strict: false, }); await manageTranslations.execute({ enabled: command.isTranslationEnabled, resourceId: workflowIdentifier, resourceType: LocalizationResourceEnum.WORKFLOW, organizationId: command.user.organizationId, environmentId: command.user.environmentId, userId: command.user._id, session: command.session, }); } catch (error) { this.logger.error( `Failed to ${command.isTranslationEnabled ? 'enable' : 'disable'} V2 translations for workflow`, { workflowIdentifier, enabled: command.isTranslationEnabled, organizationId: command.user.organizationId, error: error instanceof Error ? error.message : String(error), } ); throw error; } } } ================================================ FILE: apps/api/src/app/workflows-v2/usecases/sync-to-environment/index.ts ================================================ export * from './sync-to-environment.command'; export * from './sync-to-environment.usecase'; ================================================ FILE: apps/api/src/app/workflows-v2/usecases/sync-to-environment/sync-to-environment.command.ts ================================================ import { EnvironmentWithUserObjectCommand } from '@novu/application-generic'; import { ClientSession } from '@novu/dal'; import { Exclude } from 'class-transformer'; import { IsDefined, IsOptional, IsString } from 'class-validator'; export class SyncToEnvironmentCommand extends EnvironmentWithUserObjectCommand { @IsString() @IsDefined() workflowIdOrInternalId: string; @IsString() @IsDefined() targetEnvironmentId: string; /** * Exclude session from the command to avoid serializing it in the response */ @IsOptional() @Exclude() session?: ClientSession | null; } ================================================ FILE: apps/api/src/app/workflows-v2/usecases/sync-to-environment/sync-to-environment.usecase.ts ================================================ import { BadRequestException, Injectable, NotFoundException, Optional } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import { FeatureFlagsService, GetWorkflowCommand, GetWorkflowUseCase, Instrument, InstrumentUsecase, SendWebhookMessage, StepResponseDto, UpsertStepDataCommand, UpsertWorkflowCommand, UpsertWorkflowDataCommand, UpsertWorkflowUseCase, WorkflowPreferencesDto, WorkflowResponseDto, } from '@novu/application-generic'; import { BaseRepository, ClientSession, EnvironmentRepository, LocalizationResourceEnum, NotificationTemplateRepository, PreferencesEntity, PreferencesRepository, } from '@novu/dal'; import { FeatureFlagsKeysEnum, PreferencesTypeEnum, ResourceOriginEnum, StepTypeEnum, WebhookEventEnum, WebhookObjectTypeEnum, WorkflowCreationSourceEnum, } from '@novu/shared'; import { LayoutSyncToEnvironmentCommand, LayoutSyncToEnvironmentUseCase, } from '../../../layouts-v2/usecases/sync-to-environment'; import { SyncStepResolverToEnvironmentCommand, SyncStepResolverToEnvironmentUsecase, } from '../../../step-resolvers/usecases/sync-step-resolver-to-environment'; import { WorkflowNotSyncableException } from '../../exceptions/workflow-not-syncable-exception'; import { SyncToEnvironmentCommand } from './sync-to-environment.command'; export const SYNCABLE_WORKFLOW_ORIGINS = [ResourceOriginEnum.NOVU_CLOUD]; /** * This usecase is used to sync a workflow from one environment to another. * It will create a new workflow in the target environment if it doesn't exist, or update it if it does. * The cloning of the workflow to the target environment includes: * - the workflow (NotificationTemplateEntity) + steps * - the preferences (PreferencesEntity) * - the control values (ControlValuesEntity) * - the message template (MessageTemplateEntity) * - the payload schema and validation settings */ @Injectable() export class SyncToEnvironmentUseCase { constructor( private getWorkflowUseCase: GetWorkflowUseCase, private preferencesRepository: PreferencesRepository, private upsertWorkflowUseCase: UpsertWorkflowUseCase, private layoutSyncToEnvironmentUseCase: LayoutSyncToEnvironmentUseCase, private syncStepResolverToEnvironmentUsecase: SyncStepResolverToEnvironmentUsecase, private featureFlagsService: FeatureFlagsService, private moduleRef: ModuleRef, private notificationTemplateRepository: NotificationTemplateRepository, private environmentRepository: EnvironmentRepository, @Optional() private sendWebhookMessage?: SendWebhookMessage ) {} @InstrumentUsecase() async execute(command: SyncToEnvironmentCommand): Promise { if (command.user.environmentId === command.targetEnvironmentId) { throw new BadRequestException('Cannot sync workflow to the same environment'); } await this.validateTargetEnvironment(command.targetEnvironmentId, command.user.organizationId); const sourceWorkflow = await this.getWorkflowUseCase.execute( GetWorkflowCommand.create({ user: command.user, workflowIdOrInternalId: command.workflowIdOrInternalId, }) ); if (!this.isSyncable(sourceWorkflow)) { throw new WorkflowNotSyncableException(sourceWorkflow); } const preferencesToClone = await this.getWorkflowPreferences( sourceWorkflow._id, command.user.environmentId, command.session ); const externalId = sourceWorkflow.workflowId; const targetWorkflow = await this.findWorkflowInTargetEnvironment(command, externalId); const workflowDto = await this.buildRequestDto(sourceWorkflow, preferencesToClone, targetWorkflow); const layoutsToSync: string[] = []; for (const step of workflowDto.steps) { if (step.type === StepTypeEnum.EMAIL && step.controlValues?.layoutId) { const layoutId = step.controlValues?.layoutId as string; layoutsToSync.push(layoutId); } } const layoutsToSyncPromises = layoutsToSync.map((layoutId) => this.layoutSyncToEnvironmentUseCase.execute( LayoutSyncToEnvironmentCommand.create({ user: command.user, layoutIdOrInternalId: layoutId, targetEnvironmentId: command.targetEnvironmentId, }) ) ); await Promise.all(layoutsToSyncPromises); const layoutsTranslationGroupsPromises = layoutsToSync.map((layoutId) => this.publishTranslationGroup(layoutId, LocalizationResourceEnum.LAYOUT, command) ); await Promise.all(layoutsTranslationGroupsPromises); const upsertedWorkflow = await this.upsertWorkflowUseCase.execute( UpsertWorkflowCommand.create({ preserveWorkflowId: true, user: { ...command.user, environmentId: command.targetEnvironmentId }, workflowIdOrInternalId: targetWorkflow?._id, workflowDto, session: command.session, }) ); await this.syncStepResolver(command, sourceWorkflow, upsertedWorkflow); await this.publishTranslationGroup(sourceWorkflow.workflowId, LocalizationResourceEnum.WORKFLOW, command); // Update the source workflow with publish information await this.notificationTemplateRepository.updatePublishFields( sourceWorkflow._id, command.user.environmentId, command.user._id, command.session ); if (this.sendWebhookMessage) { await this.sendWebhookMessage.execute({ eventType: WebhookEventEnum.WORKFLOW_PUBLISHED, objectType: WebhookObjectTypeEnum.WORKFLOW, payload: { object: upsertedWorkflow as unknown as Record, previousObject: sourceWorkflow as unknown as Record, }, organizationId: command.user.organizationId, environmentId: command.user.environmentId, }); } return upsertedWorkflow; } private async validateTargetEnvironment(targetEnvironmentId: string, organizationId: string): Promise { if (!BaseRepository.isInternalId(targetEnvironmentId)) { throw new NotFoundException(`Environment ${targetEnvironmentId} not found`); } const environment = await this.environmentRepository.findByIdAndOrganization(targetEnvironmentId, organizationId); if (!environment) { throw new NotFoundException(`Environment ${targetEnvironmentId} not found`); } } @Instrument() private async syncStepResolver( command: SyncToEnvironmentCommand, sourceWorkflow: WorkflowResponseDto, upsertedWorkflow: WorkflowResponseDto ): Promise { const isEnabled = await this.featureFlagsService.getFlag({ key: FeatureFlagsKeysEnum.IS_STEP_RESOLVER_ENABLED, defaultValue: false, organization: { _id: command.user.organizationId }, }); if (!isEnabled) return; await this.syncStepResolverToEnvironmentUsecase.execute( SyncStepResolverToEnvironmentCommand.create({ user: command.user, targetEnvironmentId: command.targetEnvironmentId, session: command.session, sourceSteps: sourceWorkflow.steps.map((step) => ({ stepId: step.stepId, stepType: step.type, stepResolverHash: step.stepResolverHash, controlSchema: (step.controls?.dataSchema as Record) ?? null, })), targetSteps: upsertedWorkflow.steps.map((step) => ({ stepId: step.stepId, stepResolverHash: step.stepResolverHash, templateId: step._id, })), }) ); } private async publishTranslationGroup( resourceId: string, resourceType: LocalizationResourceEnum, command: SyncToEnvironmentCommand ): Promise { const isEnterprise = process.env.NOVU_ENTERPRISE === 'true' || process.env.CI_EE_TEST === 'true'; const isSelfHosted = process.env.IS_SELF_HOSTED === 'true'; if (!isEnterprise || isSelfHosted) { return; } const publishTranslationGroup = this.moduleRef.get(require('@novu/ee-translation')?.PublishTranslationGroup, { strict: false, }); const { user, targetEnvironmentId } = command; await publishTranslationGroup.execute({ user, resourceId, resourceType, sourceEnvironmentId: user.environmentId, targetEnvironmentId, }); } private isSyncable(workflow: WorkflowResponseDto): boolean { return SYNCABLE_WORKFLOW_ORIGINS.includes(workflow.origin); } private async buildRequestDto( sourceWorkflow: WorkflowResponseDto, preferencesToClone: PreferencesEntity[], targetWorkflow?: WorkflowResponseDto ): Promise { if (targetWorkflow) { return await this.mapWorkflowToUpdateWorkflowDto(sourceWorkflow, targetWorkflow, preferencesToClone); } return await this.mapWorkflowToCreateWorkflowDto(sourceWorkflow, preferencesToClone); } @Instrument() private async findWorkflowInTargetEnvironment( command: SyncToEnvironmentCommand, externalId: string ): Promise { try { return await this.getWorkflowUseCase.execute( GetWorkflowCommand.create({ user: { ...command.user, environmentId: command.targetEnvironmentId }, workflowIdOrInternalId: externalId, }) ); } catch (error) { return undefined; } } private async mapWorkflowToCreateWorkflowDto( sourceWorkflow: WorkflowResponseDto, preferences: PreferencesEntity[] ): Promise { return { workflowId: sourceWorkflow.workflowId, payloadSchema: sourceWorkflow.payloadSchema || null, validatePayload: sourceWorkflow.validatePayload, isTranslationEnabled: sourceWorkflow.isTranslationEnabled, origin: ResourceOriginEnum.NOVU_CLOUD, name: sourceWorkflow.name, active: sourceWorkflow.active, tags: sourceWorkflow.tags, description: sourceWorkflow.description, __source: WorkflowCreationSourceEnum.DASHBOARD, severity: sourceWorkflow.severity, steps: await this.mapStepsToCreateOrUpdateDto(sourceWorkflow.steps), preferences: this.mapPreferences(preferences), }; } private async mapWorkflowToUpdateWorkflowDto( sourceWorkflow: WorkflowResponseDto, existingTargetEnvWorkflow: WorkflowResponseDto | undefined, preferencesToClone: PreferencesEntity[] ): Promise { return { origin: ResourceOriginEnum.NOVU_CLOUD, payloadSchema: sourceWorkflow.payloadSchema || null, validatePayload: sourceWorkflow.validatePayload, workflowId: sourceWorkflow.workflowId, isTranslationEnabled: sourceWorkflow.isTranslationEnabled, name: sourceWorkflow.name, active: sourceWorkflow.active, tags: sourceWorkflow.tags, description: sourceWorkflow.description, severity: sourceWorkflow.severity, steps: await this.mapStepsToCreateOrUpdateDto(sourceWorkflow.steps, existingTargetEnvWorkflow?.steps), preferences: this.mapPreferences(preferencesToClone), }; } private async mapStepsToCreateOrUpdateDto( sourceSteps: StepResponseDto[], targetEnvSteps?: StepResponseDto[] ): Promise { return sourceSteps.map((sourceStep) => { // if we find matching step in target environment, we are updating const targetStepInternalId = targetEnvSteps?.find((targetStep) => targetStep.stepId === sourceStep.stepId)?._id; return this.buildStepCreateOrUpdateDto(sourceStep, targetStepInternalId); }); } private buildStepCreateOrUpdateDto( sourceStep: StepResponseDto, targetStepInternalId?: string ): UpsertStepDataCommand { return { ...(targetStepInternalId && { _id: targetStepInternalId }), stepId: sourceStep.stepId, name: sourceStep.name ?? '', type: sourceStep.type, controlValues: sourceStep.controls?.values ?? {}, }; } private mapPreferences(preferences: PreferencesEntity[]): { user: WorkflowPreferencesDto | null; workflow: WorkflowPreferencesDto | null; } { // we can typecast the preferences to WorkflowPreferences because user and workflow preferences are always full set return { user: preferences.find((pref) => pref.type === PreferencesTypeEnum.USER_WORKFLOW) ?.preferences as WorkflowPreferencesDto | null, workflow: preferences.find((pref) => pref.type === PreferencesTypeEnum.WORKFLOW_RESOURCE) ?.preferences as WorkflowPreferencesDto | null, }; } private async getWorkflowPreferences( workflowId: string, environmentId: string, session?: ClientSession | null ): Promise { return await this.preferencesRepository.find( { _templateId: workflowId, _environmentId: environmentId, type: { $in: [PreferencesTypeEnum.WORKFLOW_RESOURCE, PreferencesTypeEnum.USER_WORKFLOW], }, }, '', { session } ); } } ================================================ FILE: apps/api/src/app/workflows-v2/usecases/test-http-endpoint/index.ts ================================================ export * from './test-http-endpoint.command'; export * from './test-http-endpoint.usecase'; ================================================ FILE: apps/api/src/app/workflows-v2/usecases/test-http-endpoint/test-http-endpoint.command.ts ================================================ import { EnvironmentWithUserObjectCommand, PreviewPayloadDto } from '@novu/application-generic'; import { IsObject, IsOptional } from 'class-validator'; export class TestHttpEndpointCommand extends EnvironmentWithUserObjectCommand { @IsOptional() @IsObject() controlValues?: Record; @IsOptional() previewPayload?: PreviewPayloadDto; } ================================================ FILE: apps/api/src/app/workflows-v2/usecases/test-http-endpoint/test-http-endpoint.usecase.ts ================================================ import { Injectable } from '@nestjs/common'; import { buildNovuSignatureHeader, GetDecryptedSecretKey, GetDecryptedSecretKeyCommand, HttpClientError, HttpClientErrorType, HttpClientService, HttpRequestOptions, InstrumentUsecase, KeyValuePair, shouldIncludeBody, } from '@novu/application-generic'; import { createLiquidEngine } from '@novu/framework/internal'; import { Liquid } from 'liquidjs'; import { TestHttpEndpointResponseDto } from '../../dtos/test-http-endpoint.dto'; import { TestHttpEndpointCommand } from './test-http-endpoint.command'; const HTTP_CLIENT_ERROR_STATUS_MAP: Record = { [HttpClientErrorType.TIMEOUT]: 408, [HttpClientErrorType.NETWORK_ERROR]: 502, [HttpClientErrorType.CERTIFICATE_ERROR]: 502, [HttpClientErrorType.UNSUPPORTED_PROTOCOL]: 400, [HttpClientErrorType.MAX_REDIRECTS]: 502, [HttpClientErrorType.READ_ERROR]: 502, [HttpClientErrorType.UPLOAD_ERROR]: 502, [HttpClientErrorType.CACHE_ERROR]: 502, [HttpClientErrorType.PARSE_ERROR]: 502, [HttpClientErrorType.HTTP_ERROR]: 500, [HttpClientErrorType.UNKNOWN]: 500, }; @Injectable() export class TestHttpEndpointUsecase { private readonly liquidEngine: Liquid; constructor( private readonly httpClientService: HttpClientService, private readonly getDecryptedSecretKey: GetDecryptedSecretKey ) { this.liquidEngine = createLiquidEngine(); } @InstrumentUsecase() async execute(command: TestHttpEndpointCommand): Promise { const { controlValues = {}, previewPayload } = command; const compileContext = this.buildCompileContext(previewPayload); const compiled = (await this.compileControlValues(controlValues, compileContext)) as typeof controlValues; const resolvedUrl = (compiled.url as string) ?? ''; const method = (compiled.method as string) ?? 'GET'; const compiledHeaders = (compiled.headers as KeyValuePair[]) ?? []; const compiledBody = (compiled.body as KeyValuePair[]) ?? []; const resolvedHeaders: Record = Object.fromEntries( compiledHeaders.filter(({ key }) => key).map(({ key, value }) => [key, value]) ); const resolvedBodyPairs: Record = Object.fromEntries( compiledBody.filter(({ key }) => key).map(({ key, value }) => [key, value]) ); const hasBody = shouldIncludeBody(resolvedBodyPairs, method); const secretKey = await this.getDecryptedSecretKey.execute( GetDecryptedSecretKeyCommand.create({ environmentId: command.user.environmentId }) ); resolvedHeaders['novu-signature'] = buildNovuSignatureHeader(secretKey, hasBody ? resolvedBodyPairs : {}); const startTime = performance.now(); try { const response = await this.httpClientService.request({ url: resolvedUrl, method: method as HttpRequestOptions['method'], headers: resolvedHeaders, ...(hasBody ? { body: resolvedBodyPairs } : {}), timeout: 30_000, responseType: 'text', }); const durationMs = Math.round(performance.now() - startTime); return { statusCode: response.statusCode, body: tryParseJson(response.body), headers: response.headers, durationMs, resolvedRequest: { url: resolvedUrl, method, headers: resolvedHeaders, ...(hasBody ? { body: resolvedBodyPairs } : {}), }, }; } catch (error) { const durationMs = Math.round(performance.now() - startTime); if (error instanceof HttpClientError) { const statusCode = error.statusCode ?? HTTP_CLIENT_ERROR_STATUS_MAP[error.type] ?? 500; return { statusCode, body: error.responseBody ?? { error: error.message, type: error.type, ...(error.networkCode ? { networkCode: error.networkCode } : {}), }, headers: {}, durationMs, resolvedRequest: { url: resolvedUrl, method, headers: resolvedHeaders, ...(hasBody ? { body: resolvedBodyPairs } : {}), }, }; } throw error; } } private buildCompileContext(previewPayload?: TestHttpEndpointCommand['previewPayload']): Record { if (!previewPayload) { return {}; } return { subscriber: previewPayload.subscriber ?? {}, payload: previewPayload.payload ?? {}, steps: previewPayload.steps ?? {}, env: previewPayload.env ?? {}, ...(previewPayload.context ? { context: previewPayload.context } : {}), }; } private async compileControlValues( values: Record, context: Record ): Promise { const compiled = await this.liquidEngine.parseAndRender(JSON.stringify(values), context); try { return JSON.parse(compiled); } catch { return values; } } } function tryParseJson(text: string): unknown { try { return JSON.parse(text); } catch { return text; } } ================================================ FILE: apps/api/src/app/workflows-v2/workflow.controller.e2e.ts ================================================ import { Novu } from '@novu/api'; import { ContentIssueEnum, CreateWorkflowDto, DigestStepUpsertDto, EmailStepResponseDto, EmailStepUpsertDto, InAppStepResponseDto, InAppStepUpsertDto, ListWorkflowResponse, ResourceOriginEnum, UpdateWorkflowDto, UpdateWorkflowDtoSteps, WorkflowCreationSourceEnum, WorkflowListResponseDto, WorkflowStatusEnum, } from '@novu/api/models/components'; import { ErrorDto } from '@novu/api/models/errors'; import { WorkflowResponseDto } from '@novu/api/src/models/components'; import { buildSlug, JSONSchemaDto } from '@novu/application-generic'; import { PreferencesRepository } from '@novu/dal'; import { ApiServiceLevelEnum, DEFAULT_WORKFLOW_PREFERENCES, FeatureNameEnum, getFeatureForTierAsNumber, ShortIsPrefixEnum, StepTypeEnum, slugify, } from '@novu/shared'; import { UserSession } from '@novu/testing'; import chai, { expect } from 'chai'; import chaiSubset from 'chai-subset'; import { expectSdkExceptionGeneric, expectSdkValidationExceptionGeneric, initNovuClassSdkInternalAuth, } from '../shared/helpers/e2e/sdk/e2e-sdk.helper'; chai.use(chaiSubset); // TODO: Introduce test factories for steps and workflows and move the following build functions there function buildInAppStep(overrides: Partial = {}): InAppStepUpsertDto { return { name: 'In-App Test Step', type: 'in_app', controlValues: { subject: 'Test Subject', body: 'Test Body', }, ...overrides, } as InAppStepUpsertDto; } function buildDigestStep(overrides: Partial = {}): DigestStepUpsertDto { return { name: 'Digest Test Step', type: 'digest', controlValues: { amount: 1, unit: 'hours', }, ...overrides, } as DigestStepUpsertDto; } function buildEmailStep(overrides: Partial = {}): EmailStepUpsertDto { return { name: 'Email Test Step', type: 'email', controlValues: { subject: 'Test Email Subject', body: 'Test Email Body', disableOutputSanitization: false, }, ...overrides, } as EmailStepUpsertDto; } // biome-ignore lint/suspicious/noExportsInTest: export function buildWorkflow(overrides: Partial = {}): CreateWorkflowDto { const name = overrides.name || 'Test Workflow'; return { source: WorkflowCreationSourceEnum.Editor, name, workflowId: slugify(name), description: 'This is a test workflow', active: true, tags: ['tag1', 'tag2'], steps: [buildEmailStep(), buildInAppStep()], ...overrides, } as CreateWorkflowDto; } let session: UserSession; function buildHeaders(overrideEnv?: string): HeadersInit { return { Authorization: session.token, 'Novu-Environment-Id': overrideEnv || session.environment._id, }; } async function createWorkflowAndExpectError( apiClient: Novu, createWorkflowDto: CreateWorkflowDto, expectedPartialErrorMsg?: string ): Promise { const res = await expectSdkExceptionGeneric(() => apiClient.workflows.create(createWorkflowDto)); expect(res.error).to.be.ok; if (expectedPartialErrorMsg) { expect(res.error?.message).to.include(expectedPartialErrorMsg); } return res.error!; } async function createWorkflowAndExpectValidationError( apiClient: Novu, createWorkflowDto: CreateWorkflowDto, expectedPartialErrorMsg?: string ): Promise { const res = await expectSdkValidationExceptionGeneric(() => apiClient.workflows.create(createWorkflowDto)); expect(res.error).to.be.ok; if (expectedPartialErrorMsg) { expect(JSON.stringify(res.error?.errors)).to.include(expectedPartialErrorMsg); } return res.error!; } async function createWorkflow(apiClient: Novu, createWorkflowDto: CreateWorkflowDto) { return (await apiClient.workflows.create(createWorkflowDto)).result; } describe('Workflow Controller E2E API Testing #novu-v2', () => { let apiClient: Novu; beforeEach(async () => { session = new UserSession(); await session.initialize(); apiClient = initNovuClassSdkInternalAuth(session); }); describe('Create workflow', () => { it('should allow creating two workflows for the same user with the same name', async () => { const name = `Test Workflow${new Date().toISOString()}`; await createWorkflowAndValidate(name); const createWorkflowDto: CreateWorkflowDto = buildWorkflow({ name }); const workflowCreated = await createWorkflow(apiClient, createWorkflowDto); expect(workflowCreated.workflowId).to.include(`${slugify(name)}-`); }); it('should generate a payload schema if only control values are provided during workflow creation', async () => { const steps: UpdateWorkflowDtoSteps[] = [ { ...buildEmailStep(), controlValues: { body: 'Welcome {{payload.name}}', subject: 'Hello {{payload.name}}', }, } as UpdateWorkflowDtoSteps, ]; const createWorkflowDto: CreateWorkflowDto = buildWorkflow({ steps, payloadSchema: { type: 'object', properties: { name: { type: 'string' }, }, required: [], additionalProperties: false, }, }); const workflow = await createWorkflow(apiClient, createWorkflowDto); expect(workflow).to.be.ok; expect(workflow.steps[0].variables).to.be.ok; const stepData = await getStepData(workflow.id, workflow.steps[0].id); expect(stepData.variables).to.be.ok; const { properties } = stepData.variables as JSONSchemaDto; expect(properties).to.be.ok; const payloadProperties = properties?.payload as JSONSchemaDto; expect(payloadProperties).to.be.ok; expect(payloadProperties.properties?.name).to.be.ok; }); it('should not allow to create more than 20 workflows for a free organization', async () => { await session.updateOrganizationServiceLevel(ApiServiceLevelEnum.FREE); getFeatureForTierAsNumber(FeatureNameEnum.PLATFORM_MAX_WORKFLOWS, ApiServiceLevelEnum.FREE, false); for (let i = 0; i < 20; i += 1) { const createWorkflowDto: CreateWorkflowDto = buildWorkflow({ name: new Date().toISOString() + i }); await createWorkflow(apiClient, createWorkflowDto); } const createWorkflowDto: CreateWorkflowDto = buildWorkflow({ name: new Date().toISOString() }); const error = await createWorkflowAndExpectError(apiClient, createWorkflowDto); expect(error?.statusCode).eq(400); }); it('should create workflow with payloadSchema and validatePayload fields', async () => { const payloadSchema = { type: 'object', properties: { name: { type: 'string', description: 'User name', }, age: { type: 'number', minimum: 0, }, }, required: ['name'], }; const createWorkflowDto: CreateWorkflowDto = { ...buildWorkflow({ name: `Test Workflow with Schema ${new Date().toISOString()}`, }), payloadSchema, validatePayload: true, }; const workflowCreated = await createWorkflow(apiClient, createWorkflowDto); expect(workflowCreated).to.be.ok; expect(workflowCreated.payloadSchema).to.deep.equal(payloadSchema); expect(workflowCreated.validatePayload).to.be.true; }); it('should create workflow with validatePayload false', async () => { const createWorkflowDto: CreateWorkflowDto = { ...buildWorkflow({ name: `Test Workflow No Validation ${new Date().toISOString()}`, }), validatePayload: false, }; const workflowCreated = await createWorkflow(apiClient, createWorkflowDto); expect(workflowCreated).to.be.ok; expect(workflowCreated.validatePayload).to.be.false; }); it('should create workflow with skip condition on a step using payload variable', async () => { const skipCondition = { '!=': [{ var: 'payload.skipStep' }, 'true'], }; const steps = [ buildEmailStep({ controlValues: { subject: 'Test Email Subject', body: 'Test Email Body', disableOutputSanitization: false, skip: skipCondition, }, }), buildInAppStep({ controlValues: { body: 'In-App Body', }, }), ]; const payloadSchema = { type: 'object', properties: { skipStep: { type: 'string' }, }, required: ['skipStep'], additionalProperties: false, }; const createWorkflowDto: CreateWorkflowDto = buildWorkflow({ name: `Skip Logic Workflow ${new Date().toISOString()}`, steps: steps as any, payloadSchema, }); const workflow = await createWorkflow(apiClient, createWorkflowDto); expect(workflow).to.be.ok; expect(workflow.steps).to.have.lengthOf(2); expect(Object.keys(workflow.issues || {}).length).to.equal(0); const emailStep = workflow.steps[0] as EmailStepResponseDto; expect(emailStep.type).to.equal('email'); expect(emailStep.controls.values.skip).to.deep.equal(skipCondition); expect(emailStep.controls.values.subject).to.equal('Test Email Subject'); const inAppStep = workflow.steps[1] as InAppStepResponseDto; expect(inAppStep.type).to.equal('in_app'); expect(inAppStep.controls.values.skip).to.be.undefined; const retrievedWorkflow = await getWorkflow(workflow.id); const retrievedEmailStep = retrievedWorkflow.steps[0] as EmailStepResponseDto; expect(retrievedEmailStep.controls.values.skip).to.deep.equal(skipCondition); const retrievedInAppStep = retrievedWorkflow.steps[1] as InAppStepResponseDto; expect(retrievedInAppStep.controls.values.skip).to.be.undefined; expect(retrievedWorkflow.payloadSchema).to.deep.equal(payloadSchema); }); it('should reject workflow creation with invalid JSON schema', async () => { const invalidPayloadSchema = { type: 'invalid-type', properties: 'not-an-object', }; const createWorkflowDto: CreateWorkflowDto = { ...buildWorkflow({ name: `Test Invalid Schema ${new Date().toISOString()}`, }), payloadSchema: invalidPayloadSchema, }; const error = await createWorkflowAndExpectValidationError(apiClient, createWorkflowDto); expect(error?.statusCode).to.equal(422); expect(JSON.stringify(error)).to.include('payloadSchema must be a valid JSON schema'); }); }); describe('Update workflow', () => { it('should update control values', async () => { const nameSuffix = `Test Workflow${new Date().toISOString()}`; const workflowCreated: WorkflowResponseDto = await createWorkflowAndValidate(nameSuffix); const inAppControlValue = 'In-App Test'; const emailControlValue = 'Email Test'; const updateRequest: UpdateWorkflowDto = { origin: ResourceOriginEnum.NovuCloud, name: workflowCreated.name, preferences: { user: null, }, steps: [ buildInAppStep({ controlValues: { subject: inAppControlValue } }), buildEmailStep({ controlValues: { subject: emailControlValue } }), ], workflowId: workflowCreated.workflowId, } as UpdateWorkflowDto; const updatedWorkflow: WorkflowResponseDto = await updateWorkflow( workflowCreated.id, updateRequest as UpdateWorkflowDto ); // TODO: Control values must be typed and accept only valid control values expect((updatedWorkflow.steps[0] as InAppStepResponseDto).controls.values.subject).to.be.equal(inAppControlValue); expect((updatedWorkflow.steps[1] as EmailStepResponseDto).controls.values.subject).to.be.equal(emailControlValue); }); it('should keep the step id on updated ', async () => { const nameSuffix = `Test Workflow${new Date().toISOString()}`; const workflowCreated: WorkflowResponseDto = await createWorkflowAndValidate(nameSuffix); const updatedWorkflow = await updateWorkflow(workflowCreated.id, mapResponseToUpdateDto(workflowCreated)); const updatedStep = updatedWorkflow.steps[0]; const originalStep = workflowCreated.steps[0]; expect(updatedStep.id).to.be.ok; expect(updatedStep.id).to.be.equal(originalStep.id); }); it('should keep the step id on updated ', async () => { const nameSuffix = `Test Workflow${new Date().toISOString()}`; const workflowCreated: WorkflowResponseDto = await createWorkflowAndValidate(nameSuffix); expect(workflowCreated.steps.length).to.be.equal(2); // Verify that all step ids are unique const stepIds1 = workflowCreated.steps.map((step) => step.id); const uniqueStepIds1 = [...new Set(stepIds1)]; expect(stepIds1.length).to.equal(uniqueStepIds1.length, 'All step ids should be unique on creation'); // Add a step of an existing channel at the beginning of the steps array workflowCreated.steps = [buildInAppStep(), ...workflowCreated.steps] as any; const updatedWorkflow = await updateWorkflow(workflowCreated.id, mapResponseToUpdateDto(workflowCreated)); expect(updatedWorkflow.steps.length).to.be.equal(3); // Verify that all step ids are unique const stepIds2 = workflowCreated.steps.map((step) => step.id); const uniqueStepIds2 = [...new Set(stepIds2)]; expect(stepIds2.length).to.equal(uniqueStepIds2.length, 'All step ids should be unique after update'); }); it('should update user preferences', async () => { const nameSuffix = `Test Workflow${new Date().toISOString()}`; const workflowCreated: WorkflowResponseDto = await createWorkflowAndValidate(nameSuffix); const updatedWorkflow = await updateWorkflow(workflowCreated.id, { ...mapResponseToUpdateDto(workflowCreated), preferences: { user: { ...DEFAULT_WORKFLOW_PREFERENCES, all: { ...DEFAULT_WORKFLOW_PREFERENCES.all, enabled: false } }, }, }); expect(updatedWorkflow.preferences.user, JSON.stringify(updatedWorkflow, null, 2)).to.be.ok; expect(updatedWorkflow.preferences?.user?.all.enabled, JSON.stringify(updatedWorkflow, null, 2)).to.be.false; const updatedWorkflow2 = await updateWorkflow(workflowCreated.id, { ...mapResponseToUpdateDto(workflowCreated), preferences: { user: null, }, }); expect(updatedWorkflow2.preferences.user).to.be.null; expect(updatedWorkflow2.preferences.default).to.be.ok; }); it('should update by slugify ids', async () => { const workflowCreated = await createWorkflowAndValidate(); const { id, workflowId, slug, updatedAt } = workflowCreated; await updateWorkflowAndValidate(id, updatedAt, { ...mapResponseToUpdateDto(workflowCreated), name: 'Test Workflow 1', }); await updateWorkflowAndValidate(workflowId, updatedAt, { ...mapResponseToUpdateDto(workflowCreated), name: 'Test Workflow 2', }); await updateWorkflowAndValidate(slug, updatedAt, { ...mapResponseToUpdateDto(workflowCreated), name: 'Test Workflow 3', }); }); it('should update workflow with payloadSchema and validatePayload fields', async () => { const workflowCreated = await createWorkflowAndValidate(); const payloadSchema = { type: 'object', properties: { email: { type: 'string', format: 'email', }, count: { type: 'number', minimum: 1, }, }, required: ['email'], }; const updateRequest: UpdateWorkflowDto = { ...mapResponseToUpdateDto(workflowCreated), payloadSchema, validatePayload: true, } as UpdateWorkflowDto; const updatedWorkflow = await updateWorkflow(workflowCreated.id, updateRequest); expect(updatedWorkflow).to.be.ok; expect(updatedWorkflow.payloadSchema).to.deep.equal(payloadSchema); expect(updatedWorkflow.validatePayload).to.be.true; }); it('should update workflow to disable payload validation', async () => { const workflowCreated = await createWorkflowAndValidate(); const updateRequest: UpdateWorkflowDto = { ...mapResponseToUpdateDto(workflowCreated), validatePayload: false, } as UpdateWorkflowDto; const updatedWorkflow = await updateWorkflow(workflowCreated.id, updateRequest); expect(updatedWorkflow).to.be.ok; expect(updatedWorkflow.validatePayload).to.be.false; }); }); describe('List workflows', () => { it('should not return workflows with if not matching query', async () => { await createWorkflowAndValidate('XYZ'); await createWorkflowAndValidate('XYZ2'); const workflowSummaries = await getAllAndValidate({ searchQuery: 'ABC', expectedTotalResults: 0, expectedArraySize: 0, }); expect(workflowSummaries).to.be.empty; }); it('should not return workflows if offset is bigger than the amount of available workflows', async () => { await create10Workflows('Test Workflow'); await getAllAndValidate({ searchQuery: 'Test Workflow', offset: 11, limit: 15, expectedTotalResults: 10, expectedArraySize: 0, }); }); it('should return all results within range', async () => { await create10Workflows('Test Workflow'); await getAllAndValidate({ searchQuery: 'Test Workflow', offset: 0, limit: 15, expectedTotalResults: 10, expectedArraySize: 10, }); }); it('should return results without query', async () => { await create10Workflows('Test Workflow'); await getAllAndValidate({ searchQuery: 'Test Workflow', offset: 0, limit: 15, expectedTotalResults: 10, expectedArraySize: 10, }); }); it('paginate workflows without overlap', async () => { await create10Workflows('Test Workflow'); const listWorkflowResponse1 = await getAllAndValidate({ searchQuery: 'Test Workflow', offset: 0, limit: 5, expectedTotalResults: 10, expectedArraySize: 5, }); const listWorkflowResponse2 = await getAllAndValidate({ searchQuery: 'Test Workflow', offset: 5, limit: 5, expectedTotalResults: 10, expectedArraySize: 5, }); const idsDeduplicated = new Set([ ...listWorkflowResponse1.map((workflow) => workflow.id), ...listWorkflowResponse2.map((workflow) => workflow.id), ]); expect(idsDeduplicated.size).to.be.equal(10); }); async function createV0Workflow(id: number) { return await createWorkflowsV1({ name: `Test V0 Workflow${id}`, description: 'This is a test description', tags: ['test-tag-api'], notificationGroupId: session.notificationGroups[0]._id, steps: [], }); } async function searchWorkflowsV0(workflowId?: string) { return await searchWorkflowsV1(workflowId); } async function getV2WorkflowIdAndExternalId(prefix: string) { await create10Workflows(prefix); const listWorkflowResponse: ListWorkflowResponse = await listWorkflows(prefix, 0, 5); const workflowV2Id = listWorkflowResponse.workflows[0].id; const { workflowId } = listWorkflowResponse.workflows[0]; return { workflowV2Id, workflowId, name: listWorkflowResponse.workflows[0].name }; } it('should filter workflows by a single tag', async () => { await createWorkflow(apiClient, buildWorkflow({ name: 'Tagged Workflow 1', tags: ['ai'] })); await createWorkflow(apiClient, buildWorkflow({ name: 'Tagged Workflow 2', tags: ['ai', 'ml'] })); await createWorkflow(apiClient, buildWorkflow({ name: 'Untagged Workflow', tags: ['other'] })); const res = await apiClient.workflows.list({ tags: ['ai'] }); expect(res.result.totalCount).to.equal(2); expect(res.result.workflows).to.have.lengthOf(2); const names = res.result.workflows.map((w) => w.name); expect(names).to.include('Tagged Workflow 1'); expect(names).to.include('Tagged Workflow 2'); }); it('should filter workflows by multiple tags', async () => { await createWorkflow(apiClient, buildWorkflow({ name: 'AI Workflow', tags: ['ai'] })); await createWorkflow(apiClient, buildWorkflow({ name: 'ML Workflow', tags: ['ml'] })); await createWorkflow(apiClient, buildWorkflow({ name: 'Both Tags Workflow', tags: ['ai', 'ml'] })); await createWorkflow(apiClient, buildWorkflow({ name: 'No Match Workflow', tags: ['other'] })); const res = await apiClient.workflows.list({ tags: ['ai', 'ml'] }); expect(res.result.totalCount).to.equal(3); expect(res.result.workflows).to.have.lengthOf(3); const names = res.result.workflows.map((w) => w.name); expect(names).to.include('AI Workflow'); expect(names).to.include('ML Workflow'); expect(names).to.include('Both Tags Workflow'); }); it('should return empty results when filtering by non-existent tag', async () => { await createWorkflow(apiClient, buildWorkflow({ name: 'Some Workflow', tags: ['existing'] })); const res = await apiClient.workflows.list({ tags: ['non-existent'] }); expect(res.result.totalCount).to.equal(0); expect(res.result.workflows).to.have.lengthOf(0); }); it('old list endpoint should not retrieve the new workflow', async () => { const { workflowV2Id, name } = await getV2WorkflowIdAndExternalId('Test Workflow'); const [, , workflowV0Created] = await Promise.all([ createV0Workflow(1), createV0Workflow(2), createV0Workflow(3), ]); let workflowsFromSearch = await searchWorkflowsV0(workflowV0Created?.name); expect(workflowsFromSearch[0]._id).to.deep.eq(workflowV0Created._id); workflowsFromSearch = await searchWorkflowsV0(); const ids = workflowsFromSearch?.map((workflow) => workflow._id); const found = ids?.some((localId) => localId === workflowV2Id); expect(found, `FoundIds:${ids} SearchedID:${workflowV2Id}`).to.be.false; workflowsFromSearch = await searchWorkflowsV0(name); expect(workflowsFromSearch?.length).to.eq(0); }); }); describe('Promote workflow', () => { it('should promote by creating a new workflow in production environment with the same properties', async () => { // Create a workflow in the development environment const createWorkflowDto = buildWorkflow({ name: 'Promote Workflow', steps: [ buildEmailStep({ controlValues: { body: 'Example body', subject: 'Example subject', disableOutputSanitization: false }, }), buildInAppStep({ controlValues: { body: 'Example body' }, }), ], } as CreateWorkflowDto); let devWorkflow = await createWorkflow(apiClient, createWorkflowDto); // Update the workflow name to make sure the workflow identifier is the same after promotion devWorkflow = await updateWorkflow(devWorkflow.id, { ...mapResponseToUpdateDto(devWorkflow), name: `${devWorkflow.name}-updated`, }); devWorkflow = await getWorkflow(devWorkflow.id); // Switch to production environment and get its ID await session.switchToProdEnvironment(); const prodEnvironmentId = session.environment._id; await session.switchToDevEnvironment(); // Promote the workflow to production const prodWorkflow = await syncWorkflow(devWorkflow, prodEnvironmentId); // Verify that the promoted workflow has a new ID but the same workflowId expect(prodWorkflow.id).to.not.equal(devWorkflow.id); expect(prodWorkflow.workflowId).to.equal(devWorkflow.workflowId); // Check that all non-environment-specific properties are identical const propertiesToCompare = ['name', 'description', 'tags', 'preferences', 'status', 'type', 'origin']; propertiesToCompare.forEach((prop) => { expect(prodWorkflow[prop]).to.deep.equal(devWorkflow[prop], `Property ${prop} should match`); }); // Verify that steps are correctly promoted expect(prodWorkflow.steps).to.have.lengthOf(devWorkflow.steps.length); for (const prodStep of prodWorkflow.steps) { const index = prodWorkflow.steps.indexOf(prodStep); const devStep = devWorkflow.steps[index]; expect(prodStep.stepId).to.equal(devStep.stepId, 'Step ID should be the same'); expect(prodStep.controls.values).to.deep.equal(devStep.controls.values, 'Step controlValues should match'); expect(prodStep.name).to.equal(devStep.name, 'Step name should match'); expect(prodStep.type).to.equal(devStep.type, 'Step type should match'); } }); it('should promote by updating an existing workflow in production environment', async () => { // Switch to production environment and get its ID await session.switchToProdEnvironment(); const prodEnvironmentId = session.environment._id; await session.switchToDevEnvironment(); // Create a workflow in the development environment const createWorkflowDto = buildWorkflow({ name: 'Promote Workflow', steps: [ buildEmailStep({ controlValues: { body: 'Example body', subject: 'Example subject', disableOutputSanitization: false, editorType: 'html', }, }), buildInAppStep({ controlValues: { body: 'Example body', disableOutputSanitization: false }, }), ], } as CreateWorkflowDto); const devWorkflow = await createWorkflow(apiClient, createWorkflowDto); // Promote the workflow to production const resPromoteCreate = await apiClient.workflows.sync( { targetEnvironmentId: prodEnvironmentId, }, devWorkflow.id ); const prodWorkflowCreated = resPromoteCreate.result; // Update the workflow in the development environment const updateDto: UpdateWorkflowDto = { ...mapResponseToUpdateDto(devWorkflow), name: 'Updated Name', description: 'Updated Description', // modify existing Email Step, add new InApp Steps, previously existing InApp Step is removed steps: [ { ...buildEmailStep({ controlValues: { body: 'Example body', editorType: 'html', subject: 'Example subject', disableOutputSanitization: false, }, }), id: devWorkflow.steps[0].id, name: 'Updated Email Step', }, { ...buildInAppStep({ controlValues: { body: 'Example body', disableOutputSanitization: false } }), name: 'New InApp Step', }, ], } as UpdateWorkflowDto; await updateWorkflowAndValidate(devWorkflow.id, devWorkflow.updatedAt, updateDto); // Promote the updated workflow to production const resPromoteUpdate = await apiClient.workflows.sync( { targetEnvironmentId: prodEnvironmentId, }, devWorkflow.id ); const prodWorkflowUpdated = resPromoteUpdate.result; // Verify that IDs remain unchanged expect(prodWorkflowUpdated.id).to.equal(prodWorkflowCreated.id); expect(prodWorkflowUpdated.workflowId).to.equal(prodWorkflowCreated.workflowId); // Verify updated properties expect(prodWorkflowUpdated.name).to.equal('Updated Name'); expect(prodWorkflowUpdated.description).to.equal('Updated Description'); // Verify unchanged properties ['status', 'type', 'origin'].forEach((prop) => { expect(prodWorkflowUpdated[prop]).to.deep.equal(prodWorkflowCreated[prop], `Property ${prop} should match`); }); // Verify updated steps expect(prodWorkflowUpdated.steps).to.have.lengthOf(2); expect(prodWorkflowUpdated.steps[0].name).to.equal('Updated Email Step'); expect(prodWorkflowUpdated.steps[0].id).to.equal(prodWorkflowCreated.steps[0].id); expect(prodWorkflowUpdated.steps[0].stepId).to.equal(prodWorkflowCreated.steps[0].stepId); expect(prodWorkflowUpdated.steps[0].controls.values).to.deep.equal({ body: 'Example body', subject: 'Example subject', disableOutputSanitization: false, editorType: 'html', }); // Verify new created step expect(prodWorkflowUpdated.steps[1].name).to.equal('New InApp Step'); expect(prodWorkflowUpdated.steps[1].id).to.not.equal(prodWorkflowCreated.steps[1].id); expect(prodWorkflowUpdated.steps[1].stepId).to.equal('new-in-app-step'); expect(prodWorkflowUpdated.steps[1].controls.values).to.deep.equal({ body: 'Example body', disableOutputSanitization: false, }); }); it('should throw an error if trying to promote to the same environment', async () => { const devWorkflow = await createWorkflowAndValidate('-promote-workflow'); const { error } = await expectSdkExceptionGeneric(() => apiClient.workflows.sync( { targetEnvironmentId: session.environment._id, }, devWorkflow.id ) ); expect(error?.statusCode).to.equal(400); expect(error?.message).to.equal('Cannot sync workflow to the same environment'); }); it('should throw an error if the target environment is not found', async () => { const { error } = await expectSdkExceptionGeneric(() => apiClient.workflows.sync({ targetEnvironmentId: '123' }, '123') ); expect(error?.statusCode).to.equal(404); expect(error?.message).to.equal('Environment 123 not found'); }); it('should throw an error if the workflow to promote is not found', async () => { await session.switchToProdEnvironment(); const prodEnvironmentId = session.environment._id; await session.switchToDevEnvironment(); const { error } = await expectSdkExceptionGeneric(() => apiClient.workflows.sync({ targetEnvironmentId: prodEnvironmentId }, '123') ); expect(error?.statusCode).to.equal(404); expect(error?.message).to.equal('Workflow cannot be found'); expect(error?.ctx?.workflowId).to.equal('123'); }); }); describe('Get workflow', () => { it('should get by slugify ids', async () => { const workflowCreated = await createWorkflowAndValidate('XYZ'); const internalId = workflowCreated.id; const workflowRetrievedByInternalId = await getWorkflow(internalId); expect(workflowRetrievedByInternalId.id).to.equal(internalId); const slugPrefixAndEncodedInternalId = buildSlug(`my-workflow`, ShortIsPrefixEnum.WORKFLOW, internalId); const workflowRetrievedBySlugPrefixAndEncodedInternalId = await getWorkflow(slugPrefixAndEncodedInternalId); expect(workflowRetrievedBySlugPrefixAndEncodedInternalId.id).to.equal(internalId); const workflowIdentifier = workflowCreated.workflowId; const workflowRetrievedByWorkflowIdentifier = await getWorkflow(workflowIdentifier); expect(workflowRetrievedByWorkflowIdentifier.id).to.equal(internalId); }); it('should return 404 if workflow does not exist', async () => { const notExistingId = '123'; const novuRestResult = await expectSdkExceptionGeneric(() => apiClient.workflows.get(notExistingId)); expect(novuRestResult.error).to.be.ok; expect(novuRestResult.error!.statusCode).to.equal(404); expect(novuRestResult.error!.message).to.contain('Workflow'); expect(novuRestResult.error!.ctx?.workflowId).to.contain(notExistingId); }); }); describe('Duplicate workflow', () => { it('should duplicate a workflow', async () => { const workflowCreated = await createWorkflowAndValidate('XYZ'); const duplicatedWorkflow = ( await apiClient.workflows.duplicate( { name: 'Duplicated Workflow', }, workflowCreated.id ) ).result; expect(duplicatedWorkflow?.id).to.not.equal(workflowCreated.id); expect(duplicatedWorkflow?.active).to.be.false; expect(duplicatedWorkflow?.name).to.equal('Duplicated Workflow'); expect(duplicatedWorkflow?.description).to.equal(workflowCreated.description); expect(duplicatedWorkflow?.tags).to.deep.equal(workflowCreated.tags); expect(duplicatedWorkflow?.steps.length).to.equal(workflowCreated.steps.length); duplicatedWorkflow?.steps.forEach((step, index) => { expect(step.name).to.equal(workflowCreated.steps[index].name); expect(step.id).to.not.equal(workflowCreated.steps[index].id); }); expect(duplicatedWorkflow?.preferences).to.deep.equal(workflowCreated.preferences); }); it('should duplicate a workflow with overrides', async () => { const workflowCreated = await createWorkflowAndValidate('XYZ'); const duplicatedWorkflow = ( await apiClient.workflows.duplicate( { name: 'Duplicated Workflow', tags: ['tag1', 'tag2'], description: 'New Description', }, workflowCreated.id ) ).result; expect(duplicatedWorkflow?.id).to.not.equal(workflowCreated.id); expect(duplicatedWorkflow?.active).to.be.false; expect(duplicatedWorkflow?.name).to.equal('Duplicated Workflow'); expect(duplicatedWorkflow?.description).to.equal('New Description'); expect(duplicatedWorkflow?.tags).to.deep.equal(['tag1', 'tag2']); }); it('should throw an error if the workflow to duplicate is not found', async () => { const res = await expectSdkExceptionGeneric(() => apiClient.workflows.duplicate({ name: 'Duplicated Workflow' }, '123') ); expect(res.error).to.be.ok; expect(res.error!.statusCode).to.equal(404); expect(res.error!.message).to.contain('Workflow'); expect(res.error!.ctx?.workflowId).to.contain('123'); }); it('should duplicate a workflow with payloadSchema, validatePayload, and severity', async () => { const payloadSchema = { type: 'object', properties: { name: { type: 'string' }, email: { type: 'string' }, }, required: ['name'], }; const createWorkflowDto: CreateWorkflowDto = buildWorkflow({ name: 'Test Workflow with Schema', payloadSchema, validatePayload: true, }); const workflowCreated = await createWorkflow(apiClient, createWorkflowDto); const duplicatedWorkflow = ( await apiClient.workflows.duplicate( { name: 'Duplicated Workflow with Schema', }, workflowCreated.id ) ).result; expect(duplicatedWorkflow?.id).to.not.equal(workflowCreated.id); expect(duplicatedWorkflow?.payloadSchema).to.deep.equal(payloadSchema); expect(duplicatedWorkflow?.validatePayload).to.equal(true); expect(duplicatedWorkflow?.severity).to.equal(workflowCreated.severity); }); }); describe('Get step data', () => { it('should get step by worflow slugify ids', async () => { const workflowCreated = await createWorkflowAndValidate('XYZ'); const internalWorkflowId = workflowCreated.id; const stepId = workflowCreated.steps[0].id; const stepRetrievedByWorkflowInternalId = await getStepData(internalWorkflowId, stepId); expect(stepRetrievedByWorkflowInternalId.id).to.equal(stepId); const slugPrefixAndEncodedWorkflowInternalId = buildSlug( `my-workflow`, ShortIsPrefixEnum.WORKFLOW, internalWorkflowId ); const stepRetrievedBySlugPrefixAndEncodedWorkflowInternalId = await getStepData( slugPrefixAndEncodedWorkflowInternalId, stepId ); expect(stepRetrievedBySlugPrefixAndEncodedWorkflowInternalId.id).to.equal(stepId); const workflowIdentifier = workflowCreated.workflowId; const stepRetrievedByWorkflowIdentifier = await getStepData(workflowIdentifier, stepId); expect(stepRetrievedByWorkflowIdentifier.id).to.equal(stepId); }); it('should get step by step slugify ids', async () => { const workflowCreated = await createWorkflowAndValidate('XYZ'); const internalWorkflowId = workflowCreated.id; const stepId = workflowCreated.steps[0].id; const stepRetrievedByStepInternalId = await getStepData(internalWorkflowId, stepId); expect(stepRetrievedByStepInternalId.id).to.equal(stepId); const slugPrefixAndEncodedStepId = buildSlug(`my-step`, ShortIsPrefixEnum.STEP, stepId); const stepRetrievedBySlugPrefixAndEncodedStepId = await getStepData( internalWorkflowId, slugPrefixAndEncodedStepId ); expect(stepRetrievedBySlugPrefixAndEncodedStepId.id).to.equal(stepId); const stepIdentifier = workflowCreated.steps[0].stepId; const stepRetrievedByStepIdentifier = await getStepData(internalWorkflowId, stepIdentifier); expect(stepRetrievedByStepIdentifier.id).to.equal(stepId); }); describe('Variables', () => { it('should get step available variables', async () => { const steps = [ { ...buildEmailStep(), controlValues: { body: 'Welcome to our newsletter {{subscriber.nonExistentValue}}{{payload.prefixBodyText2}}{{payload.prefixBodyText}}', editorType: 'html', subject: 'Welcome to our newsletter {{subjectText}} {{payload.prefixSubjectText}}', }, }, { ...buildInAppStep(), controlValues: { subject: 'Welcome to our newsletter {{inAppSubjectText}}' } }, ]; const createWorkflowDto: CreateWorkflowDto = buildWorkflow({ steps: steps as UpdateWorkflowDtoSteps[], payloadSchema: { type: 'object', properties: { prefixBodyText2: { type: 'string' }, prefixBodyText: { type: 'string' }, prefixSubjectText: { type: 'string' }, }, required: [], additionalProperties: false, }, }); const res = await createWorkflow(apiClient, createWorkflowDto); const stepData = await getStepData(res.id, res.steps[0].id); const { variables } = stepData; if (typeof variables === 'boolean') throw new Error('Variables is not an object'); const { properties } = variables; expect(properties).to.be.ok; if (!properties) throw new Error('Payload schema is not valid'); const payloadVariables = properties.payload; expect(payloadVariables).to.be.ok; if (!payloadVariables) throw new Error('Payload schema is not valid'); expect(JSON.stringify(payloadVariables)).to.contain('prefixBodyText2'); expect(JSON.stringify(payloadVariables)).to.contain('prefixSubjectText'); }); it('should serve previous step variables with payload schema', async () => { const steps = [ buildDigestStep(), { ...buildInAppStep(), controlValues: { subject: 'Welcome to our newsletter {{payload.inAppSubjectText}}' } }, ]; const createWorkflowDto: CreateWorkflowDto = buildWorkflow({ steps: steps as UpdateWorkflowDtoSteps[], payloadSchema: { type: 'object', properties: { inAppSubjectText: { type: 'string' }, }, required: [], additionalProperties: false, }, }); const res = await createWorkflow(apiClient, createWorkflowDto); const novuRestResult = await apiClient.workflows.steps.retrieve(res.id, res.steps[1].id); const { variables } = novuRestResult.result; const variableList = getJsonSchemaPrimitiveProperties(variables as JSONSchemaDto); const hasStepVariables = variableList.some((variable) => variable.startsWith('steps.')); expect(hasStepVariables, JSON.stringify(variableList)).to.be.true; }); }); }); describe('Patch workflow', () => { it('should work and allow us to turn workflow active on / off and have the status change accordingly', async () => { const workflowDto = await createWorkflow(apiClient, buildWorkflow()); let updatedWorkflow = await patchWorkflow(workflowDto.id, false); expect(updatedWorkflow.status).to.equal(WorkflowStatusEnum.Inactive); updatedWorkflow = await patchWorkflow(workflowDto.id, true); expect(updatedWorkflow.status).to.equal(WorkflowStatusEnum.Active); }); }); describe('Delete workflow', () => { it('should delete a workflow', async () => { const { id, workflowId } = await createWorkflowAndValidate(); await apiClient.workflows.delete(workflowId); const { error, successfulBody } = await expectSdkExceptionGeneric(() => apiClient.workflows.delete(workflowId)); expect(error).to.be.ok; expect(error?.statusCode).to.equal(404); const preferencesRepository = new PreferencesRepository(); const preferences = await preferencesRepository.find({ _templateId: id, _organizationId: session.organization._id, }); expect(preferences.length).to.equal(0); }); }); describe('Error handling', () => { it('should show status ok when no problems', async () => { const workflowCreated = await createWorkflowAndValidate(); await getWorkflowAndValidate(workflowCreated); }); describe('workflow validation issues', () => { it('should respond with 400 when name is empty', async () => { const createWorkflowDto: CreateWorkflowDto = buildWorkflow({ name: '' }); await createWorkflowAndExpectValidationError( apiClient, createWorkflowDto, 'name must be longer than or equal to 1 characters' ); }); it('should respond with 400 when name is too long', async () => { const createWorkflowDto: CreateWorkflowDto = buildWorkflow({ name: Array.from({ length: 80 }).join('X'), }); await createWorkflowAndExpectValidationError( apiClient, createWorkflowDto, 'name must be shorter than or equal to 64 characters' ); }); it('should respond with 400 when description is too long', async () => { const createWorkflowDto: CreateWorkflowDto = buildWorkflow({ description: Array.from({ length: 260 }).join('X'), }); await createWorkflowAndExpectValidationError( apiClient, createWorkflowDto, 'description must be shorter than or equal to 256 characters' ); }); it('should respond with 400 when description is too long on an update call', async () => { const createWorkflowDto = buildWorkflow(); const res = await createWorkflow(apiClient, createWorkflowDto); const updateWorkflowDto: UpdateWorkflowDto = { ...mapResponseToUpdateDto(res), description: Array.from({ length: 260 }).join('X'), }; const errorResult = await expectSdkValidationExceptionGeneric(() => apiClient.workflows.update(updateWorkflowDto, res.id) ); expect(errorResult.error).to.be.ok; expect(JSON.stringify(errorResult.error?.errors), JSON.stringify(errorResult.error)).to.include( 'description must be shorter than or equal to 256 characters' ); }); it('should respond with 400 when a tag is too long', async () => { const createWorkflowDto: CreateWorkflowDto = buildWorkflow({ tags: ['tag1', Array.from({ length: 70 }).join('X')], }); await createWorkflowAndExpectValidationError( apiClient, createWorkflowDto, 'each value in tags must be longer than or equal to 1 and shorter than or equal to 64 characters' ); }); it('should respond with 400 when a tag is empty', async () => { const createWorkflowDto: CreateWorkflowDto = buildWorkflow({ tags: ['tag1', ''], }); await createWorkflowAndExpectValidationError( apiClient, createWorkflowDto, 'each value in tags must be longer than or equal to 1 and shorter than or equal to 64 characters' ); }); it('should respond with 400 when a duplicate tag is provided', async () => { const createWorkflowDto: CreateWorkflowDto = buildWorkflow({ tags: ['tag1', 'tag1'], }); await createWorkflowAndExpectValidationError( apiClient, createWorkflowDto, "All tags's elements must be unique" ); }); it('should respond with 400 when more than 16 tags are provided', async () => { const createWorkflowDto: CreateWorkflowDto = buildWorkflow({ tags: Array.from({ length: 17 }).map((_, index) => `tag${index}`), }); await createWorkflowAndExpectValidationError( apiClient, createWorkflowDto, 'tags must contain no more than 16 elements' ); }); }); describe('steps validation', () => { it('should throw 400 when name is empty', async () => { // @ts-expect-error const overrideDto = { steps: [{ ...buildEmailStep(), name: '' } as unknown as StepUpsertDto] }; const createWorkflowDto: CreateWorkflowDto = buildWorkflow(); const dtoWithoutName = { ...createWorkflowDto, ...overrideDto }; await createWorkflowAndExpectValidationError(apiClient, dtoWithoutName, 'name'); }); describe('step control issues', () => { it('should return issues for all steps immediately', async () => { const createWorkflowDto: CreateWorkflowDto = buildWorkflow({ steps: [ { name: 'In-App Test Step', type: StepTypeEnum.IN_APP, controlValues: { // body is missing on purpose redirect: { url: 'not-good-url-please-replace', target: '_blank' }, primaryAction: { label: 'primary', redirect: { url: 'not-good-url-please-replace', target: '_blank' }, }, secondaryAction: { label: 'secondary', redirect: { url: 'not-good-url-please-replace', target: '_blank' }, }, }, }, ], }); const createdWorkflow = await createWorkflow(apiClient, createWorkflowDto); const stepData = await getStepData(createdWorkflow!.id, createdWorkflow!.steps[0].id); expect(stepData.issues!.controls!.body).to.eql([ { message: 'Subject or body is required', issueType: 'MISSING_VALUE', variableName: 'body' }, ]); expect(stepData.issues!.controls!['redirect.url'][0].issueType, 'redirect.url').to.equal('INVALID_URL'); expect( stepData.issues!.controls!['primaryAction.redirect.url'][0].issueType, 'primaryAction.redirect.url' ).to.equal('INVALID_URL'); expect( stepData.issues!.controls!['secondaryAction.redirect.url'][0].issueType, 'secondaryAction.redirect.url' ).to.equal('INVALID_URL'); }); it('should always show digest control value issues when illegal value provided', async () => { const steps = [{ ...buildDigestStep({ controlValues: { amount: 555, unit: 'days' } }) }]; const workflowCreated = await createWorkflow(apiClient, buildWorkflow({ steps } as CreateWorkflowDto)); const step = workflowCreated.steps[0]; expect(step.issues?.controls?.amount[0].issueType).to.deep.equal(ContentIssueEnum.TierLimitExceeded); expect(step.issues?.controls?.unit[0].issueType).to.deep.equal(ContentIssueEnum.TierLimitExceeded); }); it('should always show issues for illegal variables in control values', async () => { const createWorkflowDto: CreateWorkflowDto = buildWorkflow({ steps: [ { name: 'Email Test Step', type: StepTypeEnum.EMAIL, controlValues: { body: 'Welcome {{}}', subject: 'Welcome {{}}' }, }, ], }); const workflow = await createWorkflow(apiClient, createWorkflowDto); const stepData = await getStepData(workflow.id, workflow.steps[0].id); expect(stepData.issues, 'Step data should have issues').to.exist; expect(stepData.issues?.controls?.body, 'Step data should have body issues').to.exist; expect(stepData.issues?.controls?.body?.[0]?.variableName).to.equal('{{}}'); expect(stepData.issues?.controls?.body?.[0]?.issueType).to.equal('ILLEGAL_VARIABLE_IN_CONTROL_VALUE'); }); }); }); }); async function getWorkflow(id: string): Promise { const res = await apiClient.workflows.get(id); return res.result; } async function patchWorkflow(workflowId: string, active: boolean) { const res = await apiClient.workflows.patch( { active, }, workflowId ); return res.result; } async function updateWorkflow(id: string, workflow: UpdateWorkflowDto): Promise { const res = await apiClient.workflows.update(workflow, id); return res.result; } async function syncWorkflow(devWorkflow: WorkflowResponseDto, prodEnvironmentId: string) { const res = await apiClient.workflows.sync( { targetEnvironmentId: prodEnvironmentId, }, devWorkflow.id ); return res.result; } async function getStepData(workflowId: string, stepId: string, envId?: string) { const novuRestResult = await apiClient.workflows.steps.retrieve(workflowId, stepId, undefined, { fetchOptions: { headers: buildHeaders(envId) }, }); return novuRestResult.result; } async function updateWorkflowAndValidate( workflowRequestId: string, expectedPastUpdatedAt: string, updateRequest: UpdateWorkflowDto ): Promise { const updatedWorkflow: WorkflowResponseDto = await updateWorkflow(workflowRequestId, updateRequest); const slug = buildSlug(updateRequest.name, ShortIsPrefixEnum.WORKFLOW, updatedWorkflow.id); expect(updatedWorkflow.slug).to.equal(slug); for (let i = 0; i < updateRequest.steps.length; i++) { const stepInRequest = updateRequest.steps[i]; expect(stepInRequest.name).to.equal(updatedWorkflow.steps[i].name); expect(stepInRequest.type).to.equal(updatedWorkflow.steps[i].type); if (stepInRequest.controlValues) { expect(stepInRequest.controlValues).to.deep.equal(updatedWorkflow.steps[i].controls.values); } if ('id' in stepInRequest) { expect(buildSlug(stepInRequest.name, ShortIsPrefixEnum.STEP, stepInRequest.id!)).to.equal( updatedWorkflow.steps[i].slug ); } } expect(new Date(updatedWorkflow.updatedAt)).to.be.greaterThan(new Date(expectedPastUpdatedAt)); } async function create10Workflows(prefix: string = 'Test Workflow') { for (let i = 0; i < 10; i++) { await createWorkflowAndValidate(`${prefix}-${i}`); } } async function createWorkflowAndValidate(name: string = 'Test Workflow'): Promise { const createWorkflowDto: CreateWorkflowDto = buildWorkflow({ name }); const res = await createWorkflow(apiClient, createWorkflowDto); validateCreateWorkflowResponse(res, createWorkflowDto); return res; } async function getWorkflowAndValidate(workflowCreated: WorkflowResponseDto) { const workflowRetrieved = await getWorkflow(workflowCreated.id); expect(workflowRetrieved).to.deep.equal(workflowCreated); } async function listWorkflows(query: string, offset: number, limit: number): Promise { return (await apiClient.workflows.list({ query, offset, limit })).result; } async function getAllAndValidate({ msgPrefix = '', searchQuery = '', offset = 0, limit = 50, expectedTotalResults, expectedArraySize, }: { msgPrefix?: string; searchQuery: string; offset?: number; limit?: number; expectedTotalResults: number; expectedArraySize: number; }): Promise { const listWorkflowResponse: ListWorkflowResponse = await listWorkflows(searchQuery, offset, limit); expect(listWorkflowResponse.workflows).lengthOf(expectedArraySize); expect(listWorkflowResponse.totalCount).to.be.equal(expectedTotalResults); return listWorkflowResponse.workflows; } function stringify(obj: unknown) { return JSON.stringify(obj, null, 2); } function mapResponseToUpdateDto(workflowResponse: WorkflowResponseDto): UpdateWorkflowDto { return { ...workflowResponse, steps: workflowResponse.steps.map( (step) => ({ id: step.id, type: step.type, name: step.name, controlValues: step.controls?.values || {}, }) as UpdateWorkflowDtoSteps ), }; } function assertWorkflowResponseBodyData(workflowResponseDto: WorkflowResponseDto) { expect(workflowResponseDto, stringify(workflowResponseDto)).to.be.ok; expect(workflowResponseDto.id, stringify(workflowResponseDto)).to.be.ok; expect(workflowResponseDto.updatedAt, stringify(workflowResponseDto)).to.be.ok; expect(workflowResponseDto.createdAt, stringify(workflowResponseDto)).to.be.ok; expect(workflowResponseDto.preferences, stringify(workflowResponseDto)).to.be.ok; expect(workflowResponseDto.status, stringify(workflowResponseDto)).to.be.ok; expect(workflowResponseDto.origin, stringify(workflowResponseDto)).to.be.eq(ResourceOriginEnum.NovuCloud); expect(Object.keys(workflowResponseDto.issues || {}).length, stringify(workflowResponseDto)).to.be.equal(0); } function assertStepResponse(workflowResponseDto: WorkflowResponseDto, createWorkflowDto: CreateWorkflowDto) { for (let i = 0; i < workflowResponseDto.steps.length; i++) { const stepInRequest = createWorkflowDto.steps[i]; const step = workflowResponseDto.steps[i]; expect(step.id, stringify(step)).to.be.ok; expect(step.slug, stringify(step)).to.be.ok; expect(step.name, stringify(step)).to.be.equal(stepInRequest.name); expect(step.type, stringify(step)).to.be.equal(stepInRequest.type); } } function validateCreateWorkflowResponse( workflowResponseDto: WorkflowResponseDto, createWorkflowDto: CreateWorkflowDto ) { assertWorkflowResponseBodyData(workflowResponseDto); assertStepResponse(workflowResponseDto, createWorkflowDto); } function getJsonSchemaPrimitiveProperties(schema: JSONSchemaDto, prefix: string = ''): string[] { if (!isJSONSchemaDto(schema)) { return []; } let properties: string[] = []; // Check if the schema has properties if (schema.properties) { for (const key in schema.properties) { const propertySchema = schema.properties[key]; if (!isJSONSchemaDto(propertySchema)) { continue; } const propertyPath = prefix ? `${prefix}.${key}` : key; // Check if the property type is primitive if (isPrimitiveType(propertySchema)) { properties.push(propertyPath); } else { // If not primitive, recurse into the object properties = properties.concat(getJsonSchemaPrimitiveProperties(propertySchema, propertyPath)); } } } // Check if the schema has items (for arrays) if (schema.items && isJSONSchemaDto(schema.items)) { // Assuming items is an object schema, we can treat it like a property if (isPrimitiveType(schema.items)) { properties.push(prefix); // If items are primitive, add the array itself } else { properties = properties.concat(getJsonSchemaPrimitiveProperties(schema.items, prefix)); } } return properties; } function isJSONSchemaDto(obj: any): obj is JSONSchemaDto { // Check if the object has a 'type' property and is of type 'string' return typeof obj === 'object' && obj !== null && typeof obj.type === 'string'; } function isPrimitiveType(schema: JSONSchemaDto): boolean { const primitiveTypes = ['string', 'number', 'boolean', 'null']; return primitiveTypes.includes((schema.type && (schema.type as string)) || ''); } }); const createWorkflowsV1 = async (templateBody: { name: string; description: string; tags: string[]; notificationGroupId: string; steps: any[]; }): Promise<{ _id: string; name: string }> => { const res = await session.testAgent.post(`/v1/workflows`).send({ name: templateBody.name, description: templateBody.description, tags: templateBody.tags, notificationGroupId: templateBody.notificationGroupId, steps: templateBody.steps, }); expect(res.status).to.equal(201); return res.body.data; }; const searchWorkflowsV1 = async (queryParams?: string): Promise<{ _id: string }[]> => { const query = new URLSearchParams(); query.append('defaultLimit', '10'); query.append('maxLimit', '50'); if (queryParams) { query.append('query', queryParams); } const res = await session.testAgent.get(`/v1/workflows?${query.toString()}`); expect(res.status).to.equal(200); return res.body.data; }; ================================================ FILE: apps/api/src/app/workflows-v2/workflow.controller.ts ================================================ import { ClassSerializerInterceptor, HttpStatus, Patch } from '@nestjs/common'; import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put, Query, UseInterceptors, } from '@nestjs/common/decorators'; import { ApiBody, ApiExcludeEndpoint, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; import { BuildStepDataCommand, BuildStepDataUsecase, ExternalApiAccessible, GeneratePreviewRequestDto, GeneratePreviewResponseDto, GetWorkflowCommand, GetWorkflowUseCase, ParseSlugEnvironmentIdPipe, ParseSlugIdPipe, PreviewCommand, PreviewUsecase, RequirePermissions, StepResponseDto, UpsertStepDataCommand, UpsertWorkflowCommand, UpsertWorkflowUseCase, UserSession, WorkflowResponseDto, } from '@novu/application-generic'; import { ApiRateLimitCategoryEnum, DirectionEnum, PermissionsEnum, ResourceOriginEnum, UserSessionData, } from '@novu/shared'; import { RequireAuthentication } from '../auth/framework/auth.decorator'; import { ThrottlerCategory } from '../rate-limiting/guards/throttler.decorator'; import { ApiCommonResponses, ApiResponse } from '../shared/framework/response.decorator'; import { SdkGroupName, SdkMethodName } from '../shared/framework/swagger/sdk.decorators'; import { DeleteWorkflowCommand } from '../workflows-v1/usecases/delete-workflow/delete-workflow.command'; import { DeleteWorkflowUseCase } from '../workflows-v1/usecases/delete-workflow/delete-workflow.usecase'; import { CreateWorkflowDto, DuplicateWorkflowDto, GetListQueryParamsDto, ListWorkflowResponse, PatchWorkflowDto, StepUpsertDto, SyncWorkflowDto, TestHttpEndpointRequestDto, TestHttpEndpointResponseDto, UpdateWorkflowDto, WorkflowTestDataResponseDto, } from './dtos'; import { BuildWorkflowTestDataUseCase, DuplicateWorkflowCommand, DuplicateWorkflowUseCase, ListWorkflowsCommand, ListWorkflowsUseCase, SyncToEnvironmentCommand, SyncToEnvironmentUseCase, TestHttpEndpointCommand, TestHttpEndpointUsecase, WorkflowTestDataCommand, } from './usecases'; import { PatchWorkflowCommand, PatchWorkflowUsecase } from './usecases/patch-workflow'; @ThrottlerCategory(ApiRateLimitCategoryEnum.CONFIGURATION) @ApiCommonResponses() @Controller({ path: `/workflows`, version: '2' }) @UseInterceptors(ClassSerializerInterceptor) @RequireAuthentication() @ApiTags('Workflows') export class WorkflowController { constructor( private upsertWorkflowUseCase: UpsertWorkflowUseCase, private getWorkflowUseCase: GetWorkflowUseCase, private listWorkflowsUseCase: ListWorkflowsUseCase, private deleteWorkflowUsecase: DeleteWorkflowUseCase, private syncToEnvironmentUseCase: SyncToEnvironmentUseCase, private previewUsecase: PreviewUsecase, private buildWorkflowTestDataUseCase: BuildWorkflowTestDataUseCase, private buildStepDataUsecase: BuildStepDataUsecase, private patchWorkflowUsecase: PatchWorkflowUsecase, private duplicateWorkflowUseCase: DuplicateWorkflowUseCase, private testHttpEndpointUsecase: TestHttpEndpointUsecase ) {} @Post('') @ApiOperation({ summary: 'Create a workflow', description: 'Creates a new workflow in the Novu Cloud environment', }) @ExternalApiAccessible() @ApiBody({ type: CreateWorkflowDto, description: 'Workflow creation details' }) @ApiResponse(WorkflowResponseDto, 201) @RequirePermissions(PermissionsEnum.WORKFLOW_WRITE) async create( @UserSession(ParseSlugEnvironmentIdPipe) user: UserSessionData, @Body() createWorkflowDto: CreateWorkflowDto ): Promise { const upsertSteps = this.normalizeSteps(createWorkflowDto.steps); return this.upsertWorkflowUseCase.execute( UpsertWorkflowCommand.create({ preserveWorkflowId: true, workflowDto: { ...createWorkflowDto, steps: upsertSteps, origin: ResourceOriginEnum.NOVU_CLOUD, }, user, }) ); } @Put(':workflowId/sync') @ExternalApiAccessible() @ApiOperation({ summary: 'Sync a workflow', description: 'Synchronizes a workflow to the target environment', }) @ApiBody({ type: SyncWorkflowDto, description: 'Sync workflow details' }) @ApiResponse(WorkflowResponseDto) @SdkMethodName('sync') @RequirePermissions(PermissionsEnum.WORKFLOW_WRITE) async sync( @UserSession() user: UserSessionData, @Param('workflowId', ParseSlugIdPipe) workflowIdOrInternalId: string, @Body() syncWorkflowDto: SyncWorkflowDto ): Promise { return this.syncToEnvironmentUseCase.execute( SyncToEnvironmentCommand.create({ user, workflowIdOrInternalId, targetEnvironmentId: syncWorkflowDto.targetEnvironmentId, }) ); } @Put(':workflowId') @ExternalApiAccessible() @ApiOperation({ summary: 'Update a workflow', description: 'Updates the details of an existing workflow, here **workflowId** is the identifier of the workflow', }) @ApiBody({ type: UpdateWorkflowDto, description: 'Workflow update details' }) @ApiResponse(WorkflowResponseDto) @RequirePermissions(PermissionsEnum.WORKFLOW_WRITE) async update( @UserSession(ParseSlugEnvironmentIdPipe) user: UserSessionData, @Param('workflowId', ParseSlugIdPipe) workflowIdOrInternalId: string, @Body() updateWorkflowDto: UpdateWorkflowDto ): Promise { const upsertSteps = this.normalizeSteps(updateWorkflowDto.steps); return await this.upsertWorkflowUseCase.execute( UpsertWorkflowCommand.create({ workflowDto: { ...updateWorkflowDto, steps: upsertSteps, }, user, workflowIdOrInternalId, }) ); } private normalizeSteps(steps: StepUpsertDto[]): UpsertStepDataCommand[] { return steps.map((step: StepUpsertDto) => ({ ...step, controlValues: (step.controlValues as Record | null | undefined) ?? null, })); } @Get(':workflowId') @ExternalApiAccessible() @ApiOperation({ summary: 'Retrieve a workflow', description: 'Fetches details of a specific workflow by its unique identifier **workflowId**', }) @ApiResponse(WorkflowResponseDto) @ApiQuery({ name: 'environmentId', type: String, required: false, }) @SdkMethodName('get') @RequirePermissions(PermissionsEnum.WORKFLOW_READ) async getWorkflow( @UserSession(ParseSlugEnvironmentIdPipe) user: UserSessionData, @Param('workflowId', ParseSlugIdPipe) workflowIdOrInternalId: string, @Query('environmentId') environmentId?: string ): Promise { return this.getWorkflowUseCase.execute( GetWorkflowCommand.create({ workflowIdOrInternalId, user, environmentId, }) ); } @Delete(':workflowId') @ExternalApiAccessible() @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Delete a workflow', description: 'Removes a specific workflow by its unique identifier **workflowId**', }) @SdkMethodName('delete') @RequirePermissions(PermissionsEnum.WORKFLOW_WRITE) async removeWorkflow( @UserSession(ParseSlugEnvironmentIdPipe) user: UserSessionData, @Param('workflowId', ParseSlugIdPipe) workflowIdOrInternalId: string ) { await this.deleteWorkflowUsecase.execute( DeleteWorkflowCommand.create({ workflowIdOrInternalId, environmentId: user.environmentId, organizationId: user.organizationId, userId: user._id, }) ); } @Get('') @ExternalApiAccessible() @ApiOperation({ summary: 'List all workflows', description: 'Retrieves a list of workflows with optional filtering and pagination', }) @ApiResponse(ListWorkflowResponse) @SdkMethodName('list') @RequirePermissions(PermissionsEnum.WORKFLOW_READ) async searchWorkflows( @UserSession(ParseSlugEnvironmentIdPipe) user: UserSessionData, @Query() query: GetListQueryParamsDto ): Promise { return this.listWorkflowsUseCase.execute( ListWorkflowsCommand.create({ offset: Number(query.offset || '0'), limit: Number(query.limit || '50'), orderDirection: query.orderDirection ?? DirectionEnum.DESC, orderBy: query.orderBy ?? 'createdAt', searchQuery: query.query, tags: query.tags, status: query.status, user, }) ); } @Post(':workflowId/duplicate') @ApiOperation({ summary: 'Duplicate a workflow', description: 'Duplicates a workflow by its unique identifier **workflowId**. This will create a new workflow with the same steps and settings.', }) @ApiBody({ type: DuplicateWorkflowDto }) @ApiResponse(WorkflowResponseDto, 201) @SdkMethodName('duplicate') @RequirePermissions(PermissionsEnum.WORKFLOW_WRITE) async duplicateWorkflow( @UserSession(ParseSlugEnvironmentIdPipe) user: UserSessionData, @Param('workflowId', ParseSlugIdPipe) workflowIdOrInternalId: string, @Body() duplicateWorkflowDto: DuplicateWorkflowDto ): Promise { return this.duplicateWorkflowUseCase.execute( DuplicateWorkflowCommand.create({ user, workflowIdOrInternalId, overrides: duplicateWorkflowDto, }) ); } @Post('/:workflowId/step/:stepId/preview') @ExternalApiAccessible() @ApiOperation({ summary: 'Generate step preview', description: 'Generates a preview for a specific workflow step by its unique identifier **stepId**', }) @ApiBody({ type: GeneratePreviewRequestDto, description: 'Preview generation details' }) @ApiResponse(GeneratePreviewResponseDto, 201) @SdkGroupName('Workflows.Steps') @SdkMethodName('generatePreview') @RequirePermissions(PermissionsEnum.WORKFLOW_READ) async generatePreview( @UserSession(ParseSlugEnvironmentIdPipe) user: UserSessionData, @Param('workflowId', ParseSlugIdPipe) workflowIdOrInternalId: string, @Param('stepId', ParseSlugIdPipe) stepIdOrInternalId: string, @Body() generatePreviewRequestDto: GeneratePreviewRequestDto ): Promise { return await this.previewUsecase.execute( PreviewCommand.create({ user, workflowIdOrInternalId, stepIdOrInternalId, generatePreviewRequestDto, }) ); } @Post('/steps/test-http-request') @ApiOperation({ summary: 'Test HTTP request step', description: 'Executes the configured HTTP request for a step, resolving template variables using the provided preview payload', }) @ApiBody({ type: TestHttpEndpointRequestDto, description: 'Control values and preview payload for variable resolution', }) @ApiResponse(TestHttpEndpointResponseDto, 201) @ApiExcludeEndpoint() @RequirePermissions(PermissionsEnum.WORKFLOW_WRITE) async testHttpEndpoint( @UserSession(ParseSlugEnvironmentIdPipe) user: UserSessionData, @Body() body: TestHttpEndpointRequestDto ): Promise { return this.testHttpEndpointUsecase.execute( TestHttpEndpointCommand.create({ user, controlValues: body.controlValues, previewPayload: body.previewPayload, }) ); } @Get('/:workflowId/steps/:stepId') @ApiOperation({ summary: 'Retrieve workflow step', description: 'Retrieves data for a specific step in a workflow', }) @ApiResponse(StepResponseDto) @ExternalApiAccessible() @SdkGroupName('Workflows.Steps') @SdkMethodName('retrieve') @RequirePermissions(PermissionsEnum.WORKFLOW_READ) async getWorkflowStepData( @UserSession(ParseSlugEnvironmentIdPipe) user: UserSessionData, @Param('workflowId', ParseSlugIdPipe) workflowIdOrInternalId: string, @Param('stepId', ParseSlugIdPipe) stepIdOrInternalId: string ): Promise { return await this.buildStepDataUsecase.execute( BuildStepDataCommand.create({ user, workflowIdOrInternalId, stepIdOrInternalId }) ); } @Patch('/:workflowId') @ExternalApiAccessible() @ApiOperation({ summary: 'Update a workflow', description: 'Partially updates a workflow by its unique identifier **workflowId**', }) @ApiBody({ type: PatchWorkflowDto, description: 'Workflow patch details' }) @ApiResponse(WorkflowResponseDto) @SdkMethodName('patch') @RequirePermissions(PermissionsEnum.WORKFLOW_WRITE) async patchWorkflow( @UserSession(ParseSlugEnvironmentIdPipe) user: UserSessionData, @Param('workflowId', ParseSlugIdPipe) workflowIdOrInternalId: string, @Body() patchWorkflowDto: PatchWorkflowDto ): Promise { return await this.patchWorkflowUsecase.execute( PatchWorkflowCommand.create({ user, workflowIdOrInternalId, ...patchWorkflowDto }) ); } @Get('/:workflowId/test-data') @ApiOperation({ summary: 'Retrieve workflow test data', description: 'Retrieves test data for a specific workflow by its unique identifier **workflowId**', }) @ApiResponse(WorkflowTestDataResponseDto) @SdkMethodName('getTestData') @RequirePermissions(PermissionsEnum.WORKFLOW_READ) @ApiExcludeEndpoint() async getWorkflowTestData( @UserSession() user: UserSessionData, @Param('workflowId', ParseSlugIdPipe) workflowIdOrInternalId: string ): Promise { return this.buildWorkflowTestDataUseCase.execute( WorkflowTestDataCommand.create({ workflowIdOrInternalId, user, }) ); } } ================================================ FILE: apps/api/src/app/workflows-v2/workflow.module.ts ================================================ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { BuildStepDataUsecase, BuildStepIssuesUsecase, BuildVariableSchemaUsecase, ControlValueSanitizerService, CreateVariablesObject, CreateWorkflowV0, DeletePreferencesUseCase, GetPreferences, GetWorkflowByIdsUseCase, GetWorkflowUseCase, GetWorkflowWithPreferencesUseCase, MockDataGeneratorService, PayloadMergerService, PreviewErrorHandler, PreviewPayloadProcessorService, PreviewUsecase, ResourceValidatorService, TierRestrictionsValidateUsecase, UpdateWorkflowV0, UpsertControlValuesUseCase, UpsertPreferences, UpsertWorkflowUseCase, } from '@novu/application-generic'; import { CommunityOrganizationRepository } from '@novu/dal'; import { AuthModule } from '../auth/auth.module'; import { BridgeModule } from '../bridge'; import { ChangeModule } from '../change/change.module'; import { IntegrationModule } from '../integrations/integrations.module'; import { LayoutsV2Module } from '../layouts-v2/layouts.module'; import { MessageTemplateModule } from '../message-template/message-template.module'; import { OutboundWebhooksModule } from '../outbound-webhooks/outbound-webhooks.module'; import { SharedModule } from '../shared/shared.module'; import { StepResolversModule } from '../step-resolvers/step-resolvers.module'; import { DeleteWorkflowUseCase } from '../workflows-v1/usecases/delete-workflow/delete-workflow.usecase'; import { BuildWorkflowTestDataUseCase, ListWorkflowsUseCase, SyncToEnvironmentUseCase, TestHttpEndpointUsecase, } from './usecases'; import { DuplicateWorkflowUseCase } from './usecases/duplicate-workflow/duplicate-workflow.usecase'; import { PatchWorkflowUsecase } from './usecases/patch-workflow'; import { WorkflowController } from './workflow.controller'; const DAL_REPOSITORIES = [CommunityOrganizationRepository]; const MODULES = [ SharedModule, MessageTemplateModule, ChangeModule, AuthModule, BridgeModule, IntegrationModule, LayoutsV2Module, OutboundWebhooksModule.forRoot(), StepResolversModule, ]; @Module({ imports: MODULES, controllers: [WorkflowController], providers: [ ...DAL_REPOSITORIES, CreateWorkflowV0, UpdateWorkflowV0, UpsertWorkflowUseCase, ListWorkflowsUseCase, DeleteWorkflowUseCase, UpsertPreferences, DeletePreferencesUseCase, UpsertControlValuesUseCase, GetPreferences, GetWorkflowByIdsUseCase, GetWorkflowWithPreferencesUseCase, SyncToEnvironmentUseCase, BuildStepDataUsecase, PreviewUsecase, BuildWorkflowTestDataUseCase, GetWorkflowUseCase, DuplicateWorkflowUseCase, BuildVariableSchemaUsecase, PatchWorkflowUsecase, CreateVariablesObject, BuildStepIssuesUsecase, ResourceValidatorService, TierRestrictionsValidateUsecase, ControlValueSanitizerService, PayloadMergerService, PreviewPayloadProcessorService, MockDataGeneratorService, PreviewErrorHandler, TestHttpEndpointUsecase, ], exports: [UpsertWorkflowUseCase, SyncToEnvironmentUseCase, GetWorkflowUseCase, DeleteWorkflowUseCase], }) export class WorkflowModule implements NestModule { configure(consumer: MiddlewareConsumer): MiddlewareConsumer | void {} } ================================================ FILE: apps/api/src/app.module.ts ================================================ import { DynamicModule, Module, Provider } from '@nestjs/common'; import { ForwardReference } from '@nestjs/common/interfaces/modules/forward-reference.interface'; import { Type } from '@nestjs/common/interfaces/type.interface'; import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; import { ApiExcludeController } from '@nestjs/swagger'; import { cacheService, TracingModule } from '@novu/application-generic'; import { Client, NovuModule } from '@novu/framework/nest'; import { usageLimitsWorkflow, usageReportWorkflow } from '@novu/notifications'; import { isClerkEnabled } from '@novu/shared'; import { SentryModule } from '@sentry/nestjs/setup'; import packageJson from '../package.json'; import { ActivityModule } from './app/activity/activity.module'; import { AnalyticsModule } from './app/analytics/analytics.module'; import { AuthModule } from './app/auth/auth.module'; import { BlueprintModule } from './app/blueprint/blueprint.module'; import { BridgeModule } from './app/bridge/bridge.module'; import { ChangeModule } from './app/change/change.module'; import { ChannelConnectionsModule } from './app/channel-connections/channel-connections.module'; import { ChannelEndpointsModule } from './app/channel-endpoints/channel-endpoints.module'; import { ContentTemplatesModule } from './app/content-templates/content-templates.module'; import { ContextsModule } from './app/contexts/contexts.module'; import { EnvironmentVariablesModule } from './app/environment-variables/environment-variables.module'; import { EnvironmentsModuleV1 } from './app/environments-v1/environments-v1.module'; import { EnvironmentsModule } from './app/environments-v2/environments.module'; import { EventsModule } from './app/events/events.module'; import { ExecutionDetailsModule } from './app/execution-details/execution-details.module'; import { FeedsModule } from './app/feeds/feeds.module'; import { HealthModule } from './app/health/health.module'; import { InboundParseModule } from './app/inbound-parse/inbound-parse.module'; import { InboxModule } from './app/inbox/inbox.module'; import { IntegrationModule } from './app/integrations/integrations.module'; import { InternalModule } from './app/internal/internal.module'; import { InvitesModule } from './app/invites/invites.module'; import { LayoutsV1Module } from './app/layouts-v1/layouts-v1.module'; import { LayoutsV2Module } from './app/layouts-v2/layouts.module'; import { MessagesModule } from './app/messages/messages.module'; import { NotificationGroupsModule } from './app/notification-groups/notification-groups.module'; import { NotificationModule } from './app/notifications/notification.module'; import { OrganizationModule } from './app/organization/organization.module'; import { OutboundWebhooksModule } from './app/outbound-webhooks/outbound-webhooks.module'; import { PartnerIntegrationsModule } from './app/partner-integrations/partner-integrations.module'; import { PreferencesModule } from './app/preferences'; import { ApiRateLimitInterceptor } from './app/rate-limiting/guards'; import { RateLimitingModule } from './app/rate-limiting/rate-limiting.module'; import { AnalyticsLogsGuard } from './app/shared/framework/analytics-logs.guard'; import { AnalyticsLogsInterceptor } from './app/shared/framework/analytics-logs.interceptor'; import { IdempotencyInterceptor } from './app/shared/framework/idempotency.interceptor'; import { ProductFeatureInterceptor } from './app/shared/interceptors/product-feature.interceptor'; import { SharedModule } from './app/shared/shared.module'; import { StepResolversModule } from './app/step-resolvers/step-resolvers.module'; import { StorageModule } from './app/storage/storage.module'; import { SubscribersV1Module } from './app/subscribers/subscribersV1.module'; import { SubscribersModule } from './app/subscribers-v2/subscribers.module'; import { SupportModule } from './app/support/support.module'; import { TenantModule } from './app/tenant/tenant.module'; import { TestingModule } from './app/testing/testing.module'; import { TopicsV1Module } from './app/topics-v1/topics-v1.module'; import { TopicsV2Module } from './app/topics-v2/topics-v2.module'; import { UserModule } from './app/user/user.module'; import { WidgetsModule } from './app/widgets/widgets.module'; import { WorkflowOverridesModule } from './app/workflow-overrides/workflow-overrides.module'; import { WorkflowModuleV1 } from './app/workflows-v1/workflow-v1.module'; import { WorkflowModule } from './app/workflows-v2/workflow.module'; const enterpriseImports = (): Array | ForwardReference> => { const modules: Array | ForwardReference> = []; if (process.env.NOVU_ENTERPRISE === 'true' || process.env.CI_EE_TEST === 'true') { if (require('@novu/ee-translation')?.EnterpriseTranslationModule) { modules.push(require('@novu/ee-translation')?.EnterpriseTranslationModule); modules.push(require('@novu/ee-translation')?.TranslationModule); } if (require('@novu/ee-billing')?.BillingModule) { modules.push(require('@novu/ee-billing')?.BillingModule.forRoot()); } if (require('@novu/ee-api')?.InboundWebhooksModule) { modules.push(require('@novu/ee-api')?.InboundWebhooksModule); } if (require('@novu/ee-ai')?.AiModule) { modules.push(require('@novu/ee-ai')?.AiModule); } modules.push(SupportModule); modules.push(OutboundWebhooksModule.forRoot()); } return modules; }; const enterpriseQuotaThrottlerInterceptor = (process.env.NOVU_ENTERPRISE === 'true' || process.env.CI_EE_TEST === 'true') && require('@novu/ee-billing')?.QuotaThrottlerInterceptor ? [ { provide: APP_INTERCEPTOR, useClass: require('@novu/ee-billing')?.QuotaThrottlerInterceptor, }, ] : []; const baseModules: Array | ForwardReference> = [ AuthModule, InboundParseModule, SharedModule, HealthModule, EnvironmentsModuleV1, ExecutionDetailsModule, WorkflowModuleV1, EventsModule, WidgetsModule, InboxModule, NotificationModule, NotificationGroupsModule, ContentTemplatesModule, OrganizationModule, ActivityModule, UserModule, IntegrationModule, InternalModule, ChangeModule, ContextsModule, SubscribersV1Module, SubscribersModule, FeedsModule, LayoutsV1Module, LayoutsV2Module, MessagesModule, PartnerIntegrationsModule, TopicsV1Module, TopicsV2Module, BlueprintModule, TenantModule, EnvironmentVariablesModule, StorageModule, WorkflowOverridesModule, RateLimitingModule, TracingModule.register(packageJson.name, packageJson.version), BridgeModule, PreferencesModule, WorkflowModule, EnvironmentsModule, NovuModule, ChannelConnectionsModule, ChannelEndpointsModule, StepResolversModule, ]; const enterpriseModules = enterpriseImports(); if (!isClerkEnabled()) { const communityModules = [InvitesModule]; baseModules.push(...communityModules); } const modules = baseModules.concat(enterpriseModules); const providers: Provider[] = [ { provide: APP_GUARD, useClass: AnalyticsLogsGuard, }, { provide: APP_INTERCEPTOR, useClass: ApiRateLimitInterceptor, }, { provide: APP_INTERCEPTOR, useClass: ProductFeatureInterceptor, }, ...enterpriseQuotaThrottlerInterceptor, { provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor, }, { provide: APP_INTERCEPTOR, useClass: AnalyticsLogsInterceptor, }, cacheService, ]; if (process.env.SENTRY_DSN) { modules.unshift(SentryModule.forRoot()); } if (process.env.SEGMENT_TOKEN) { modules.push(AnalyticsModule); } if (process.env.NODE_ENV === 'test') { modules.push(TestingModule); } modules.push( NovuModule.register({ apiPath: '/bridge/novu', client: new Client({ secretKey: process.env.NOVU_INTERNAL_SECRET_KEY, strictAuthentication: process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'dev' || process.env.NOVU_STRICT_AUTHENTICATION_ENABLED === 'true', }), controllerDecorators: [ApiExcludeController()], workflows: [usageLimitsWorkflow, usageReportWorkflow], }) ); @Module({ imports: modules, controllers: [], providers, }) export class AppModule {} ================================================ FILE: apps/api/src/bootstrap.ts ================================================ import './instrument'; import { INestApplication, ValidationPipe, VersioningType } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { BullMqService, getErrorInterceptor, // biome-ignore lint/style/noRestrictedImports: x Logger, PinoLogger, RequestLogRepository, } from '@novu/application-generic'; import bodyParser from 'body-parser'; import helmet from 'helmet'; import { ResponseInterceptor } from './app/shared/framework/response.interceptor'; import { setupSwagger } from './app/shared/framework/swagger/swagger.controller'; import { RequestIdMiddleware } from './app/shared/middleware/request-id.middleware'; import { AppModule } from './app.module'; import { CONTEXT_PATH, corsOptionsDelegate, validateEnv } from './config'; import { AllExceptionsFilter } from './exception-filter'; const passport = require('passport'); const compression = require('compression'); const extendedBodySizeRoutes = [ '/v1/events', '/v1/notification-templates', '/v1/workflows', '/v1/layouts', '/v1/bridge/sync', '/v1/bridge/diff', '/v1/environments/:environmentId/bridge', '/v2/workflows', ]; // Validate the ENV variables after launching SENTRY, so missing variables will report to sentry. validateEnv(); class BootstrapOptions { internalSdkGeneration?: boolean; } export async function bootstrap( bootstrapOptions?: BootstrapOptions ): Promise<{ app: INestApplication; document: any }> { BullMqService.haveProInstalled(); let rawBodyBuffer: undefined | ((...args) => void); let nestOptions: Record = {}; if (process.env.NOVU_ENTERPRISE === 'true' || process.env.CI_EE_TEST === 'true') { rawBodyBuffer = (_req, _res, buffer, _encoding): void => { if (buffer?.length) { // eslint-disable-next-line no-param-reassign (_req as any).rawBody = Buffer.from(buffer); } }; nestOptions = { bodyParser: false, rawBody: true, }; } const app = await NestFactory.create(AppModule, { bufferLogs: true, ...nestOptions }); app.enableVersioning({ type: VersioningType.URI, prefix: `${CONTEXT_PATH}v`, defaultVersion: '1', }); const logger = await app.resolve(PinoLogger); logger.setContext('Bootstrap'); app.useLogger(app.get(Logger)); app.flushLogs(); const server = app.getHttpServer(); logger.trace(`Server timeout: ${server.timeout}`); server.keepAliveTimeout = 61 * 1000; logger.trace(`Server keepAliveTimeout: ${server.keepAliveTimeout / 1000}s `); server.headersTimeout = 65 * 1000; logger.trace(`Server headersTimeout: ${server.headersTimeout / 1000}s `); app.use(helmet()); app.enableCors(corsOptionsDelegate); app.use(passport.initialize()); // Apply transaction ID middleware early in the request lifecycle const transactionIdMiddleware = new RequestIdMiddleware(); app.use((req, res, next) => transactionIdMiddleware.use(req, res, next)); app.useGlobalPipes( new ValidationPipe({ transform: true, forbidUnknownValues: false, }) ); app.useGlobalInterceptors(new ResponseInterceptor()); app.useGlobalInterceptors(getErrorInterceptor()); app.use(extendedBodySizeRoutes, bodyParser.json({ limit: '26mb' })); app.use(extendedBodySizeRoutes, bodyParser.urlencoded({ limit: '26mb', extended: true })); // Add text/plain parser specifically for inbound webhooks (SNS confirmations) app.use( '/v2/inbound-webhooks/delivery-providers/:environmentId/:integrationId', bodyParser.text({ verify: rawBodyBuffer }) ); app.use((req, res, next) => { if (req.path.startsWith('/v1/better-auth')) { return next(); } return bodyParser.json({ verify: rawBodyBuffer })(req, res, next); }); app.use((req, res, next) => { if (req.path.startsWith('/v1/better-auth')) { return next(); } return bodyParser.urlencoded({ extended: true, verify: rawBodyBuffer })(req, res, next); }); app.use( compression({ filter: (req, res) => { // the compression middleware buffers the response to compress it, which breaks SSE streaming if (res.getHeader('Content-Type') === 'text/event-stream') { return false; } return compression.filter(req, res); }, }) ); const document = await setupSwagger(app, bootstrapOptions?.internalSdkGeneration); app.useGlobalFilters(new AllExceptionsFilter(app.get(Logger), app.get(RequestLogRepository))); /* * Handle unhandled promise rejections * We explicitly crash the process on unhandled rejections as they indicate the application * is in an undefined state. NestJS can't handle these as they occur outside the event lifecycle. * According to Node.js docs, it's unsafe to resume normal operation after unhandled rejections. * We log these rejections with fatal level to ensure they are properly monitored and tracked. * See: https://nodejs.org/api/process.html#process_warning_using_uncaughtexception_correctly */ process.on('unhandledRejection', (reason, promise) => { logger.fatal({ err: reason, message: 'Unhandled promise rejection', promise, }); process.exit(1); }); await app.listen(process.env.PORT || 3000); app.enableShutdownHooks(); logger.info(`Started application in NODE_ENV=${process.env.NODE_ENV} on port ${process.env.PORT}.`); return { app, document }; } ================================================ FILE: apps/api/src/config/cors.config.spec.ts ================================================ import { expect } from 'chai'; import { spy } from 'sinon'; import { corsOptionsDelegate } from './cors.config'; const dashboardOrigin = 'https://dashboard.novu.co'; const widgetOrigin = 'https://widget.novu.co'; const previewOrigin = 'https://deploy-preview-8045.dashboard-v2.novu-staging.co'; describe('CORS Configuration', () => { describe('Local Environment', () => { beforeEach(() => { process.env.NODE_ENV = 'local'; }); afterEach(() => { process.env.NODE_ENV = 'test'; }); it('should allow all origins', () => { const callbackSpy = spy(); // @ts-expect-error - corsOptionsDelegate is not typed correctly corsOptionsDelegate({ url: '/v1/test' }, callbackSpy); expect(callbackSpy.calledOnce).to.be.ok; expect(callbackSpy.firstCall.firstArg).to.be.null; expect(callbackSpy.firstCall.lastArg.origin).to.equal('*'); }); }); describe(`CORS Configuration`, () => { beforeEach(() => { process.env.NODE_ENV = 'production'; }); afterEach(() => { process.env.NODE_ENV = 'test'; process.env.WIDGET_BASE_URL = ''; }); it('should allow only dashboard and widget origins', () => { process.env.WIDGET_BASE_URL = widgetOrigin; const callbackSpy = spy(); // @ts-expect-error - corsOptionsDelegate is not typed correctly corsOptionsDelegate( { url: '/v1/test', headers: { origin: dashboardOrigin, }, }, callbackSpy ); expect(callbackSpy.calledOnce).to.be.ok; expect(callbackSpy.firstCall.firstArg).to.be.null; expect(callbackSpy.firstCall.lastArg.origin.length).to.equal(2); expect(callbackSpy.firstCall.lastArg.origin[0]).to.equal(dashboardOrigin); expect(callbackSpy.firstCall.lastArg.origin[1]).to.equal(widgetOrigin); }); it('should allow for the preview deployments origin', () => { const callbackSpy = spy(); // @ts-expect-error - corsOptionsDelegate is not typed correctly corsOptionsDelegate( { url: '/v1/test', headers: { origin: previewOrigin, }, }, callbackSpy ); expect(callbackSpy.calledOnce).to.be.ok; expect(callbackSpy.firstCall.firstArg).to.be.null; expect(callbackSpy.firstCall.lastArg.origin.length).to.equal(1); expect(callbackSpy.firstCall.lastArg.origin[0]).to.equal(previewOrigin); }); it('widget routes should be wildcarded', () => { const callbackSpy = spy(); // @ts-expect-error - corsOptionsDelegate is not typed correctly corsOptionsDelegate({ url: '/v1/widgets/test' }, callbackSpy); expect(callbackSpy.calledOnce).to.be.ok; expect(callbackSpy.firstCall.firstArg).to.be.null; expect(callbackSpy.firstCall.lastArg.origin).to.equal('*'); }); it('inbox routes should be wildcarded', () => { const callbackSpy = spy(); // @ts-expect-error - corsOptionsDelegate is not typed correctly corsOptionsDelegate({ url: '/v1/inbox/session' }, callbackSpy); expect(callbackSpy.calledOnce).to.be.ok; expect(callbackSpy.firstCall.firstArg).to.be.null; expect(callbackSpy.firstCall.lastArg.origin).to.equal('*'); }); }); }); ================================================ FILE: apps/api/src/config/cors.config.ts ================================================ import { INestApplication } from '@nestjs/common'; import { HttpRequestHeaderKeysEnum } from '@novu/application-generic'; const ALLOWED_ORIGINS_REGEX = new RegExp(process.env.FRONT_BASE_URL || ''); export const corsOptionsDelegate: Parameters[0] = (req: Request, callback) => { const corsOptions: Parameters[1] = { origin: false as boolean | string | string[], preflightContinue: false, maxAge: 86400, credentials: true, allowedHeaders: Object.values(HttpRequestHeaderKeysEnum), methods: ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], }; if (enableWildcard(req)) { corsOptions.origin = '*'; } else { corsOptions.origin = []; const requestOrigin = origin(req); if (ALLOWED_ORIGINS_REGEX.test(requestOrigin)) { corsOptions.origin.push(requestOrigin); } if (process.env.WIDGET_BASE_URL) { corsOptions.origin.push(process.env.WIDGET_BASE_URL); } // Enable CORS for the docs if (process.env.DOCS_BASE_URL) { corsOptions.origin.push(process.env.DOCS_BASE_URL); } } callback(null as unknown as Error, corsOptions); }; function enableWildcard(req: Request): boolean { return ( (isDevelopmentEnvironment() || isWidgetRoute(req.url) || isInboxRoute(req.url) || isBlueprintRoute(req.url)) && !isBetterAuthRoute(req.url) ); } // BetterAuth routes require explicit origin validation for credential-based requests function isBetterAuthRoute(url: string): boolean { return url.startsWith('/v1/better-auth'); } function isWidgetRoute(url: string): boolean { return url.startsWith('/v1/widgets'); } function isInboxRoute(url: string): boolean { return url.startsWith('/v1/inbox'); } function isBlueprintRoute(url: string): boolean { return url.startsWith('/v1/blueprints'); } function isDevelopmentEnvironment(): boolean { return ['test', 'local'].includes(process.env.NODE_ENV || ''); } function origin(req: Request): string { return (req.headers as any)?.origin || ''; } ================================================ FILE: apps/api/src/config/env.config.ts ================================================ import path from 'node:path'; import { getContextPath, getEnvFileNameForNodeEnv, NovuComponentEnum } from '@novu/shared'; import dotenv from 'dotenv'; dotenv.config({ path: path.join(__dirname, '..', getEnvFileNameForNodeEnv(process.env.NODE_ENV)) }); export const CONTEXT_PATH = getContextPath(NovuComponentEnum.API); ================================================ FILE: apps/api/src/config/env.validators.ts ================================================ import { DEFAULT_NOTIFICATION_RETENTION_DAYS, FeatureFlagsKeysEnum, StringifyEnv } from '@novu/shared'; import { bool, CleanedEnv, cleanEnv, json, num, port, str, url, ValidatorSpec } from 'envalid'; export function validateEnv() { return cleanEnv(process.env, envValidators); } export type ValidatedEnv = StringifyEnv>; const processEnv = process.env as Record; // Hold the initial process.env to avoid circular reference function getFeatureFlagValidator(key: FeatureFlagsKeysEnum): ValidatorSpec { if (key.endsWith('_NUMBER') || key === FeatureFlagsKeysEnum.MAX_ENVIRONMENT_COUNT) { return num({ default: undefined }); } if (key.startsWith('IS_')) { return bool({ default: false }); } return str({ default: undefined }); } export const envValidators = { TZ: str({ default: 'UTC' }), NODE_ENV: str({ choices: ['dev', 'test', 'production', 'ci', 'local'], default: 'local' }), LOG_LEVEL: str({ choices: ['trace', 'debug', 'info', 'warn', 'error', 'fatal', 'none'] }), PORT: port(), FRONT_BASE_URL: str(), DASHBOARD_URL: str({ default: '' }), DISABLE_USER_REGISTRATION: bool({ default: false }), REDIS_HOST: str(), REDIS_PORT: port(), REDIS_TLS: json({ default: undefined }), REDIS_MASTER_HOST: str({ default: '' }), REDIS_MASTER_PORT: str({ default: '' }), REDIS_SLAVE_HOST: str({ default: '' }), REDIS_SLAVE_PORT: str({ default: '' }), JWT_SECRET: str(), SENDGRID_API_KEY: str({ default: '' }), MONGO_AUTO_CREATE_INDEXES: bool({ default: false }), MONGO_MAX_IDLE_TIME_IN_MS: num({ default: 1000 * 30 }), MONGO_MAX_POOL_SIZE: num({ default: 50 }), MONGO_MIN_POOL_SIZE: num({ default: 10 }), MONGO_URL: str(), NOVU_API_KEY: str({ default: '' }), STORE_ENCRYPTION_KEY: str(), REDIS_CACHE_SERVICE_HOST: str({ default: '' }), REDIS_CACHE_SERVICE_PORT: str({ default: '' }), REDIS_CACHE_SERVICE_TLS: json({ default: undefined }), REDIS_CLUSTER_SERVICE_HOST: str({ default: '' }), REDIS_CLUSTER_SERVICE_PORTS: str({ default: '' }), STORE_NOTIFICATION_CONTENT: bool({ default: false }), WORKER_DEFAULT_CONCURRENCY: num({ default: undefined }), WORKER_DEFAULT_LOCK_DURATION: num({ default: undefined }), ENABLE_OTEL: bool({ default: false }), ENABLE_OTEL_LOGS: bool({ default: false }), OTEL_PROMETHEUS_PORT: num({ default: 9464 }), NOTIFICATION_RETENTION_DAYS: num({ default: DEFAULT_NOTIFICATION_RETENTION_DAYS }), API_ROOT_URL: url(), NOVU_INVITE_TEAM_MEMBER_NUDGE_TRIGGER_IDENTIFIER: str({ default: undefined }), SUBSCRIBER_WIDGET_JWT_EXPIRATION_TIME: str({ default: '15 days' }), NOVU_REGION: str({ default: 'local' }), NOVU_SECRET_KEY: str({ default: '' }), INTERNAL_SERVICES_API_KEY: str({ default: undefined }), SCHEDULER_URL: str({ default: undefined }), SCHEDULER_API_KEY: str({ default: undefined }), INTERNAL_CALLBACK_API_KEY: str({ default: undefined }), // AI/LLM Configuration AI_LLM_PROVIDER: str({ choices: ['openai', 'anthropic'], default: 'openai' }), AI_LLM_API_KEY: str({ default: '' }), AI_LLM_MODEL: str({ default: '' }), AI_LLM_MAX_OUTPUT_TOKENS: num({ default: 8192 }), AI_LLM_TEMPERATURE: num({ default: 0.7 }), AI_LLM_MAX_RETRIES: num({ default: 3 }), AI_LLM_SERVICE_TIER: str({ choices: ['auto', 'default', 'flex', 'priority'], default: 'priority' }), AI_LLM_PROMPT_CACHE_RETENTION: str({ choices: ['in-memory', '24h'], default: '24h' }), STEP_RESOLVER_CF_ACCOUNT_ID: str({ default: undefined }), STEP_RESOLVER_CF_API_TOKEN: str({ default: undefined }), STEP_RESOLVER_CF_DISPATCH_NAMESPACE: str({ default: undefined }), STEP_RESOLVER_DISPATCH_URL: url({ default: '' }), STEP_RESOLVER_HMAC_SECRET: str({ default: '' }), // Novu Cloud third party services ...(processEnv.IS_SELF_HOSTED !== 'true' && processEnv.NOVU_ENTERPRISE === 'true' && { HUBSPOT_INVITE_NUDGE_EMAIL_USER_LIST_ID: str({ default: undefined }), HUBSPOT_PRIVATE_APP_ACCESS_TOKEN: str({ default: undefined }), LAUNCH_DARKLY_SDK_KEY: str({ default: '' }), NEW_RELIC_APP_NAME: str({ default: '' }), NEW_RELIC_LICENSE_KEY: str({ default: '' }), PLAIN_SUPPORT_KEY: str({ default: undefined }), PLAIN_IDENTITY_VERIFICATION_SECRET_KEY: str({ default: undefined }), PLAIN_CARDS_HMAC_SECRET_KEY: str({ default: undefined }), STRIPE_API_KEY: str({ default: undefined }), STRIPE_CONNECT_SECRET: str({ default: undefined }), NOVU_INTERNAL_SECRET_KEY: str({ default: '' }), KEYLESS_ORGANIZATION_ID: str({ desc: 'Required organizationId for Keyless authentication', default: undefined }), KEYLESS_USER_EMAIL: str({ desc: 'Required email for Keyless authentication', default: undefined }), CLICK_HOUSE_URL: str({ default: '' }), CLICK_HOUSE_DATABASE: str({ default: '' }), CLICK_HOUSE_USER: str({ default: '' }), CLICK_HOUSE_PASSWORD: str({ default: '' }), }), // Feature Flags ...(Object.fromEntries( Object.values(FeatureFlagsKeysEnum).map((key) => [key, getFeatureFlagValidator(key)]) ) as Record>), // Azure validators ...(processEnv.STORAGE_SERVICE === 'AZURE' && { AZURE_ACCOUNT_NAME: str(), AZURE_ACCOUNT_KEY: str(), AZURE_HOST_NAME: str({ default: `https://${processEnv.AZURE_ACCOUNT_NAME}.blob.core.windows.net` }), AZURE_CONTAINER_NAME: str({ default: 'novu' }), }), // GCS validators ...(processEnv.STORAGE_SERVICE === 'GCS' && { GCS_BUCKET_NAME: str(), GCS_DOMAIN: str(), }), // AWS validators ...(processEnv.STORAGE_SERVICE === 'AWS' && { S3_LOCAL_STACK: str({ default: '' }), S3_BUCKET_NAME: str(), S3_REGION: str(), }), // Production validators ...(['local', 'test'].includes(processEnv.NODE_ENV) && { SENTRY_DSN: str({ default: '' }), VERCEL_CLIENT_ID: str({ default: '' }), VERCEL_CLIENT_SECRET: str({ default: '' }), VERCEL_REDIRECT_URI: url({ default: 'https://dashboard.novu.co/auth/login' }), VERCEL_BASE_URL: url({ default: 'https://api.vercel.com' }), }), } satisfies Record>; ================================================ FILE: apps/api/src/config/index.ts ================================================ export * from './cors.config'; export * from './env.config'; export * from './env.validators'; ================================================ FILE: apps/api/src/error-dto.ts ================================================ import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger'; // Ensure you have the correct import for ApiProperty import { ConstraintValidation } from '@novu/application-generic'; export class ErrorDto { @ApiProperty({ description: 'HTTP status code of the error response.', example: 404, }) statusCode: number; @ApiProperty({ description: 'Timestamp of when the error occurred.', example: '2024-12-12T13:00:00Z', }) timestamp: string; @ApiProperty({ description: 'The path where the error occurred.', example: '/api/v1/resource', }) path: string; @ApiProperty({ required: false, description: 'Value that failed validation', oneOf: [ { type: 'string', nullable: true }, { type: 'number' }, { type: 'boolean' }, { type: 'object', nullable: true }, { type: 'array', items: { anyOf: [ { type: 'string', nullable: true }, { type: 'number' }, { type: 'boolean' }, { type: 'object', additionalProperties: true }, ], }, }, ], example: 'xx xx xx ', }) message?: unknown; @ApiProperty({ description: 'Optional context object for additional error details.', type: 'object', required: false, additionalProperties: true, example: { workflowId: 'some_wf_id', stepId: 'some_wf_id', }, }) ctx?: object | Object; /** * Optional unique identifier for the error, useful for tracking using Sentry and New Relic, only available for 500. */ @ApiProperty({ description: `Optional unique identifier for the error, useful for tracking using Sentry and New Relic, only available for 500.`, example: 'abc123', required: false, }) errorId?: string; } export class PayloadValidationErrorDto { @ApiProperty({ description: 'Field path that failed validation', example: 'user.name', }) field: string; @ApiProperty({ description: 'Validation error message', example: "must have required property 'name'", }) message: string; @ApiProperty({ description: 'The actual value that failed validation', oneOf: [ { type: 'string', nullable: true }, { type: 'number' }, { type: 'boolean' }, { type: 'object', nullable: true }, { type: 'array', items: { anyOf: [ { type: 'string', nullable: true }, { type: 'number' }, { type: 'boolean' }, { type: 'object', additionalProperties: true }, ], }, }, ], required: false, example: { age: 25 }, }) value?: any; @ApiProperty({ description: 'JSON Schema path where the validation failed', example: '#/required', required: false, }) schemaPath?: string; } @ApiExtraModels(PayloadValidationErrorDto) export class PayloadValidationExceptionDto extends ErrorDto { @ApiProperty({ description: 'Type identifier for payload validation errors', example: 'PAYLOAD_VALIDATION_ERROR', }) type: string; @ApiProperty({ description: 'Array of detailed validation errors', type: [PayloadValidationErrorDto], example: [ { field: 'user.name', message: "must have required property 'name'", value: { age: 25 }, schemaPath: '#/required', }, ], }) errors: PayloadValidationErrorDto[]; @ApiProperty({ description: 'The JSON schema that was used for validation', type: 'object', required: false, example: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'number' }, }, required: ['name'], }, }) schema?: any; } @ApiExtraModels(ConstraintValidation) export class ValidationErrorDto extends ErrorDto { @ApiProperty({ description: 'A record of validation errors keyed by field name', type: 'object', additionalProperties: { $ref: getSchemaPath(ConstraintValidation), }, example: { fieldName1: { messages: ['Field is required', 'Must be a valid email address'], value: 'invalidEmail', }, fieldName2: { messages: ['Must be at least 18 years old'], value: 17, }, fieldName3: { messages: ['Must be a boolean value'], value: true, }, fieldName4: { messages: ['Must be a valid object'], value: { key: 'value' }, }, fieldName5: { messages: ['Field is missing'], value: null, }, fieldName6: { messages: ['Undefined value'], }, }, }) errors: Record; } ================================================ FILE: apps/api/src/exception-filter.ts ================================================ import { randomUUID } from 'node:crypto'; import { ArgumentsHost, ExceptionFilter, HttpException, HttpStatus, PayloadTooLargeException } from '@nestjs/common'; import { InternalServerErrorException } from '@nestjs/common/exceptions/internal-server-error.exception'; import { HttpArgumentsHost } from '@nestjs/common/interfaces'; import { CommandValidationException, PinoLogger, RequestLogRepository } from '@novu/application-generic'; import { UserSessionData } from '@novu/shared'; import { captureException } from '@sentry/node'; import { Response } from 'express'; import { ZodError } from 'zod'; import { RequestWithReqId } from './app/shared/middleware/request-id.middleware'; import { buildLog } from './app/shared/utils/mappers'; import { ErrorDto, ValidationErrorDto } from './error-dto'; export const ERROR_MSG_500 = `Internal server error, contact support and provide them with the errorId`; class ValidationPipeError { response: { message: string[] | string }; } export class AllExceptionsFilter implements ExceptionFilter { constructor( private readonly logger: PinoLogger, private readonly requestLogRepository: RequestLogRepository ) {} async catch(exception: unknown, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse(); const request = ctx.getRequest(); const errorDto = this.buildErrorResponse(exception, request); // TODO: In same cases the statusCode is a string. We should investigate why this is happening. const statusCode = Number(errorDto.statusCode); if (statusCode >= 500) { this.logError(errorDto, exception); } // This is for backwards compatibility for clients waiting for the context elements to appear flat const finalResponse = { ...errorDto.ctx, ...errorDto }; await this.createAnalyticsLog(ctx, request, statusCode, errorDto); response.status(statusCode).json(finalResponse); } private async createAnalyticsLog( ctx: HttpArgumentsHost, request: RequestWithReqId, statusCode: number, errorDto: ErrorDto ) { const shouldRun = await this.shouldRun(ctx); if (!shouldRun) return; const req = ctx.getRequest(); const user = req.user as UserSessionData; const basicLog = buildLog(request, statusCode, errorDto, user); try { if (basicLog) { this.requestLogRepository.create(basicLog, { organizationId: user?.organizationId, environmentId: user?.environmentId, userId: user?._id, }); } } catch (err) { this.logger.warn({ err }, 'Failed to log analytics to ClickHouse after retries'); } } private async shouldRun(ctx: HttpArgumentsHost): Promise { const req = ctx.getRequest(); // Check if the analytics metadata was set by the guard (AnalyticsLogsGuard) if (req._shouldLogAnalytics !== true) return false; const isEnabled = process.env.IS_ANALYTICS_LOGS_ENABLED === 'true'; return isEnabled; } private logError(errorDto: ErrorDto, exception: unknown) { this.logger.error({ /** * It's important to use `err` as the key, pino (the logger we use) will * log an empty object if the key is not `err` * * @see https://github.com/pinojs/pino/issues/819#issuecomment-611995074 */ err: exception, error: errorDto, }); } private buildErrorDto( request: RequestWithReqId, statusCode: number, message: string, ctx?: Object | object ): ErrorDto { return { statusCode, timestamp: new Date().toISOString(), path: request.url, message, ctx, }; } private buildErrorResponse(exception: unknown, request: RequestWithReqId): ErrorDto { if (exception instanceof HttpException && exception.name === 'ThrottlerException') { return this.handlerThrottlerException(request); } if (exception instanceof ZodError) { return this.handleZod(exception, request); } if (exception instanceof CommandValidationException) { return this.handleCommandValidation(exception, request); } if (this.isBadRequestWithMultipleExceptions(exception)) { return this.handleValidationPipeValidation(exception, request); } if (exception instanceof HttpException && !(exception instanceof InternalServerErrorException)) { return this.handleOtherHttpExceptions(exception, request); } if (this.isPayloadTooLargeError(exception)) { return this.handleOtherHttpExceptions(new PayloadTooLargeException(), request); } return this.buildA5xxError(request, exception); } private isPayloadTooLargeError(exception: unknown) { return exception?.constructor?.name === 'PayloadTooLargeError'; } private isBadRequestWithMultipleExceptions(exception: unknown): exception is ValidationPipeError { // noinspection UnnecessaryLocalVariableJS const isBadRequestExceptionFromValidationPipe = exception instanceof Object && safeHasProperty(exception, 'response') && safeHasProperty((exception as any).response, 'message') && Array.isArray((exception as any).response.message); return isBadRequestExceptionFromValidationPipe; } private buildA5xxError(request: RequestWithReqId, exception: unknown) { const errorDto500 = this.buildErrorDto(request, HttpStatus.INTERNAL_SERVER_ERROR, ERROR_MSG_500); return { ...errorDto500, errorId: this.getUuid(exception), }; } private handleOtherHttpExceptions(exception: HttpException, request: RequestWithReqId): ErrorDto { const status = exception.getStatus(); const response = exception.getResponse(); const { innerMsg, tempContext } = this.buildMsgAndContextForHttpError(response, status); return this.buildErrorDto(request, status || 500, innerMsg, tempContext); } private buildMsgAndContextForHttpError(response: string | object | { message: string }, status: number) { if (typeof response === 'string') { return { innerMsg: response as string }; } if (safeHasProperty(response, 'message')) { const { message, ...ctx } = response as { message: string }; return { innerMsg: message, tempContext: ctx }; } if (typeof response === 'object' && response !== null) { return { innerMsg: `Api Exception Raised with status ${status}`, tempContext: response }; } return { innerMsg: `Api Exception Raised with status ${status}` }; } private handleCommandValidation( exception: CommandValidationException, request: RequestWithReqId ): ValidationErrorDto { const errorDto = this.buildErrorDto(request, HttpStatus.UNPROCESSABLE_ENTITY, exception.message, {}); return { ...errorDto, errors: exception.constraintsViolated }; } private getUuid(exception: unknown) { if (process.env.SENTRY_DSN) { try { return captureException(exception); } catch (e) { return randomUUID(); } } else { return randomUUID(); } } private handleZod(exception: ZodError, request: RequestWithReqId): ErrorDto { const ctx = { errors: exception.errors.map((err) => ({ message: err.message, path: err.path, })), }; return this.buildErrorDto(request, HttpStatus.BAD_REQUEST, 'Zod Validation Failed', ctx); } private handleValidationPipeValidation(exception: ValidationPipeError, request: RequestWithReqId) { const errorDto = this.buildErrorDto(request, HttpStatus.UNPROCESSABLE_ENTITY, 'Validation Error', {}); return { ...errorDto, errors: { general: { messages: exception.response.message, value: 'No Value Recorded' } } }; } private handlerThrottlerException(request: RequestWithReqId) { return this.buildErrorDto(request, HttpStatus.TOO_MANY_REQUESTS, 'API rate limit exceeded', {}); } } function safeHasProperty(obj: unknown, property: string): boolean { return typeof obj === 'object' && obj !== null && property in obj; } ================================================ FILE: apps/api/src/instrument.ts ================================================ import './config/env.config'; // Import from the tracing subpath, NOT the main barrel. The barrel loads // @novu/application-generic which transitively pulls in pino/mongoose/ioredis. // TypeScript hoists all imports — if pino loads before startOtel() registers // instrumentations, PinoInstrumentation cannot patch the already-bound references. // Importing only otel-init keeps those modules out of require.cache until after // the SDK's require()-hooks are in place. import { startOtel } from '@novu/application-generic/build/main/tracing/otel-init'; import { name, version } from '../package.json'; startOtel(name, version); // biome-ignore lint: must execute after startOtel() so New Relic layers on top require('newrelic'); // biome-ignore lint: lazy require so @sentry/nestjs loads after OTEL instrumentations are installed const { init } = require('@sentry/nestjs'); if (process.env.SENTRY_DSN) { init({ dsn: process.env.SENTRY_DSN, environment: process.env.NODE_ENV, release: `v${version}`, ignoreErrors: ['Non-Error exception captured'], }); } ================================================ FILE: apps/api/src/main.ts ================================================ import { bootstrap } from './bootstrap'; bootstrap(); ================================================ FILE: apps/api/src/newrelic.ts ================================================ /** * New Relic agent configuration. * * See lib/config/default.js in the agent distribution for a more complete * description of configuration variables and their potential values. */ exports.config = { /** * Array of application names. */ app_name: [process.env.NEW_RELIC_APP_NAME], /** * Your New Relic license key. */ license_key: process.env.NEW_RELIC_LICENSE_KEY, /** * This setting controls distributed tracing. * Distributed tracing lets you see the path that a request takes through your * distributed system. Enabling distributed tracing changes the behavior of some * New Relic features, so carefully consult the transition guide before you enable * this feature: https://docs.newrelic.com/docs/transition-guide-distributed-tracing * Default is true. */ distributed_tracing: { /** * Enables/disables distributed tracing. * * @env NEW_RELIC_DISTRIBUTED_TRACING_ENABLED */ enabled: true, }, application_logging: { forwarding: { enabled: true, }, }, logging: { /** * Level at which to log. 'trace' is most useful to New Relic when diagnosing * issues with the agent, 'info' and higher will impose the least overhead on * production applications. */ level: 'info', }, /** * When true, all request headers except for those listed in attributes.exclude * will be captured for all traces, unless otherwise specified in a destination's * attributes include/exclude lists. */ allow_all_headers: true, attributes: { /** * Prefix of attributes to exclude from all destinations. Allows * as wildcard * at end. * * NOTE: If excluding headers, they must be in camelCase form to be filtered. * * @env NEW_RELIC_ATTRIBUTES_EXCLUDE */ exclude: [ 'request.headers.cookie', 'request.headers.authorization', 'request.headers.proxyAuthorization', 'request.headers.setCookie*', 'request.headers.x*', 'response.headers.cookie', 'response.headers.authorization', 'response.headers.proxyAuthorization', 'response.headers.setCookie*', 'response.headers.x*', ], }, }; ================================================ FILE: apps/api/src/types/env.d.ts ================================================ import type { FeatureFlagsKeysEnum, ApiRateLimitEnvVarFormat } from '@novu/shared'; import type { ValidatedEnv } from '../config'; type ApiRateLimitEnvVars = Record; type TypedEnvVars = ValidatedEnv & ApiRateLimitEnvVars; declare global { namespace NodeJS { interface ProcessEnv extends TypedEnvVars { NODE_ENV: 'test' | 'production' | 'dev' | 'ci' | 'local'; } } } ================================================ FILE: apps/api/src/utils/payload-sanitizer.ts ================================================ const SENSITIVE_KEYS = ['password', 'token', 'secret', 'apikey', 'email', 'phone', 'bearer']; const MAX_PAYLOAD_SIZE = 51200; // 50KB export function sanitizePayload(payload: Record): string { if (!payload) return ''; try { let str = JSON.stringify(payload); if (str.length > MAX_PAYLOAD_SIZE) { str = `${str.slice(0, MAX_PAYLOAD_SIZE)}...`; } return str; } catch { return '[Unserializable Payload]'; } } export async function retryWithBackoff(fn: () => Promise, maxAttempts = 3, initialDelayMs = 100): Promise { let delay = initialDelayMs; for (let attempt = 0; attempt < maxAttempts; attempt += 1) { try { return await fn(); } catch (err) { if (attempt === maxAttempts - 1) throw err; const currentDelay = delay; await new Promise((resolve) => setTimeout(resolve, currentDelay)); delay *= 2; } } throw new Error('Max attempts reached'); } ================================================ FILE: apps/api/swagger-spec.json ================================================ { "openapi": "3.0.0", "paths": { "/v1/events/trigger": { "post": { "operationId": "EventsController_trigger", "x-speakeasy-group": "", "x-speakeasy-usage-example": { "title": "Trigger Notification Event" }, "x-speakeasy-name-override": "trigger", "summary": "Trigger event", "description": "\n Trigger event is the main (and only) way to send notifications to subscribers. \n The trigger identifier is used to match the particular workflow associated with it. \n Additional information can be passed according the body interface below.\n ", "parameters": [ { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TriggerEventRequestDto" } } } }, "responses": { "201": { "description": "Created", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TriggerEventResponseDto" } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Events"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] } }, "/v1/events/trigger/bulk": { "post": { "operationId": "EventsController_triggerBulk", "x-speakeasy-group": "", "x-speakeasy-usage-example": { "title": "Trigger Notification Events in Bulk" }, "x-speakeasy-name-override": "triggerBulk", "summary": "Bulk trigger event", "description": "\n Using this endpoint you can trigger multiple events at once, to avoid multiple calls to the API.\n The bulk API is limited to 100 events per request.\n ", "parameters": [ { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BulkTriggerEventDto" } } } }, "responses": { "201": { "description": "Created", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/TriggerEventResponseDto" } } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Events"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] } }, "/v1/events/trigger/broadcast": { "post": { "operationId": "EventsController_broadcastEventToAll", "x-speakeasy-group": "", "x-speakeasy-usage-example": { "title": "Broadcast Event to All" }, "x-speakeasy-name-override": "triggerBroadcast", "summary": "Broadcast event to all", "description": "Trigger a broadcast event to all existing subscribers, could be used to send announcements, etc.\n In the future could be used to trigger events to a subset of subscribers based on defined filters.", "parameters": [ { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TriggerEventToAllRequestDto" } } } }, "responses": { "200": { "description": "OK", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TriggerEventResponseDto" } } } }, "201": { "description": "Broadcast request has been registered successfully ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TriggerEventResponseDto" } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Events"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] } }, "/v1/events/trigger/{transactionId}": { "delete": { "operationId": "EventsController_cancel", "x-speakeasy-group": "", "x-speakeasy-usage-example": { "title": "Cancel Triggered Event" }, "x-speakeasy-name-override": "cancel", "summary": "Cancel triggered event", "description": "\n Using a previously generated transactionId during the event trigger,\n will cancel any active or pending workflows. This is useful to cancel active digests, delays etc...\n ", "parameters": [ { "name": "transactionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "description": "", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DataBooleanDto" } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Events"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] } }, "/v1/notifications": { "get": { "operationId": "NotificationsController_listNotifications", "summary": "Get notifications", "parameters": [ { "name": "channels", "required": false, "in": "query", "description": "Array of channel types", "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ChannelTypeEnum" } } }, { "name": "templates", "required": false, "in": "query", "description": "Array of template IDs or a single template ID", "schema": { "type": "array", "items": { "type": "string" } } }, { "name": "emails", "required": false, "in": "query", "description": "Array of email addresses or a single email address", "schema": { "type": "array", "items": { "type": "string" } } }, { "name": "search", "required": false, "in": "query", "deprecated": true, "description": "Search term (deprecated)", "schema": { "type": "string" } }, { "name": "subscriberIds", "required": false, "in": "query", "description": "Array of subscriber IDs or a single subscriber ID", "schema": { "type": "array", "items": { "type": "string" } } }, { "name": "page", "required": false, "in": "query", "description": "Page number for pagination", "schema": { "default": 0, "type": "number" } }, { "name": "transactionId", "required": false, "in": "query", "description": "Transaction ID for filtering", "schema": { "type": "string" } }, { "name": "after", "required": false, "in": "query", "description": "Date filter for records after this timestamp", "schema": { "type": "string" } }, { "name": "before", "required": false, "in": "query", "description": "Date filter for records before this timestamp", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "description": "", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ActivitiesResponseDto" } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Notifications"], "security": [ { "secretKey": [] }, { "bearerAuth": [] } ] } }, "/v1/notifications/stats": { "get": { "operationId": "NotificationsController_getActivityStats", "x-speakeasy-group": "Notifications.Stats", "summary": "Get notification statistics", "parameters": [ { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "description": "OK", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ActivityStatsResponseDto" } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Notifications"], "security": [ { "secretKey": [] }, { "bearerAuth": [] } ] } }, "/v1/notifications/graph/stats": { "get": { "operationId": "NotificationsController_getActivityGraphStats", "x-speakeasy-name-override": "graph", "x-speakeasy-group": "Notifications.Stats", "summary": "Get notification graph statistics", "parameters": [ { "name": "days", "required": false, "in": "query", "schema": { "type": "number" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "description": "OK", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/ActivityGraphStatesResponse" } } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Notifications"], "security": [ { "secretKey": [] }, { "bearerAuth": [] } ] } }, "/v1/notifications/{notificationId}": { "get": { "operationId": "NotificationsController_getNotification", "summary": "Get notification", "parameters": [ { "name": "notificationId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "description": "OK", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ActivityNotificationResponseDto" } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Notifications"], "security": [ { "secretKey": [] }, { "bearerAuth": [] } ] } }, "/v1/integrations": { "get": { "operationId": "IntegrationsController_listIntegrations", "summary": "Get integrations", "description": "Return all the integrations the user has created for that organization. Review v.0.17.0 changelog for a breaking change", "parameters": [ { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "description": "The list of integrations belonging to the organization that are successfully returned.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/IntegrationResponseDto" } } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Integrations"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] }, "post": { "operationId": "IntegrationsController_createIntegration", "summary": "Create integration", "description": "Create an integration for the current environment the user is based on the API key provided", "parameters": [ { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateIntegrationRequestDto" } } } }, "responses": { "201": { "description": "Created", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IntegrationResponseDto" } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Integrations"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] } }, "/v1/integrations/active": { "get": { "operationId": "IntegrationsController_getActiveIntegrations", "x-speakeasy-name-override": "listActive", "summary": "Get active integrations", "description": "Return all the active integrations the user has created for that organization. Review v.0.17.0 changelog for a breaking change", "parameters": [ { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "description": "The list of active integrations belonging to the organization that are successfully returned.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/IntegrationResponseDto" } } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Integrations"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] } }, "/v1/integrations/webhook/provider/{providerOrIntegrationId}/status": { "get": { "operationId": "IntegrationsController_getWebhookSupportStatus", "x-speakeasy-group": "Integrations.Webhooks", "summary": "Get webhook support status for provider", "description": "Return the status of the webhook for this provider, if it is supported or if it is not based on a boolean value", "parameters": [ { "name": "providerOrIntegrationId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "description": "The status of the webhook for the provider requested", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "type": "boolean" } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Integrations"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] } }, "/v1/integrations/{integrationId}": { "put": { "operationId": "IntegrationsController_updateIntegrationById", "summary": "Update integration", "parameters": [ { "name": "integrationId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpdateIntegrationRequestDto" } } } }, "responses": { "200": { "description": "OK", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IntegrationResponseDto" } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "The integration with the integrationId provided does not exist in the database.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Integrations"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] }, "delete": { "operationId": "IntegrationsController_removeIntegration", "summary": "Delete integration", "parameters": [ { "name": "integrationId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "description": "OK", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/IntegrationResponseDto" } } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Integrations"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] } }, "/v1/integrations/{integrationId}/set-primary": { "post": { "operationId": "IntegrationsController_setIntegrationAsPrimary", "x-speakeasy-name-override": "setAsPrimary", "summary": "Set integration as primary", "parameters": [ { "name": "integrationId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "description": "OK", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IntegrationResponseDto" } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "The integration with the integrationId provided does not exist in the database.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Integrations"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] } }, "/v1/subscribers": { "get": { "operationId": "SubscribersV1Controller_listSubscribers", "x-speakeasy-pagination": { "type": "offsetLimit", "inputs": [ { "name": "page", "in": "parameters", "type": "page" }, { "name": "limit", "in": "parameters", "type": "limit" } ], "outputs": { "results": "$.data.resultArray" } }, "summary": "Get subscribers", "description": "Returns a list of subscribers, could paginated using the `page` and `limit` query parameter", "parameters": [ { "name": "page", "required": false, "in": "query", "schema": { "type": "number" } }, { "name": "limit", "required": false, "in": "query", "schema": { "maximum": 100, "default": 10, "type": "number" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "description": "", "content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/PaginatedResponseDto" }, { "properties": { "data": { "type": "array", "items": { "$ref": "#/components/schemas/SubscriberResponseDto" } } } } ] } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Subscribers"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] }, "post": { "operationId": "SubscribersV1Controller_createSubscriber", "summary": "Create subscriber", "description": "Creates a subscriber entity, in the Novu platform. The subscriber will be later used to receive notifications, and access notification feeds. Communication credentials such as email, phone number, and 3 rd party credentials i.e slack tokens could be later associated to this entity.", "parameters": [ { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateSubscriberRequestDto" } } } }, "responses": { "201": { "description": "Created", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SubscriberResponseDto" } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Subscribers"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] } }, "/v1/subscribers/{subscriberId}": { "get": { "operationId": "SubscribersV1Controller_getSubscriber", "summary": "Get subscriber", "description": "Get subscriber by your internal id used to identify the subscriber", "parameters": [ { "name": "subscriberId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "includeTopics", "required": false, "in": "query", "description": "Includes the topics associated with the subscriber", "schema": { "type": "boolean" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "description": "OK", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SubscriberResponseDto" } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Subscribers"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] }, "put": { "operationId": "SubscribersV1Controller_updateSubscriber", "summary": "Update subscriber", "description": "Used to update the subscriber entity with new information", "parameters": [ { "name": "subscriberId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpdateSubscriberRequestDto" } } } }, "responses": { "200": { "description": "OK", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SubscriberResponseDto" } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Subscribers"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] }, "delete": { "operationId": "SubscribersV1Controller_removeSubscriber", "summary": "Delete subscriber", "description": "Deletes a subscriber entity from the Novu platform", "parameters": [ { "name": "subscriberId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "description": "OK", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteSubscriberResponseDto" } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Subscribers"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] } }, "/v1/subscribers/bulk": { "post": { "operationId": "SubscribersV1Controller_bulkCreateSubscribers", "x-speakeasy-name-override": "createBulk", "summary": "Bulk create subscribers", "description": "\n Using this endpoint you can create multiple subscribers at once, to avoid multiple calls to the API.\n The bulk API is limited to 500 subscribers per request.\n ", "parameters": [ { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BulkSubscriberCreateDto" } } } }, "responses": { "201": { "description": "Created", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BulkCreateSubscriberResponseDto" } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Subscribers"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] } }, "/v1/subscribers/{subscriberId}/credentials": { "put": { "operationId": "SubscribersV1Controller_updateSubscriberChannel", "x-speakeasy-group": "Subscribers.Credentials", "summary": "Update subscriber credentials", "description": "Subscriber credentials associated to the delivery methods such as slack and push tokens.", "parameters": [ { "name": "subscriberId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpdateSubscriberChannelRequestDto" } } } }, "responses": { "200": { "description": "OK", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SubscriberResponseDto" } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Subscribers"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] }, "patch": { "operationId": "SubscribersV1Controller_modifySubscriberChannel", "x-speakeasy-name-override": "append", "x-speakeasy-group": "Subscribers.Credentials", "summary": "Modify subscriber credentials", "description": "Subscriber credentials associated to the delivery methods such as slack and push tokens.\n This endpoint appends provided credentials and deviceTokens to the existing ones.", "parameters": [ { "name": "subscriberId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpdateSubscriberChannelRequestDto" } } } }, "responses": { "200": { "description": "OK", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SubscriberResponseDto" } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Subscribers"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] } }, "/v1/subscribers/{subscriberId}/credentials/{providerId}": { "delete": { "operationId": "SubscribersV1Controller_deleteSubscriberCredentials", "x-speakeasy-group": "Subscribers.Credentials", "summary": "Delete subscriber credentials by providerId", "description": "Delete subscriber credentials such as slack and expo tokens.", "parameters": [ { "name": "subscriberId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "providerId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "204": { "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "description": "" }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Subscribers"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] } }, "/v1/subscribers/{subscriberId}/online-status": { "patch": { "operationId": "SubscribersV1Controller_updateSubscriberOnlineFlag", "x-speakeasy-name-override": "updateOnlineFlag", "x-speakeasy-group": "Subscribers.properties", "summary": "Update subscriber online status", "description": "Used to update the subscriber isOnline flag.", "parameters": [ { "name": "subscriberId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpdateSubscriberOnlineFlagRequestDto" } } } }, "responses": { "200": { "description": "OK", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SubscriberResponseDto" } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Subscribers"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] } }, "/v1/subscribers/{subscriberId}/preferences": { "get": { "operationId": "SubscribersV1Controller_listSubscriberPreferences", "x-speakeasy-name-override": "list", "x-speakeasy-group": "Subscribers.Preferences", "summary": "Get subscriber preferences", "parameters": [ { "name": "subscriberId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "includeInactiveChannels", "required": false, "in": "query", "description": "A flag which specifies if the inactive workflow channels should be included in the retrieved preferences. Default is true", "schema": { "type": "boolean" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "description": "OK", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/UpdateSubscriberPreferenceResponseDto" } } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Subscribers"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] }, "patch": { "operationId": "SubscribersV1Controller_updateSubscriberGlobalPreferences", "x-speakeasy-name-override": "updateGlobal", "x-speakeasy-group": "Subscribers.Preferences", "summary": "Update subscriber global preferences", "parameters": [ { "name": "subscriberId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpdateSubscriberGlobalPreferencesRequestDto" } } } }, "responses": { "200": { "description": "OK", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpdateSubscriberPreferenceGlobalResponseDto" } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Subscribers"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] } }, "/v1/subscribers/{subscriberId}/preferences/{parameter}": { "get": { "operationId": "SubscribersV1Controller_getSubscriberPreferenceByLevel", "x-speakeasy-name-override": "retrieveByLevel", "x-speakeasy-group": "Subscribers.Preferences", "summary": "Get subscriber preferences by level", "parameters": [ { "name": "includeInactiveChannels", "required": false, "in": "query", "description": "A flag which specifies if the inactive workflow channels should be included in the retrieved preferences. Default is true", "schema": { "type": "boolean" } }, { "name": "parameter", "required": true, "in": "path", "description": "the preferences level to be retrieved (template / global) ", "x-speakeasy-name-override": "preferenceLevel", "schema": { "enum": ["global", "template"], "type": "string" } }, { "name": "subscriberId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "description": "OK", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/GetSubscriberPreferencesResponseDto" } } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Subscribers"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] }, "patch": { "operationId": "SubscribersV1Controller_updateSubscriberPreference", "x-speakeasy-group": "Subscribers.Preferences", "summary": "Update subscriber preference", "parameters": [ { "name": "subscriberId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "parameter", "required": true, "in": "path", "x-speakeasy-name-override": "workflowId", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpdateSubscriberPreferenceRequestDto" } } } }, "responses": { "200": { "description": "OK", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpdateSubscriberPreferenceResponseDto" } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Subscribers"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] } }, "/v1/subscribers/{subscriberId}/notifications/feed": { "get": { "operationId": "SubscribersV1Controller_getNotificationsFeed", "x-speakeasy-name-override": "feed", "x-speakeasy-group": "Subscribers.Notifications", "summary": "Get in-app notification feed for a particular subscriber", "parameters": [ { "name": "subscriberId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "page", "required": false, "in": "query", "schema": { "type": "number" } }, { "name": "limit", "required": false, "in": "query", "schema": { "maximum": 100, "default": 10, "type": "number" } }, { "name": "read", "required": false, "in": "query", "schema": { "type": "boolean" } }, { "name": "seen", "required": false, "in": "query", "schema": { "type": "boolean" } }, { "name": "payload", "required": false, "in": "query", "description": "Base64 encoded string of the partial payload JSON object", "example": "btoa(JSON.stringify({ foo: 123 })) results in base64 encoded string like eyJmb28iOjEyM30=", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "description": "OK", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FeedResponseDto" } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Subscribers"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] } }, "/v1/subscribers/{subscriberId}/notifications/unseen": { "get": { "operationId": "SubscribersV1Controller_getUnseenCount", "x-speakeasy-name-override": "unseenCount", "x-speakeasy-group": "Subscribers.Notifications", "summary": "Get the unseen in-app notifications count for subscribers feed", "parameters": [ { "name": "subscriberId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "seen", "required": false, "in": "query", "description": "Indicates whether to count seen notifications.", "schema": { "default": false, "type": "boolean" } }, { "name": "limit", "required": false, "in": "query", "description": "The maximum number of notifications to return.", "schema": { "default": 100, "type": "number" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "description": "OK", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UnseenCountResponse" } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Subscribers"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] } }, "/v1/subscribers/{subscriberId}/messages/mark-as": { "post": { "operationId": "SubscribersV1Controller_markMessagesAs", "x-speakeasy-name-override": "markAllAs", "x-speakeasy-group": "Subscribers.Messages", "summary": "Mark a subscriber messages as seen, read, unseen or unread", "parameters": [ { "name": "subscriberId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MessageMarkAsRequestDto" } } } }, "responses": { "201": { "description": "Created", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "type": "array", "items": { "$ref": "#/components/schemas/MessageResponseDto" } } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Subscribers"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] } }, "/v1/subscribers/{subscriberId}/messages/mark-all": { "post": { "operationId": "SubscribersV1Controller_markAllUnreadAsRead", "x-speakeasy-name-override": "markAll", "x-speakeasy-group": "Subscribers.Messages", "summary": "Marks all the subscriber messages as read, unread, seen or unseen. Optionally you can pass feed id (or array) to mark messages of a particular feed.", "parameters": [ { "name": "subscriberId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MarkAllMessageAsRequestDto" } } } }, "responses": { "201": { "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "description": "", "content": { "application/json": { "schema": { "type": "number" } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Subscribers"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] } }, "/v1/subscribers/{subscriberId}/messages/{messageId}/actions/{type}": { "post": { "operationId": "SubscribersV1Controller_markActionAsSeen", "x-speakeasy-name-override": "updateAsSeen", "x-speakeasy-group": "Subscribers.Messages", "summary": "Mark message action as seen", "parameters": [ { "name": "messageId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "type", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "subscriberId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MarkMessageActionAsSeenDto" } } } }, "responses": { "201": { "description": "Created", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/MessageResponseDto" } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Subscribers"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] } }, "/v1/subscribers/{subscriberId}/credentials/{providerId}/oauth/callback": { "get": { "operationId": "SubscribersV1Controller_chatOauthCallback", "x-speakeasy-name-override": "chatAccessOauthCallBack", "x-speakeasy-group": "Subscribers.Authentication", "summary": "Handle providers oauth redirect", "parameters": [ { "name": "subscriberId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "providerId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "hmacHash", "required": true, "in": "query", "description": "HMAC hash for the request", "schema": { "type": "string" } }, { "name": "environmentId", "required": true, "in": "query", "description": "The ID of the environment, must be a valid MongoDB ID", "schema": { "type": "string" } }, { "name": "integrationIdentifier", "required": false, "in": "query", "description": "Optional integration identifier", "schema": { "type": "string" } }, { "name": "code", "required": true, "in": "query", "description": "Optional authorization code returned from the OAuth provider", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Returns plain text response.", "content": { "text/html": { "schema": { "type": "string" } } }, "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } } }, "302": { "description": "Redirects to the specified URL.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Location": { "description": "The URL to redirect to.", "schema": { "type": "string", "example": "https://www.novu.co" } } }, "content": { "application/json": { "schema": { "type": "string" } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Subscribers"], "security": [ { "secretKey": [] } ] } }, "/v1/subscribers/{subscriberId}/credentials/{providerId}/oauth": { "get": { "operationId": "SubscribersV1Controller_chatAccessOauth", "x-speakeasy-name-override": "chatAccessOauth", "x-speakeasy-group": "Subscribers.Authentication", "summary": "Handle chat oauth", "parameters": [ { "name": "subscriberId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "providerId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "hmacHash", "required": true, "in": "query", "description": "HMAC hash for the request", "schema": { "type": "string" } }, { "name": "environmentId", "required": true, "in": "query", "description": "The ID of the environment, must be a valid MongoDB ID", "schema": { "type": "string" } }, { "name": "integrationIdentifier", "required": false, "in": "query", "description": "Optional integration identifier", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "description": "" }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Subscribers"], "security": [ { "secretKey": [] } ] } }, "/v2/subscribers": { "get": { "operationId": "SubscriberController_getSubscribers", "x-speakeasy-name-override": "search", "parameters": [ { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "description": "" }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Subscribers"], "security": [ { "bearerAuth": [] } ] } }, "/v2/subscribers": { "get": { "operationId": "SubscribersController_searchSubscribers", "x-speakeasy-name-override": "search", "summary": "Search for subscribers", "parameters": [ { "name": "after", "required": false, "in": "query", "description": "Cursor for pagination indicating the starting point after which to fetch results.", "schema": { "type": "string" } }, { "name": "before", "required": false, "in": "query", "description": "Cursor for pagination indicating the ending point before which to fetch results.", "schema": { "type": "string" } }, { "name": "email", "required": false, "in": "query", "description": "Email address of the subscriber to filter results.", "schema": { "type": "string" } }, { "name": "name", "required": false, "in": "query", "description": "Name of the subscriber to filter results.", "schema": { "type": "string" } }, { "name": "phone", "required": false, "in": "query", "description": "Phone number of the subscriber to filter results.", "schema": { "type": "string" } }, { "name": "subscriberId", "required": false, "in": "query", "description": "Unique identifier of the subscriber to filter results.", "schema": { "type": "string" } }, { "name": "limit", "required": false, "in": "query", "schema": { "type": "number" } }, { "name": "orderDirection", "required": false, "in": "query", "schema": { "enum": ["ASC", "DESC"], "type": "string" } }, { "name": "orderBy", "required": false, "in": "query", "schema": {} }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "description": "List of subscribers retrieved successfully.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ListSubscribersResponseDto" } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Subscribers"], "security": [ { "api-key": [] }, { "bearerAuth": [] } ] } }, "/v1/messages": { "get": { "operationId": "MessagesController_getMessages", "summary": "Get messages", "description": "Returns a list of messages, could paginate using the `page` query parameter", "parameters": [ { "name": "channel", "required": false, "in": "query", "schema": { "$ref": "#/components/schemas/ChannelTypeEnum" } }, { "name": "subscriberId", "required": false, "in": "query", "schema": { "type": "string" } }, { "name": "transactionId", "required": false, "in": "query", "schema": { "type": "array", "items": { "type": "string" } } }, { "name": "page", "required": false, "in": "query", "schema": { "default": 0, "type": "number" } }, { "name": "limit", "required": false, "in": "query", "schema": { "default": 10, "type": "number" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "description": "", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ActivitiesResponseDto" } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Messages"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] } }, "/v1/messages/{messageId}": { "delete": { "operationId": "MessagesController_deleteMessage", "summary": "Delete message", "description": "Deletes a message entity from the Novu platform", "parameters": [ { "name": "messageId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "description": "OK", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteMessageResponseDto" } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Messages"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] } }, "/v1/messages/transaction/{transactionId}": { "delete": { "operationId": "MessagesController_deleteMessagesByTransactionId", "x-speakeasy-name-override": "deleteByTransactionId", "summary": "Delete messages by transactionId", "description": "Deletes messages entity from the Novu platform using TransactionId of message", "parameters": [ { "name": "channel", "required": false, "in": "query", "description": "The channel of the message to be deleted", "schema": { "enum": ["in_app", "email", "sms", "chat", "push"], "type": "string" } }, { "name": "transactionId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "204": { "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "description": "" }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Messages"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] } }, "/v1/topics": { "post": { "operationId": "TopicsController_createTopic", "summary": "Topic creation", "description": "Create a topic", "parameters": [ { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateTopicRequestDto" } } } }, "responses": { "201": { "description": "Created", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateTopicResponseDto" } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Topics"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] }, "get": { "operationId": "TopicsController_listTopics", "summary": "Get topic list filtered ", "description": "Returns a list of topics that can be paginated using the `page` query parameter and filtered by the topic key with the `key` query parameter", "parameters": [ { "name": "page", "required": false, "in": "query", "example": 0, "description": "The page number to retrieve (starts from 0)", "schema": { "format": "int64", "type": "integer" } }, { "name": "pageSize", "required": false, "in": "query", "example": 10, "description": "The number of items to return per page (default: 10)", "schema": { "format": "int64", "type": "integer" } }, { "name": "key", "required": false, "in": "query", "example": "exampleKey", "description": "A filter key to apply to the results", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "description": "", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FilterTopicsResponseDto" } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Topics"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] } }, "/v1/topics/{topicKey}/subscribers": { "post": { "operationId": "TopicsController_assign", "x-speakeasy-name-override": "assign", "x-speakeasy-group": "Topics.Subscribers", "summary": "Subscribers addition", "description": "Add subscribers to a topic by key", "parameters": [ { "name": "topicKey", "required": true, "in": "path", "description": "The topic key", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AddSubscribersRequestDto" } } } }, "responses": { "200": { "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "description": "", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AssignSubscriberToTopicDto" } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Topics"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] } }, "/v1/topics/{topicKey}/subscribers/{externalSubscriberId}": { "get": { "operationId": "TopicsController_getTopicSubscriber", "x-speakeasy-group": "Topics.Subscribers", "summary": "Check topic subscriber", "description": "Check if a subscriber belongs to a certain topic", "parameters": [ { "name": "topicKey", "required": true, "in": "path", "description": "The topic key", "schema": { "type": "string" } }, { "name": "externalSubscriberId", "required": true, "in": "path", "description": "The external subscriber id", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "description": "", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TopicSubscriberDto" } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Topics"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] } }, "/v1/topics/{topicKey}/subscribers/removal": { "post": { "operationId": "TopicsController_removeSubscribers", "x-speakeasy-name-override": "remove", "x-speakeasy-group": "Topics.Subscribers", "summary": "Subscribers removal", "description": "Remove subscribers from a topic", "parameters": [ { "name": "topicKey", "required": true, "in": "path", "description": "The topic key", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RemoveSubscribersRequestDto" } } } }, "responses": { "204": { "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "description": "" }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Topics"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] } }, "/v1/topics/{topicKey}": { "delete": { "operationId": "TopicsController_deleteTopic", "summary": "Delete topic", "description": "Delete a topic by its topic key if it has no subscribers", "parameters": [ { "name": "topicKey", "required": true, "in": "path", "description": "The topic key", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "204": { "description": "The topic has been deleted correctly", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Topics"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] }, "get": { "operationId": "TopicsController_getTopic", "summary": "Get topic", "description": "Get a topic by its topic key", "parameters": [ { "name": "topicKey", "required": true, "in": "path", "description": "The topic key", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "description": "OK", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GetTopicResponseDto" } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Topics"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] }, "patch": { "operationId": "TopicsController_renameTopic", "x-speakeasy-name-override": "rename", "summary": "Rename a topic", "description": "Rename a topic by providing a new name", "parameters": [ { "name": "topicKey", "required": true, "in": "path", "description": "The topic key", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RenameTopicRequestDto" } } } }, "responses": { "200": { "description": "OK", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RenameTopicResponseDto" } } } }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Topics"], "security": [ { "bearerAuth": [] }, { "secretKey": [] } ] } }, "/v2/workflows": { "post": { "operationId": "WorkflowController_create", "parameters": [ { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "201": { "description": "" }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Workflows"], "security": [ { "bearerAuth": [] } ] }, "get": { "operationId": "WorkflowController_searchWorkflows", "parameters": [ { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "description": "" }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Workflows"], "security": [ { "bearerAuth": [] } ] } }, "/v2/workflows/{workflowId}/sync": { "put": { "operationId": "WorkflowController_sync", "parameters": [ { "name": "workflowId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "description": "" }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Workflows"], "security": [ { "bearerAuth": [] } ] } }, "/v2/workflows/{workflowId}": { "put": { "operationId": "WorkflowController_update", "parameters": [ { "name": "workflowId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "description": "" }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Workflows"], "security": [ { "bearerAuth": [] } ] }, "get": { "operationId": "WorkflowController_getWorkflow", "parameters": [ { "name": "workflowId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "environmentId", "required": true, "in": "query", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "description": "" }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Workflows"], "security": [ { "bearerAuth": [] } ] }, "delete": { "operationId": "WorkflowController_removeWorkflow", "parameters": [ { "name": "workflowId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "204": { "description": "" }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Workflows"], "security": [ { "bearerAuth": [] } ] }, "patch": { "operationId": "WorkflowController_patchWorkflow", "parameters": [ { "name": "workflowId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "description": "" }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Workflows"], "security": [ { "bearerAuth": [] } ] } }, "/v2/workflows/{workflowId}/step/{stepId}/preview": { "post": { "operationId": "WorkflowController_generatePreview", "parameters": [ { "name": "workflowId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "stepId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "201": { "description": "" }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Workflows"], "security": [ { "bearerAuth": [] } ] } }, "/v2/workflows/{workflowId}/steps/{stepId}": { "get": { "operationId": "WorkflowController_getWorkflowStepData", "x-speakeasy-name-override": "getStepData", "parameters": [ { "name": "workflowId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "stepId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "description": "" }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Workflows"], "security": [ { "bearerAuth": [] } ] }, "patch": { "operationId": "WorkflowController_patchWorkflowStepData", "parameters": [ { "name": "workflowId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "stepId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "description": "" }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Workflows"], "security": [ { "bearerAuth": [] } ] } }, "/v2/workflows/{workflowId}/test-data": { "get": { "operationId": "WorkflowController_getWorkflowTestData", "x-speakeasy-name-override": "getWorkflowTestData", "parameters": [ { "name": "workflowId", "required": true, "in": "path", "schema": { "type": "string" } }, { "name": "idempotency-key", "in": "header", "description": "A header for idempotency purposes", "required": false, "schema": { "type": "string" } } ], "responses": { "200": { "description": "" }, "400": { "description": "Bad Request", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "401": { "description": "Unauthorized", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "403": { "description": "Forbidden", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "404": { "description": "Not Found", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "405": { "description": "Method Not Allowed", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "409": { "description": "Conflict", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "413": { "description": "Payload Too Large", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "414": { "description": "URI Too Long", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "415": { "description": "Unsupported Media Type", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "422": { "description": "Unprocessable Entity", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationErrorDto" } } } }, "429": { "description": "The client has sent too many requests in a given amount of time. ", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "API rate limit exceeded" } } } }, "500": { "description": "Internal Server Error", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorDto" } } } }, "503": { "description": "The server is currently unable to handle the request due to a temporary overload or scheduled maintenance, which will likely be alleviated after some delay.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" }, "RateLimit-Limit": { "$ref": "#/components/headers/RateLimit-Limit" }, "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimit-Remaining" }, "RateLimit-Reset": { "$ref": "#/components/headers/RateLimit-Reset" }, "RateLimit-Policy": { "$ref": "#/components/headers/RateLimit-Policy" }, "Idempotency-Key": { "$ref": "#/components/headers/Idempotency-Key" }, "Idempotency-Replay": { "$ref": "#/components/headers/Idempotency-Replay" }, "Retry-After": { "$ref": "#/components/headers/Retry-After" } }, "content": { "application/json": { "schema": { "type": "string", "example": "Please wait some time, then try again." } } } } }, "tags": ["Workflows"], "security": [ { "bearerAuth": [] } ] } } }, "info": { "title": "Novu API", "description": "Novu REST API. Please see https://docs.novu.co/api-reference for more details.", "version": "1.0", "contact": { "name": "Novu Support", "url": "https://discord.gg/novu", "email": "support@novu.co" }, "termsOfService": "https://novu.co/terms", "license": { "name": "MIT", "url": "https://opensource.org/license/mit" } }, "tags": [ { "name": "Events", "description": "Events represent a change in state of a subscriber. They are used to trigger workflows, and enable you to send notifications to subscribers based on their actions.", "externalDocs": { "url": "https://docs.novu.co/workflows" } }, { "name": "Subscribers", "description": "A subscriber in Novu represents someone who should receive a message. A subscriber’s profile information contains important attributes about the subscriber that will be used in messages (name, email). The subscriber object can contain other key-value pairs that can be used to further personalize your messages.", "externalDocs": { "url": "https://docs.novu.co/subscribers/subscribers" } }, { "name": "Topics", "description": "Topics are a way to group subscribers together so that they can be notified of events at once. A topic is identified by a custom key. This can be helpful for things like sending out marketing emails or notifying users of new features. Topics can also be used to send notifications to the subscribers who have been grouped together based on their interests, location, activities and much more.", "externalDocs": { "url": "https://docs.novu.co/subscribers/topics" } }, { "name": "Notification", "description": "A notification conveys information from source to recipient, triggered by a workflow acting as a message blueprint. Notifications can be individual or bundled as digest for user-friendliness.", "externalDocs": { "url": "https://docs.novu.co/getting-started/introduction" } }, { "name": "Integrations", "description": "With the help of the Integration Store, you can easily integrate your favorite delivery provider. During the runtime of the API, the Integrations Store is responsible for storing the configurations of all the providers.", "externalDocs": { "url": "https://docs.novu.co/platform/integrations/overview" } }, { "name": "Layouts", "description": "Novu allows the creation of layouts - a specific HTML design or structure to wrap content of email notifications. Layouts can be manipulated and assigned to new or existing workflows within the Novu platform, allowing users to create, manage, and assign these layouts to workflows, so they can be reused to structure the appearance of notifications sent through the platform.", "externalDocs": { "url": "https://docs.novu.co/content-creation-design/layouts" } }, { "name": "Workflows", "description": "All notifications are sent via a workflow. Each workflow acts as a container for the logic and blueprint that are associated with a type of notification in your system.", "externalDocs": { "url": "https://docs.novu.co/workflows" } }, { "name": "Notification Templates", "description": "Deprecated. Use Workflows (/workflows) instead, which provide the same functionality under a new name." }, { "name": "Workflow groups", "description": "Workflow groups are used to organize workflows into logical groups." }, { "name": "Changes", "description": "Changes represent a change in state of an environment. They are analagous to a pending pull request in git, enabling you to test changes before they are applied to your environment and atomically apply them when you are ready.", "externalDocs": { "url": "https://docs.novu.co/platform/environments#promoting-pending-changes-to-production" } }, { "name": "Environments", "description": "Novu uses the concept of environments to ensure logical separation of your data and configuration. This means that subscribers, and preferences created in one environment are never accessible to another.", "externalDocs": { "url": "https://docs.novu.co/platform/environments" } }, { "name": "Inbound Parse", "description": "Inbound Webhook is a feature that allows processing of incoming emails for a domain or subdomain. The feature parses the contents of the email and POSTs the information to a specified URL in a multipart/form-data format.", "externalDocs": { "url": "https://docs.novu.co/platform/inbound-parse-webhook" } }, { "name": "Feeds", "description": "Novu provides a notification activity feed that monitors every outgoing message associated with its relevant metadata. This can be used to monitor activity and discover potential issues with a specific provider or a channel type.", "externalDocs": { "url": "https://docs.novu.co/activity-feed" } }, { "name": "Tenants", "description": "A tenant represents a group of users. As a developer, when your apps have organizations, they are referred to as tenants. Tenants in Novu provides the ability to tailor specific notification experiences to users of different groups or organizations.", "externalDocs": { "url": "https://docs.novu.co/tenants" } }, { "name": "Messages", "description": "A message in Novu represents a notification delivered to a recipient on a particular channel. Messages contain information about the request that triggered its delivery, a view of the data sent to the recipient, and a timeline of its lifecycle events. Learn more about messages.", "externalDocs": { "url": "https://docs.novu.co/workflows/messages" } }, { "name": "Organizations", "description": "An organization serves as a separate entity within your Novu account. Each organization you create has its own separate integration store, workflows, subscribers, and API keys. This separation of resources allows you to manage multi-tenant environments and separate domains within a single account.", "externalDocs": { "url": "https://docs.novu.co/platform/organizations" } }, { "name": "Execution Details", "description": "Execution details are used to track the execution of a workflow. They provided detailed information on the execution of a workflow, including the status of each step, the input and output of each step, and the overall status of the execution.", "externalDocs": { "url": "https://docs.novu.co/activity-feed" } } ], "servers": [ { "url": "https://api.novu.co" }, { "url": "https://eu.api.novu.co" } ], "components": { "securitySchemes": { "secretKey": { "type": "apiKey", "name": "Authorization", "in": "header", "description": "API key authentication. Allowed headers-- \"Authorization: ApiKey \"." }, "bearerAuth": { "type": "http", "scheme": "bearer", "bearerFormat": "JWT" } }, "schemas": { "DataWrapperDto": { "type": "object", "properties": { "data": { "type": "object" } }, "required": ["data"] }, "ErrorDto": { "type": "object", "properties": { "statusCode": { "type": "number", "description": "HTTP status code of the error response.", "example": 404 }, "timestamp": { "type": "string", "description": "Timestamp of when the error occurred.", "example": "2024-12-12T13:00:00Z" }, "path": { "type": "string", "description": "The path where the error occurred.", "example": "/api/v1/resource" }, "message": { "type": "string", "description": "A detailed error message.", "example": "Resource not found." }, "ctx": { "type": "object", "description": "Optional context object for additional error details.", "additionalProperties": true, "example": { "workflowId": "some_wf_id", "stepId": "some_wf_id" } }, "errorId": { "type": "string", "description": "Optional unique identifier for the error, useful for tracking using Sentry and \n New Relic, only available for 500.", "example": "abc123" } }, "required": ["statusCode", "timestamp", "path", "message"] }, "ValidationErrorDto": { "type": "object", "properties": { "statusCode": { "type": "number", "description": "HTTP status code of the error response.", "example": 404 }, "timestamp": { "type": "string", "description": "Timestamp of when the error occurred.", "example": "2024-12-12T13:00:00Z" }, "path": { "type": "string", "description": "The path where the error occurred.", "example": "/api/v1/resource" }, "message": { "type": "string", "description": "A detailed error message.", "example": "Resource not found." }, "ctx": { "type": "object", "description": "Optional context object for additional error details.", "additionalProperties": true, "example": { "workflowId": "some_wf_id", "stepId": "some_wf_id" } }, "errorId": { "type": "string", "description": "Optional unique identifier for the error, useful for tracking using Sentry and \n New Relic, only available for 500.", "example": "abc123" }, "errors": { "type": "object", "description": "A record of validation errors keyed by field name", "additionalProperties": { "type": "object", "properties": { "messages": { "type": "array", "items": { "type": "string" } }, "value": { "oneOf": [ { "type": "string", "nullable": true }, { "type": "number" }, { "type": "boolean" }, { "type": "object", "additionalProperties": true }, { "type": "array", "items": { "type": "object", "additionalProperties": true } } ] } }, "required": ["messages", "value"], "example": { "messages": ["Field is required", "Invalid format"], "value": "xx xx xx " } }, "example": { "fieldName1": { "messages": ["Field is required", "Must be a valid email address"], "value": "invalidEmail" }, "fieldName2": { "messages": ["Must be at least 18 years old"], "value": 17 }, "fieldName3": { "messages": ["Must be a boolean value"], "value": true }, "fieldName4": { "messages": ["Must be a valid object"], "value": { "key": "value" } } } } }, "required": ["statusCode", "timestamp", "path", "message", "errors"] }, "TriggerEventResponseDto": { "type": "object", "properties": { "acknowledged": { "type": "boolean", "description": "Indicates whether the trigger was acknowledged or not" }, "status": { "type": "string", "description": "Status of the trigger", "enum": [ "error", "trigger_not_active", "no_workflow_active_steps_defined", "no_workflow_steps_defined", "processed", "no_tenant_found" ] }, "error": { "description": "In case of an error, this field will contain the error message(s)", "type": "array", "items": { "type": "string" } }, "transactionId": { "type": "string", "description": "The returned transaction ID of the trigger" } }, "required": ["acknowledged", "status"] }, "ChannelCredentialsDto": { "type": "object", "properties": { "webhookUrl": { "type": "string", "description": "The URL for the webhook associated with the channel." }, "deviceTokens": { "description": "An array of device tokens for push notifications.", "type": "array", "items": { "type": "string" } } } }, "SubscriberChannelDto": { "type": "object", "properties": { "providerId": { "type": "string", "description": "The ID of the chat or push provider.", "enum": [ "slack", "discord", "msteams", "mattermost", "ryver", "zulip", "grafana-on-call", "getstream", "rocket-chat", "whatsapp-business", "fcm", "apns", "expo", "one-signal", "pushpad", "push-webhook", "pusher-beams" ] }, "integrationIdentifier": { "type": "string", "description": "An optional identifier for the integration." }, "credentials": { "description": "Credentials for the channel.", "allOf": [ { "$ref": "#/components/schemas/ChannelCredentialsDto" } ] } }, "required": ["providerId", "credentials"] }, "SubscriberPayloadDto": { "type": "object", "properties": { "subscriberId": { "type": "string", "description": "The internal identifier you used to create this subscriber, usually correlates to the id the user in your systems" }, "email": { "type": "string", "description": "The email address of the subscriber." }, "firstName": { "type": "string", "description": "The first name of the subscriber." }, "lastName": { "type": "string", "description": "The last name of the subscriber." }, "phone": { "type": "string", "description": "The phone number of the subscriber." }, "avatar": { "type": "string", "description": "An HTTP URL to the profile image of your subscriber." }, "locale": { "type": "string", "description": "The locale of the subscriber." }, "data": { "type": "object", "description": "An optional payload object that can contain any properties.", "additionalProperties": { "oneOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } }, { "type": "boolean" }, { "type": "number" } ] } }, "channels": { "description": "An optional array of subscriber channels.", "type": "array", "items": { "$ref": "#/components/schemas/SubscriberChannelDto" } } }, "required": ["subscriberId"] }, "TenantPayloadDto": { "type": "object", "properties": { "identifier": { "type": "string" }, "name": { "type": "string" }, "data": { "type": "object" } } }, "TriggerRecipientsTypeEnum": { "type": "string", "enum": ["Subscriber", "Topic"] }, "TopicPayloadDto": { "type": "object", "properties": { "topicKey": { "type": "string" }, "type": { "$ref": "#/components/schemas/TriggerRecipientsTypeEnum" } }, "required": ["topicKey", "type"] }, "WorkflowToStepControlValuesDto": { "type": "object", "properties": { "steps": { "type": "object", "description": "A mapping of step IDs to their corresponding data.", "additionalProperties": { "type": "object", "additionalProperties": true } } } }, "TriggerEventRequestDto": { "type": "object", "properties": { "name": { "type": "string", "description": "The trigger identifier of the workflow you wish to send. This identifier can be found on the workflow page.", "example": "workflow_identifier" }, "payload": { "type": "object", "description": "The payload object is used to pass additional custom information that could be \n used to render the workflow, or perform routing rules based on it. \n This data will also be available when fetching the notifications feed from the API to display certain parts of the UI.", "additionalProperties": true, "example": { "comment_id": "string", "post": { "text": "string" } } }, "bridgeUrl": { "type": "string", "description": "A URL to bridge for additional processing.", "example": "https://example.com/bridge" }, "overrides": { "type": "object", "description": "This could be used to override provider specific configurations", "example": { "fcm": { "data": { "key": "value" } } }, "additionalProperties": { "type": "object", "additionalProperties": true } }, "to": { "description": "The recipients list of people who will receive the notification.", "oneOf": [ { "type": "array", "items": { "oneOf": [ { "$ref": "#/components/schemas/SubscriberPayloadDto" }, { "$ref": "#/components/schemas/TopicPayloadDto" }, { "type": "string", "description": "Unique identifier of a subscriber in your systems", "example": "SUBSCRIBER_ID" } ] } }, { "type": "string", "description": "Unique identifier of a subscriber in your systems", "example": "SUBSCRIBER_ID" }, { "$ref": "#/components/schemas/SubscriberPayloadDto" }, { "$ref": "#/components/schemas/TopicPayloadDto" } ] }, "transactionId": { "type": "string", "description": "A unique identifier for this transaction, we will generate a UUID if not provided." }, "actor": { "description": "It is used to display the Avatar of the provided actor's subscriber id or actor object.\n If a new actor object is provided, we will create a new subscriber in our system", "oneOf": [ { "type": "string", "description": "Unique identifier of a subscriber in your systems" }, { "$ref": "#/components/schemas/SubscriberPayloadDto" } ] }, "tenant": { "description": "It is used to specify a tenant context during trigger event.\n Existing tenants will be updated with the provided details.", "oneOf": [ { "type": "string", "description": "Unique identifier of a tenant in your system" }, { "$ref": "#/components/schemas/TenantPayloadDto" } ] }, "controls": { "description": "Additional control configurations.", "allOf": [ { "$ref": "#/components/schemas/WorkflowToStepControlValuesDto" } ] } }, "required": ["name", "to"] }, "BulkTriggerEventDto": { "type": "object", "properties": { "events": { "type": "array", "items": { "$ref": "#/components/schemas/TriggerEventRequestDto" } } }, "required": ["events"] }, "TriggerEventToAllRequestDto": { "type": "object", "properties": { "name": { "type": "string", "description": "The trigger identifier associated for the template you wish to send. This identifier can be found on the template page." }, "payload": { "type": "object", "example": { "comment_id": "string", "post": { "text": "string" } }, "description": "The payload object is used to pass additional information that \n could be used to render the template, or perform routing rules based on it. \n For In-App channel, payload data are also available in ", "additionalProperties": true }, "overrides": { "type": "object", "description": "This could be used to override provider specific configurations", "example": { "fcm": { "data": { "key": "value" } } } }, "transactionId": { "type": "string", "description": "A unique identifier for this transaction, we will generated a UUID if not provided." }, "actor": { "description": "It is used to display the Avatar of the provided actor's subscriber id or actor object.\n If a new actor object is provided, we will create a new subscriber in our system\n ", "oneOf": [ { "type": "string", "description": "Unique identifier of a subscriber in your systems" }, { "$ref": "#/components/schemas/SubscriberPayloadDto" } ] }, "tenant": { "description": "It is used to specify a tenant context during trigger event.\n If a new tenant object is provided, we will create a new tenant.\n ", "oneOf": [ { "type": "string", "description": "Unique identifier of a tenant in your system" }, { "$ref": "#/components/schemas/TenantPayloadDto" } ] } }, "required": ["name", "payload", "transactionId", "actor", "tenant"] }, "DataBooleanDto": { "type": "object", "properties": { "data": { "type": "boolean" } }, "required": ["data"] }, "ChannelTypeEnum": { "type": "string", "description": "Channel type through which the message is sent", "enum": ["in_app", "email", "sms", "chat", "push"] }, "StepTypeEnum": { "type": "string", "description": "Channels of the notification", "enum": ["in_app", "email", "sms", "chat", "push", "digest", "trigger", "delay", "custom"] }, "ActivityNotificationSubscriberResponseDto": { "type": "object", "properties": { "firstName": { "type": "string", "description": "First name of the subscriber" }, "subscriberId": { "type": "string", "description": "External unique identifier of the subscriber" }, "_id": { "type": "string", "description": "Internal to Novu unique identifier of the subscriber" }, "lastName": { "type": "string", "description": "Last name of the subscriber" }, "email": { "type": "string", "description": "Email address of the subscriber" }, "phone": { "type": "string", "description": "Phone number of the subscriber" } }, "required": ["subscriberId", "_id"] }, "NotificationTriggerVariable": { "type": "object", "properties": { "name": { "type": "string", "description": "Name of the variable" } }, "required": ["name"] }, "NotificationTriggerDto": { "type": "object", "properties": { "type": { "type": "string", "enum": ["event"], "description": "Type of the trigger" }, "identifier": { "type": "string", "description": "Identifier of the trigger" }, "variables": { "description": "Variables of the trigger", "type": "array", "items": { "$ref": "#/components/schemas/NotificationTriggerVariable" } }, "subscriberVariables": { "description": "Subscriber variables of the trigger", "type": "array", "items": { "$ref": "#/components/schemas/NotificationTriggerVariable" } } }, "required": ["type", "identifier", "variables"] }, "ActivityNotificationTemplateResponseDto": { "type": "object", "properties": { "_id": { "type": "string", "description": "Unique identifier of the template" }, "name": { "type": "string", "description": "Name of the template" }, "triggers": { "description": "Triggers of the template", "type": "array", "items": { "$ref": "#/components/schemas/NotificationTriggerDto" } } }, "required": ["name", "triggers"] }, "DigestTypeEnum": { "type": "string", "description": "The Digest Type", "enum": ["regular", "backoff", "timed"] }, "DigestUnitEnum": { "type": "string", "description": "Regular digest: Unit for backoff", "enum": ["seconds", "minutes", "hours", "days", "weeks", "months"] }, "OrdinalEnum": { "type": "string", "description": "Ordinal position for the digest", "enum": ["1", "2", "3", "4", "5", "last"] }, "OrdinalValueEnum": { "type": "string", "description": "Value of the ordinal", "enum": [ "day", "weekday", "weekend", "sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday" ] }, "MonthlyTypeEnum": { "type": "string", "description": "Type of monthly schedule", "enum": ["each", "on"] }, "DigestTimedConfigDto": { "type": "object", "properties": { "atTime": { "type": "string", "description": "Time at which the digest is triggered" }, "weekDays": { "type": "array", "description": "Days of the week for the digest", "items": { "type": "string", "enum": ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"] } }, "monthDays": { "description": "Specific days of the month for the digest", "type": "array", "items": { "type": "number" } }, "ordinal": { "$ref": "#/components/schemas/OrdinalEnum" }, "ordinalValue": { "$ref": "#/components/schemas/OrdinalValueEnum" }, "monthlyType": { "$ref": "#/components/schemas/MonthlyTypeEnum" }, "cronExpression": { "type": "string", "description": "Cron expression for scheduling" } } }, "DigestMetadataDto": { "type": "object", "properties": { "digestKey": { "type": "string", "description": "Optional key for the digest" }, "amount": { "type": "number", "description": "Amount for the digest" }, "unit": { "type": "string", "description": "Unit of the digest", "enum": ["seconds", "minutes", "hours", "days", "weeks", "months"] }, "type": { "$ref": "#/components/schemas/DigestTypeEnum" }, "events": { "type": "array", "items": { "type": "object", "additionalProperties": true }, "description": "Optional array of events associated with the digest, represented as key-value pairs" }, "backoff": { "type": "boolean", "description": "Regular digest: Indicates if backoff is enabled for the regular digest" }, "backoffAmount": { "type": "number", "description": "Regular digest: Amount for backoff" }, "backoffUnit": { "$ref": "#/components/schemas/DigestUnitEnum" }, "updateMode": { "type": "boolean", "description": "Regular digest: Indicates if the digest should update" }, "timed": { "description": "Configuration for timed digest", "allOf": [ { "$ref": "#/components/schemas/DigestTimedConfigDto" } ] } }, "required": ["type"] }, "ExecutionDetailsStatusEnum": { "type": "string", "description": "Status of the execution detail", "enum": ["Success", "Warning", "Failed", "Pending", "Queued", "ReadConfirmation"] }, "ProvidersIdEnum": { "type": "string", "description": "Provider ID of the job", "enum": [ "emailjs", "mailgun", "mailjet", "mandrill", "nodemailer", "postmark", "sendgrid", "sendinblue", "ses", "netcore", "infobip-email", "resend", "plunk", "mailersend", "mailtrap", "clickatell", "outlook365", "novu-email", "sparkpost", "email-webhook", "braze", "nexmo", "plivo", "sms77", "sms-central", "sns", "telnyx", "twilio", "gupshup", "firetext", "infobip-sms", "burst-sms", "bulk-sms", "isend-sms", "forty-six-elks", "kannel", "maqsam", "termii", "africas-talking", "novu-sms", "sendchamp", "generic-sms", "clicksend", "bandwidth", "messagebird", "simpletexting", "azure-sms", "ring-central", "brevo-sms", "eazy-sms", "mobishastra", "fcm", "apns", "expo", "one-signal", "pushpad", "push-webhook", "pusher-beams", "novu", "slack", "discord", "msteams", "mattermost", "ryver", "zulip", "grafana-on-call", "getstream", "rocket-chat", "whatsapp-business" ] }, "ExecutionDetailsSourceEnum": { "type": "string", "description": "Source of the execution detail", "enum": ["Credentials", "Internal", "Payload", "Webhook"] }, "ActivityNotificationExecutionDetailResponseDto": { "type": "object", "properties": { "_id": { "type": "string", "description": "Unique identifier of the execution detail" }, "createdAt": { "type": "string", "description": "Creation time of the execution detail" }, "status": { "$ref": "#/components/schemas/ExecutionDetailsStatusEnum" }, "detail": { "type": "string", "description": "Detailed information about the execution" }, "isRetry": { "type": "boolean", "description": "Whether the execution is a retry or not" }, "isTest": { "type": "boolean", "description": "Whether the execution is a test or not" }, "providerId": { "$ref": "#/components/schemas/ProvidersIdEnum" }, "raw": { "type": "string", "description": "Raw data of the execution" }, "source": { "$ref": "#/components/schemas/ExecutionDetailsSourceEnum" } }, "required": ["_id", "status", "detail", "isRetry", "isTest", "providerId", "source"] }, "BuilderFieldTypeEnum": { "type": "string", "enum": ["BOOLEAN", "TEXT", "DATE", "NUMBER", "STATEMENT", "LIST", "MULTI_LIST", "GROUP"] }, "FieldFilterPartDto": { "type": "object", "properties": { "field": { "type": "string" }, "value": { "type": "string" }, "operator": { "type": "string", "enum": [ "LARGER", "SMALLER", "LARGER_EQUAL", "SMALLER_EQUAL", "EQUAL", "NOT_EQUAL", "ALL_IN", "ANY_IN", "NOT_IN", "BETWEEN", "NOT_BETWEEN", "LIKE", "NOT_LIKE", "IN" ] }, "on": { "type": "string", "enum": ["subscriber", "payload"] } }, "required": ["field", "value", "operator", "on"] }, "StepFilterDto": { "type": "object", "properties": { "isNegated": { "type": "boolean" }, "type": { "$ref": "#/components/schemas/BuilderFieldTypeEnum" }, "value": { "type": "string", "enum": ["AND", "OR"] }, "children": { "type": "array", "items": { "$ref": "#/components/schemas/FieldFilterPartDto" } } }, "required": ["isNegated", "type", "value", "children"] }, "MessageTemplateDto": { "type": "object", "properties": {} }, "ActivityNotificationStepResponseDto": { "type": "object", "properties": { "_id": { "type": "string", "description": "Unique identifier of the step" }, "active": { "type": "boolean", "description": "Whether the step is active or not" }, "replyCallback": { "type": "object", "description": "Reply callback settings" }, "controlVariables": { "type": "object", "description": "Control variables" }, "metadata": { "type": "object", "description": "Metadata for the workflow step" }, "issues": { "type": "object", "description": "Step issues" }, "filters": { "description": "Filter criteria for the step", "type": "array", "items": { "$ref": "#/components/schemas/StepFilterDto" } }, "template": { "description": "Optional template for the step", "allOf": [ { "$ref": "#/components/schemas/MessageTemplateDto" } ] }, "variants": { "description": "Variants of the step", "type": "array", "items": { "$ref": "#/components/schemas/ActivityNotificationStepResponseDto" } }, "_templateId": { "type": "string", "description": "The identifier for the template associated with this step" }, "name": { "type": "string", "description": "The name of the step" }, "_parentId": { "type": "string", "description": "The unique identifier for the parent step" } }, "required": ["_id", "active", "filters", "_templateId"] }, "ActivityNotificationJobResponseDto": { "type": "object", "properties": { "_id": { "type": "string", "description": "Unique identifier of the job" }, "type": { "type": "string", "description": "Type of the job" }, "digest": { "description": "Optional digest for the job, including metadata and events", "allOf": [ { "$ref": "#/components/schemas/DigestMetadataDto" } ] }, "executionDetails": { "description": "Execution details of the job", "type": "array", "items": { "$ref": "#/components/schemas/ActivityNotificationExecutionDetailResponseDto" } }, "step": { "description": "Step details of the job", "allOf": [ { "$ref": "#/components/schemas/ActivityNotificationStepResponseDto" } ] }, "payload": { "type": "object", "description": "Optional payload for the job" }, "providerId": { "$ref": "#/components/schemas/ProvidersIdEnum" }, "status": { "type": "string", "description": "Status of the job" }, "updatedAt": { "type": "string", "description": "Updated time of the notification" } }, "required": ["_id", "type", "executionDetails", "step", "providerId", "status"] }, "ActivityNotificationResponseDto": { "type": "object", "properties": { "_id": { "type": "string", "description": "Unique identifier of the notification" }, "_environmentId": { "type": "string", "description": "Environment ID of the notification" }, "_organizationId": { "type": "string", "description": "Organization ID of the notification" }, "_subscriberId": { "type": "string", "description": "Subscriber ID of the notification" }, "transactionId": { "type": "string", "description": "Transaction ID of the notification" }, "_templateId": { "type": "string", "description": "Template ID of the notification" }, "_digestedNotificationId": { "type": "string", "description": "Digested Notification ID" }, "createdAt": { "type": "string", "description": "Creation time of the notification" }, "updatedAt": { "type": "string", "description": "Last updated time of the notification" }, "channels": { "type": "array", "items": { "$ref": "#/components/schemas/StepTypeEnum" } }, "subscriber": { "description": "Subscriber of the notification", "allOf": [ { "$ref": "#/components/schemas/ActivityNotificationSubscriberResponseDto" } ] }, "template": { "description": "Template of the notification", "allOf": [ { "$ref": "#/components/schemas/ActivityNotificationTemplateResponseDto" } ] }, "jobs": { "description": "Jobs of the notification", "type": "array", "items": { "$ref": "#/components/schemas/ActivityNotificationJobResponseDto" } }, "payload": { "type": "object", "description": "Payload of the notification" }, "tags": { "description": "Tags associated with the notification", "type": "array", "items": { "type": "string" } }, "controls": { "type": "object", "description": "Controls associated with the notification" }, "to": { "type": "object", "description": "To field for subscriber definition" } }, "required": ["_environmentId", "_organizationId", "_subscriberId", "transactionId"] }, "ActivitiesResponseDto": { "type": "object", "properties": { "hasMore": { "type": "boolean", "description": "Indicates if there are more activities in the result set" }, "data": { "description": "Array of activity notifications", "type": "array", "items": { "$ref": "#/components/schemas/ActivityNotificationResponseDto" } }, "pageSize": { "type": "number", "description": "Page size of the activities" }, "page": { "type": "number", "description": "Current page of the activities" } }, "required": ["hasMore", "data", "pageSize", "page"] }, "ActivityStatsResponseDto": { "type": "object", "properties": { "weeklySent": { "type": "number" }, "monthlySent": { "type": "number" } }, "required": ["weeklySent", "monthlySent"] }, "ActivityGraphStatesResponse": { "type": "object", "properties": { "_id": { "type": "string" }, "count": { "type": "number" }, "templates": { "type": "array", "items": { "type": "string" } }, "channels": { "type": "array", "items": { "type": "string", "enum": ["in_app", "email", "sms", "chat", "push"] } } }, "required": ["_id", "count", "templates", "channels"] }, "CredentialsDto": { "type": "object", "properties": { "apiKey": { "type": "string" }, "user": { "type": "string" }, "secretKey": { "type": "string" }, "domain": { "type": "string" }, "password": { "type": "string" }, "host": { "type": "string" }, "port": { "type": "string" }, "secure": { "type": "boolean" }, "region": { "type": "string" }, "accountSid": { "type": "string" }, "messageProfileId": { "type": "string" }, "token": { "type": "string" }, "from": { "type": "string" }, "senderName": { "type": "string" }, "projectName": { "type": "string" }, "applicationId": { "type": "string" }, "clientId": { "type": "string" }, "requireTls": { "type": "boolean" }, "ignoreTls": { "type": "boolean" }, "tlsOptions": { "type": "object" }, "baseUrl": { "type": "string" }, "webhookUrl": { "type": "string" }, "redirectUrl": { "type": "string" }, "hmac": { "type": "boolean" }, "serviceAccount": { "type": "string" }, "ipPoolName": { "type": "string" }, "apiKeyRequestHeader": { "type": "string" }, "secretKeyRequestHeader": { "type": "string" }, "idPath": { "type": "string" }, "datePath": { "type": "string" }, "apiToken": { "type": "string" }, "authenticateByToken": { "type": "boolean" }, "authenticationTokenKey": { "type": "string" }, "instanceId": { "type": "string" }, "alertUid": { "type": "string" }, "title": { "type": "string" }, "imageUrl": { "type": "string" }, "state": { "type": "string" }, "externalLink": { "type": "string" }, "channelId": { "type": "string" }, "phoneNumberIdentification": { "type": "string" }, "accessKey": { "type": "string" } } }, "IntegrationResponseDto": { "type": "object", "properties": { "_id": { "type": "string", "description": "The unique identifier of the integration record in the database. This is automatically generated." }, "_environmentId": { "type": "string", "description": "The unique identifier for the environment associated with this integration. This links to the Environment collection." }, "_organizationId": { "type": "string", "description": "The unique identifier for the organization that owns this integration. This links to the Organization collection." }, "name": { "type": "string", "description": "The name of the integration, which is used to identify it in the user interface." }, "identifier": { "type": "string", "description": "A unique string identifier for the integration, often used for API calls or internal references." }, "providerId": { "type": "string", "description": "The identifier for the provider of the integration (e.g., \"mailgun\", \"twilio\")." }, "channel": { "type": "string", "description": "The channel type for the integration, which defines how the integration communicates (e.g., email, SMS).", "enum": ["in_app", "email", "sms", "chat", "push"] }, "credentials": { "description": "The credentials required for the integration to function, including API keys and other sensitive information.", "allOf": [ { "$ref": "#/components/schemas/CredentialsDto" } ] }, "active": { "type": "boolean", "description": "Indicates whether the integration is currently active. An active integration will process events and messages." }, "deleted": { "type": "boolean", "description": "Indicates whether the integration has been marked as deleted (soft delete)." }, "deletedAt": { "type": "string", "description": "The timestamp indicating when the integration was deleted. This is set when the integration is soft deleted." }, "deletedBy": { "type": "string", "description": "The identifier of the user who performed the deletion of this integration. Useful for audit trails." }, "primary": { "type": "boolean", "description": "Indicates whether this integration is marked as primary. A primary integration is often the default choice for processing." }, "conditions": { "description": "An array of conditions associated with the integration that may influence its behavior or processing logic.", "type": "array", "items": { "$ref": "#/components/schemas/StepFilterDto" } } }, "required": [ "_environmentId", "_organizationId", "name", "identifier", "providerId", "channel", "credentials", "active", "deleted", "primary" ] }, "CreateIntegrationRequestDto": { "type": "object", "properties": { "name": { "type": "string", "description": "The name of the integration" }, "identifier": { "type": "string", "description": "The unique identifier for the integration" }, "_environmentId": { "type": "string", "description": "The ID of the associated environment", "format": "uuid" }, "providerId": { "type": "string", "description": "The provider ID for the integration" }, "channel": { "type": "string", "enum": ["in_app", "email", "sms", "chat", "push"], "description": "The channel type for the integration" }, "credentials": { "description": "The credentials for the integration", "allOf": [ { "$ref": "#/components/schemas/CredentialsDto" } ] }, "active": { "type": "boolean", "description": "If the integration is active, the validation on the credentials field will run" }, "check": { "type": "boolean", "description": "Flag to check the integration status" }, "conditions": { "description": "Conditions for the integration", "type": "array", "items": { "$ref": "#/components/schemas/StepFilterDto" } } }, "required": ["providerId", "channel"] }, "UpdateIntegrationRequestDto": { "type": "object", "properties": { "name": { "type": "string" }, "identifier": { "type": "string" }, "_environmentId": { "type": "string" }, "active": { "type": "boolean", "description": "If the integration is active the validation on the credentials field will run" }, "credentials": { "$ref": "#/components/schemas/CredentialsDto" }, "removeNovuBranding": { "type": "boolean", "description": "If true, the Novu branding will be removed from the Inbox component" }, "check": { "type": "boolean" }, "conditions": { "type": "array", "items": { "$ref": "#/components/schemas/StepFilterDto" } } } }, "PaginatedResponseDto": { "type": "object", "properties": { "page": { "type": "number", "description": "The current page of the paginated response" }, "hasMore": { "type": "boolean", "description": "Does the list have more items to fetch" }, "pageSize": { "type": "number", "description": "Number of items on each page" }, "data": { "description": "The list of items matching the query", "type": "array", "items": { "type": "object" } } }, "required": ["page", "hasMore", "pageSize", "data"] }, "ChannelCredentials": { "type": "object", "properties": { "webhookUrl": { "type": "string", "description": "Webhook URL used by chat app integrations. The webhook should be obtained from the chat app provider.", "example": "https://example.com/webhook" }, "channel": { "type": "string", "description": "Channel specification for Mattermost chat notifications.", "example": "general" }, "deviceTokens": { "description": "Contains an array of the subscriber device tokens for a given provider. Used on Push integrations.", "example": ["token1", "token2", "token3"], "type": "array", "items": { "type": "string" } }, "alertUid": { "type": "string", "description": "Alert UID for Grafana on-call webhook payload.", "example": "12345-abcde" }, "title": { "type": "string", "description": "Title to be used with Grafana on-call webhook.", "example": "Critical Alert" }, "imageUrl": { "type": "string", "description": "Image URL property for Grafana on-call webhook.", "example": "https://example.com/image.png" }, "state": { "type": "string", "description": "State property for Grafana on-call webhook.", "example": "resolved" }, "externalUrl": { "type": "string", "description": "Link to upstream details property for Grafana on-call webhook.", "example": "https://example.com/details" } } }, "ChannelSettings": { "type": "object", "properties": { "providerId": { "type": "string", "enum": [ "slack", "discord", "msteams", "mattermost", "ryver", "zulip", "grafana-on-call", "getstream", "rocket-chat", "whatsapp-business", "fcm", "apns", "expo", "one-signal", "pushpad", "push-webhook", "pusher-beams" ], "description": "The provider identifier for the credentials" }, "integrationIdentifier": { "type": "string", "description": "The integration identifier" }, "credentials": { "description": "Credentials payload for the specified provider", "allOf": [ { "$ref": "#/components/schemas/ChannelCredentials" } ] }, "_integrationId": { "type": "string", "description": "The unique identifier of the integration associated with this channel." } }, "required": ["providerId", "integrationIdentifier", "credentials", "_integrationId"] }, "SubscriberResponseDto": { "type": "object", "properties": { "_id": { "type": "string", "description": "The internal ID generated by Novu for your subscriber. This ID does not match the `subscriberId` used in your queries. Refer to `subscriberId` for that identifier." }, "firstName": { "type": "string", "description": "The first name of the subscriber." }, "lastName": { "type": "string", "description": "The last name of the subscriber." }, "email": { "type": "string", "description": "The email address of the subscriber.", "nullable": true }, "phone": { "type": "string", "description": "The phone number of the subscriber." }, "avatar": { "type": "string", "description": "The URL of the subscriber's avatar image." }, "locale": { "type": "string", "description": "The locale setting of the subscriber, indicating their preferred language or region." }, "subscriberId": { "type": "string", "description": "The identifier used to create this subscriber, which typically corresponds to the user ID in your system." }, "channels": { "description": "An array of channel settings associated with the subscriber.", "type": "array", "items": { "$ref": "#/components/schemas/ChannelSettings" } }, "topics": { "description": "An array of topics that the subscriber is subscribed to.", "type": "array", "items": { "type": "string" } }, "isOnline": { "type": "boolean", "description": "Indicates whether the subscriber is currently online." }, "lastOnlineAt": { "type": "string", "description": "The timestamp indicating when the subscriber was last online, in ISO 8601 format." }, "_organizationId": { "type": "string", "description": "The unique identifier of the organization to which the subscriber belongs." }, "_environmentId": { "type": "string", "description": "The unique identifier of the environment associated with this subscriber." }, "deleted": { "type": "boolean", "description": "Indicates whether the subscriber has been deleted." }, "createdAt": { "type": "string", "description": "The timestamp indicating when the subscriber was created, in ISO 8601 format." }, "updatedAt": { "type": "string", "description": "The timestamp indicating when the subscriber was last updated, in ISO 8601 format." }, "__v": { "type": "number", "description": "The version of the subscriber document." } }, "required": [ "firstName", "lastName", "subscriberId", "_organizationId", "_environmentId", "deleted", "createdAt", "updatedAt" ] }, "CreateSubscriberRequestDto": { "type": "object", "properties": { "subscriberId": { "type": "string", "description": "The internal identifier you used to create this subscriber, usually correlates to the id the user in your systems" }, "email": { "type": "string", "description": "The email address of the subscriber." }, "firstName": { "type": "string", "description": "The first name of the subscriber." }, "lastName": { "type": "string", "description": "The last name of the subscriber." }, "phone": { "type": "string", "description": "The phone number of the subscriber." }, "avatar": { "type": "string", "description": "An HTTP URL to the profile image of your subscriber." }, "locale": { "type": "string", "description": "The locale of the subscriber." }, "data": { "type": "object", "description": "An optional payload object that can contain any properties.", "additionalProperties": { "oneOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } }, { "type": "boolean" }, { "type": "number" } ] } }, "channels": { "description": "An optional array of subscriber channels.", "type": "array", "items": { "$ref": "#/components/schemas/SubscriberChannelDto" } } }, "required": ["subscriberId"] }, "UpdatedSubscriberDto": { "type": "object", "properties": { "subscriberId": { "type": "string", "description": "The ID of the subscriber that was updated." } }, "required": ["subscriberId"] }, "CreatedSubscriberDto": { "type": "object", "properties": { "subscriberId": { "type": "string", "description": "The ID of the subscriber that was created." } }, "required": ["subscriberId"] }, "FailedOperationDto": { "type": "object", "properties": { "message": { "type": "string", "description": "The error message associated with the failed operation." }, "subscriberId": { "type": "string", "description": "The subscriber ID associated with the failed operation. This field is optional." } } }, "BulkCreateSubscriberResponseDto": { "type": "object", "properties": { "updated": { "description": "An array of subscribers that were successfully updated.", "type": "array", "items": { "$ref": "#/components/schemas/UpdatedSubscriberDto" } }, "created": { "description": "An array of subscribers that were successfully created.", "type": "array", "items": { "$ref": "#/components/schemas/CreatedSubscriberDto" } }, "failed": { "description": "An array of failed operations with error messages and optional subscriber IDs.", "type": "array", "items": { "$ref": "#/components/schemas/FailedOperationDto" } } }, "required": ["updated", "created", "failed"] }, "BulkSubscriberCreateDto": { "type": "object", "properties": { "subscribers": { "description": "An array of subscribers to be created in bulk.", "type": "array", "items": { "$ref": "#/components/schemas/CreateSubscriberRequestDto" } } }, "required": ["subscribers"] }, "UpdateSubscriberRequestDto": { "type": "object", "properties": { "email": { "type": "string", "description": "The email address of the subscriber.", "example": "john.doe@example.com" }, "firstName": { "type": "string", "description": "The first name of the subscriber.", "example": "John" }, "lastName": { "type": "string", "description": "The last name of the subscriber.", "example": "Doe" }, "phone": { "type": "string", "description": "The phone number of the subscriber.", "example": "+1234567890" }, "avatar": { "type": "string", "description": "The avatar URL of the subscriber.", "example": "https://example.com/avatar.jpg" }, "locale": { "type": "string", "description": "The locale of the subscriber, for example \"en-US\".", "example": "en-US" }, "data": { "type": "object", "description": "Custom data associated with the subscriber. Can contain any additional properties.", "additionalProperties": true, "example": { "preferences": { "notifications": true, "theme": "dark" }, "tags": ["premium", "newsletter"] } }, "channels": { "description": "An array of communication channels for the subscriber.", "type": "array", "items": { "$ref": "#/components/schemas/SubscriberChannelDto" } } } }, "UpdateSubscriberChannelRequestDto": { "type": "object", "properties": { "providerId": { "type": "string", "enum": [ "slack", "discord", "msteams", "mattermost", "ryver", "zulip", "grafana-on-call", "getstream", "rocket-chat", "whatsapp-business", "fcm", "apns", "expo", "one-signal", "pushpad", "push-webhook", "pusher-beams" ], "description": "The provider identifier for the credentials" }, "integrationIdentifier": { "type": "string", "description": "The integration identifier" }, "credentials": { "description": "Credentials payload for the specified provider", "allOf": [ { "$ref": "#/components/schemas/ChannelCredentials" } ] } }, "required": ["providerId", "integrationIdentifier", "credentials"] }, "UpdateSubscriberOnlineFlagRequestDto": { "type": "object", "properties": { "isOnline": { "type": "boolean" } }, "required": ["isOnline"] }, "DeleteSubscriberResponseDto": { "type": "object", "properties": { "acknowledged": { "type": "boolean", "description": "A boolean stating the success of the action" }, "status": { "type": "string", "description": "The status enum for the performed action", "enum": ["deleted"] } }, "required": ["acknowledged", "status"] }, "TriggerTypeEnum": { "type": "string", "description": "The type of the trigger", "enum": ["event"] }, "NotificationTriggerVariableResponse": { "type": "object", "properties": { "name": { "type": "string", "description": "The name of the variable" }, "value": { "type": "object", "description": "The value of the variable" }, "type": { "type": "string", "enum": ["String", "Array", "Boolean"], "description": "The type of the variable" } }, "required": ["name"] }, "TriggerReservedVariableResponse": { "type": "object", "properties": { "type": { "type": "string", "enum": ["tenant", "actor"], "description": "The type of the reserved variable" }, "variables": { "description": "The reserved variables of the trigger", "type": "array", "items": { "type": "string" } } }, "required": ["type", "variables"] }, "NotificationTriggerResponse": { "type": "object", "properties": { "type": { "$ref": "#/components/schemas/TriggerTypeEnum" }, "identifier": { "type": "string", "description": "The identifier of the trigger" }, "variables": { "description": "The variables of the trigger", "type": "array", "items": { "$ref": "#/components/schemas/NotificationTriggerVariableResponse" } }, "subscriberVariables": { "description": "The subscriber variables of the trigger", "type": "array", "items": { "$ref": "#/components/schemas/NotificationTriggerVariableResponse" } }, "reservedVariables": { "description": "The reserved variables of the trigger", "type": "array", "items": { "$ref": "#/components/schemas/TriggerReservedVariableResponse" } } }, "required": ["type", "identifier", "variables"] }, "TemplateResponse": { "type": "object", "properties": { "_id": { "type": "string", "description": "Unique identifier of the workflow" }, "name": { "type": "string", "description": "Name of the workflow" }, "critical": { "type": "boolean", "description": "Critical templates will always be delivered to the end user and should be hidden from the subscriber preferences screen" }, "triggers": { "description": "Triggers are the events that will trigger the workflow.", "type": "array", "items": { "$ref": "#/components/schemas/NotificationTriggerResponse" } } }, "required": ["_id", "name", "critical", "triggers"] }, "PreferenceChannels": { "type": "object", "properties": { "email": { "type": "boolean" }, "sms": { "type": "boolean" }, "in_app": { "type": "boolean" }, "chat": { "type": "boolean" }, "push": { "type": "boolean" } } }, "Preference": { "type": "object", "properties": { "enabled": { "type": "boolean", "description": "Sets if the workflow is fully enabled for all channels or not for the subscriber." }, "channels": { "description": "Subscriber preferences for the different channels regarding this workflow", "allOf": [ { "$ref": "#/components/schemas/PreferenceChannels" } ] } }, "required": ["enabled", "channels"] }, "UpdateSubscriberPreferenceResponseDto": { "type": "object", "properties": { "template": { "description": "The workflow information and if it is critical or not", "allOf": [ { "$ref": "#/components/schemas/TemplateResponse" } ] }, "preference": { "description": "The preferences of the subscriber regarding the related workflow", "allOf": [ { "$ref": "#/components/schemas/Preference" } ] } }, "required": ["template", "preference"] }, "GetSubscriberPreferencesResponseDto": { "type": "object", "properties": { "template": { "description": "The workflow information and if it is critical or not", "allOf": [ { "$ref": "#/components/schemas/TemplateResponse" } ] }, "preference": { "description": "The preferences of the subscriber regarding the related workflow", "allOf": [ { "$ref": "#/components/schemas/Preference" } ] } }, "required": ["preference"] }, "ChannelPreference": { "type": "object", "properties": { "type": { "$ref": "#/components/schemas/ChannelTypeEnum" }, "enabled": { "type": "boolean", "description": "If channel is enabled or not" } }, "required": ["type", "enabled"] }, "UpdateSubscriberPreferenceRequestDto": { "type": "object", "properties": { "channel": { "description": "Optional preferences for each channel type in the assigned workflow.", "allOf": [ { "$ref": "#/components/schemas/ChannelPreference" } ] }, "enabled": { "type": "boolean", "description": "Indicates whether the workflow is fully enabled for all channels for the subscriber." } } }, "UpdateSubscriberPreferenceGlobalResponseDto": { "type": "object", "properties": { "preference": { "description": "The preferences of the subscriber regarding the related workflow", "allOf": [ { "$ref": "#/components/schemas/Preference" } ] } }, "required": ["preference"] }, "UpdateSubscriberGlobalPreferencesRequestDto": { "type": "object", "properties": { "enabled": { "type": "boolean", "description": "Enable or disable the subscriber global preferences." }, "preferences": { "description": "The subscriber global preferences for every ChannelTypeEnum.", "type": "array", "items": { "$ref": "#/components/schemas/ChannelPreference" } } } }, "EmailBlockTypeEnum": { "type": "string", "description": "Type of the email block", "enum": ["button", "text"] }, "TextAlignEnum": { "type": "string", "description": "Text alignment for the email block", "enum": ["center", "left", "right"] }, "EmailBlockStyles": { "type": "object", "properties": { "textAlign": { "$ref": "#/components/schemas/TextAlignEnum" } }, "required": ["textAlign"] }, "EmailBlock": { "type": "object", "properties": { "type": { "$ref": "#/components/schemas/EmailBlockTypeEnum" }, "content": { "type": "string", "description": "Content of the email block" }, "url": { "type": "string", "description": "URL associated with the email block, if any" }, "styles": { "description": "Styles applied to the email block", "allOf": [ { "$ref": "#/components/schemas/EmailBlockStyles" } ] } }, "required": ["type", "content"] }, "ChannelCTATypeEnum": { "type": "string", "description": "Type of call to action", "enum": ["redirect"] }, "MessageCTAData": { "type": "object", "properties": { "url": { "type": "string", "description": "URL for the call to action" } } }, "MessageActionStatusEnum": { "type": "string", "description": "Status of the message action", "enum": ["pending", "done"] }, "ButtonTypeEnum": { "type": "string", "description": "Type of button for the action result", "enum": ["primary", "secondary"] }, "MessageButton": { "type": "object", "properties": { "type": { "$ref": "#/components/schemas/ButtonTypeEnum" }, "content": { "type": "string", "description": "Content of the button" }, "resultContent": { "type": "string", "description": "Content of the result when the button is clicked" } }, "required": ["type", "content"] }, "MessageActionResult": { "type": "object", "properties": { "payload": { "type": "object", "description": "Payload of the action result" }, "type": { "$ref": "#/components/schemas/ButtonTypeEnum" } } }, "MessageAction": { "type": "object", "properties": { "status": { "$ref": "#/components/schemas/MessageActionStatusEnum" }, "buttons": { "description": "List of buttons associated with the message action", "type": "array", "items": { "$ref": "#/components/schemas/MessageButton" } }, "result": { "description": "Result of the message action", "allOf": [ { "$ref": "#/components/schemas/MessageActionResult" } ] } } }, "MessageCTA": { "type": "object", "properties": { "type": { "$ref": "#/components/schemas/ChannelCTATypeEnum" }, "data": { "description": "Data associated with the call to action", "allOf": [ { "$ref": "#/components/schemas/MessageCTAData" } ] }, "action": { "description": "Action associated with the call to action", "allOf": [ { "$ref": "#/components/schemas/MessageAction" } ] } }, "required": ["data"] }, "ActorTypeEnum": { "type": "string", "description": "The type of the actor, indicating the role in the notification process.", "enum": ["none", "user", "system_icon", "system_custom"] }, "ActorFeedItemDto": { "type": "object", "properties": { "data": { "type": "string", "description": "The data associated with the actor, can be null if not applicable.", "nullable": true, "example": null }, "type": { "$ref": "#/components/schemas/ActorTypeEnum" } }, "required": ["data", "type"] }, "SubscriberFeedResponseDto": { "type": "object", "properties": { "_id": { "type": "string", "description": "The internal ID generated by Novu for your subscriber. This ID does not match the `subscriberId` used in your queries. Refer to `subscriberId` for that identifier." }, "firstName": { "type": "string", "description": "The first name of the subscriber." }, "lastName": { "type": "string", "description": "The last name of the subscriber." }, "avatar": { "type": "string", "description": "The URL of the subscriber's avatar image." }, "subscriberId": { "type": "string", "description": "The identifier used to create this subscriber, which typically corresponds to the user ID in your system." } }, "required": ["firstName", "lastName", "subscriberId"] }, "NotificationFeedItemDto": { "type": "object", "properties": { "_id": { "type": "string", "description": "Unique identifier for the notification.", "example": "615c1f2f9b0c5b001f8e4e3b" }, "_templateId": { "type": "string", "description": "Identifier for the template used to generate the notification.", "example": "template_12345" }, "_environmentId": { "type": "string", "description": "Identifier for the environment where the notification is sent.", "example": "env_67890" }, "_messageTemplateId": { "type": "string", "description": "Identifier for the message template used.", "example": "message_template_54321" }, "_organizationId": { "type": "string", "description": "Identifier for the organization sending the notification.", "example": "org_98765" }, "_notificationId": { "type": "string", "description": "Unique identifier for the notification instance.", "example": "notification_123456" }, "_subscriberId": { "type": "string", "description": "Unique identifier for the subscriber receiving the notification.", "example": "subscriber_112233" }, "_feedId": { "type": "string", "description": "Identifier for the feed associated with the notification.", "example": "feed_445566" }, "_jobId": { "type": "string", "description": "Identifier for the job that triggered the notification.", "example": "job_778899" }, "createdAt": { "type": "string", "description": "Timestamp indicating when the notification was created.", "format": "date-time", "nullable": true, "example": "2024-12-10T10:10:59.639Z" }, "updatedAt": { "type": "string", "description": "Timestamp indicating when the notification was last updated.", "format": "date-time", "nullable": true, "example": "2024-12-10T10:10:59.639Z" }, "actor": { "description": "Actor details related to the notification, if applicable.", "allOf": [ { "$ref": "#/components/schemas/ActorFeedItemDto" } ] }, "subscriber": { "description": "Subscriber details associated with this notification.", "allOf": [ { "$ref": "#/components/schemas/SubscriberFeedResponseDto" } ] }, "transactionId": { "type": "string", "description": "Unique identifier for the transaction associated with the notification.", "example": "transaction_123456" }, "templateIdentifier": { "type": "string", "description": "Identifier for the template used, if applicable.", "nullable": true, "example": "template_abcdef" }, "providerId": { "type": "string", "description": "Identifier for the provider that sends the notification.", "nullable": true, "example": "provider_xyz" }, "content": { "type": "string", "description": "The main content of the notification.", "example": "This is a test notification content." }, "subject": { "type": "string", "description": "The subject line for email notifications, if applicable.", "nullable": true, "example": "Test Notification Subject" }, "channel": { "$ref": "#/components/schemas/ChannelTypeEnum" }, "read": { "type": "boolean", "description": "Indicates whether the notification has been read by the subscriber.", "example": false }, "seen": { "type": "boolean", "description": "Indicates whether the notification has been seen by the subscriber.", "example": true }, "deleted": { "type": "boolean", "description": "Indicates whether the notification has been deleted.", "example": false }, "deviceTokens": { "description": "Device tokens for push notifications, if applicable.", "nullable": true, "example": ["token1", "token2"], "type": "array", "items": { "type": "string" } }, "cta": { "description": "Call-to-action information associated with the notification.", "allOf": [ { "$ref": "#/components/schemas/MessageCTA" } ] }, "status": { "type": "string", "description": "Current status of the notification.", "enum": ["sent", "error", "warning"], "example": "sent" }, "payload": { "type": "object", "description": "The payload that was used to send the notification trigger.", "additionalProperties": true, "example": { "key": "value" } }, "overrides": { "type": "object", "description": "Provider-specific overrides used when triggering the notification.", "additionalProperties": true, "example": { "overrideKey": "overrideValue" } } }, "required": [ "_id", "_templateId", "_environmentId", "_messageTemplateId", "_organizationId", "_notificationId", "_subscriberId", "_feedId", "_jobId", "transactionId", "content", "channel", "read", "seen", "deleted", "cta", "status" ] }, "FeedResponseDto": { "type": "object", "properties": { "totalCount": { "type": "number", "description": "Total number of notifications available.", "example": 5 }, "hasMore": { "type": "boolean", "description": "Indicates if there are more notifications to load.", "example": true }, "data": { "description": "Array of notifications returned in the response.", "type": "array", "items": { "$ref": "#/components/schemas/NotificationFeedItemDto" } }, "pageSize": { "type": "number", "description": "The number of notifications returned in this response.", "example": 2 }, "page": { "type": "number", "description": "The current page number of the notifications.", "example": 1 } }, "required": ["hasMore", "data", "pageSize", "page"] }, "UnseenCountResponse": { "type": "object", "properties": { "count": { "type": "number" } }, "required": ["count"] }, "NotificationGroup": { "type": "object", "properties": { "_id": { "type": "string" }, "name": { "type": "string" }, "_environmentId": { "type": "string" }, "_organizationId": { "type": "string" }, "_parentId": { "type": "string" } }, "required": ["name", "_environmentId", "_organizationId"] }, "DigestRegularMetadata": { "type": "object", "properties": { "amount": { "type": "number" }, "unit": { "type": "string", "enum": ["seconds", "minutes", "hours", "days", "weeks", "months"] }, "digestKey": { "type": "string" }, "type": { "type": "string", "enum": ["regular", "backoff"] }, "backoff": { "type": "boolean" }, "backoffAmount": { "type": "number" }, "backoffUnit": { "type": "string", "enum": ["seconds", "minutes", "hours", "days", "weeks", "months"] }, "updateMode": { "type": "boolean" } }, "required": ["type"] }, "TimedConfig": { "type": "object", "properties": { "atTime": { "type": "string" }, "weekDays": { "type": "array", "items": { "type": "string", "enum": ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"] } }, "monthDays": { "type": "array", "items": { "type": "string" } }, "ordinal": { "type": "string", "enum": ["1", "2", "3", "4", "5", "last"] }, "ordinalValue": { "type": "string", "enum": [ "day", "weekday", "weekend", "sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday" ] }, "monthlyType": { "type": "string", "enum": ["each", "on"] } } }, "DigestTimedMetadata": { "type": "object", "properties": { "amount": { "type": "number" }, "unit": { "type": "string", "enum": ["seconds", "minutes", "hours", "days", "weeks", "months"] }, "digestKey": { "type": "string" }, "type": { "type": "string", "enum": ["timed"] }, "timed": { "$ref": "#/components/schemas/TimedConfig" } }, "required": ["type"] }, "DelayRegularMetadata": { "type": "object", "properties": { "amount": { "type": "number" }, "unit": { "type": "string", "enum": ["seconds", "minutes", "hours", "days", "weeks", "months"] }, "type": { "type": "string", "enum": ["regular"] } }, "required": ["type"] }, "DelayScheduledMetadata": { "type": "object", "properties": { "type": { "type": "string", "enum": ["scheduled"] }, "delayPath": { "type": "string" } }, "required": ["type", "delayPath"] }, "MessageTemplate": { "type": "object", "properties": {} }, "ReplyCallback": { "type": "object", "properties": { "active": { "type": "boolean", "description": "Indicates whether the reply callback is active." }, "url": { "type": "string", "description": "The URL to which replies should be sent." } } }, "NotificationStepData": { "type": "object", "properties": { "_id": { "type": "string", "description": "Unique identifier for the notification step." }, "uuid": { "type": "string", "description": "Universally unique identifier for the notification step." }, "name": { "type": "string", "description": "Name of the notification step." }, "_templateId": { "type": "string", "description": "ID of the template associated with this notification step." }, "active": { "type": "boolean", "description": "Indicates whether the notification step is active." }, "shouldStopOnFail": { "type": "boolean", "description": "Determines if the process should stop on failure." }, "template": { "description": "Message template used in this notification step.", "allOf": [ { "$ref": "#/components/schemas/MessageTemplate" } ] }, "filters": { "description": "Filters applied to this notification step.", "type": "array", "items": { "$ref": "#/components/schemas/StepFilterDto" } }, "_parentId": { "type": "string", "description": "ID of the parent notification step, if applicable." }, "metadata": { "description": "Metadata associated with the workflow step. Can vary based on the type of step.", "oneOf": [ { "$ref": "#/components/schemas/DigestRegularMetadata" }, { "$ref": "#/components/schemas/DigestTimedMetadata" }, { "$ref": "#/components/schemas/DelayRegularMetadata" }, { "$ref": "#/components/schemas/DelayScheduledMetadata" } ] }, "replyCallback": { "description": "Callback information for replies, including whether it is active and the callback URL.", "allOf": [ { "$ref": "#/components/schemas/ReplyCallback" } ] } } }, "NotificationStepDto": { "type": "object", "properties": { "_id": { "type": "string", "description": "Unique identifier for the notification step." }, "uuid": { "type": "string", "description": "Universally unique identifier for the notification step." }, "name": { "type": "string", "description": "Name of the notification step." }, "_templateId": { "type": "string", "description": "ID of the template associated with this notification step." }, "active": { "type": "boolean", "description": "Indicates whether the notification step is active." }, "shouldStopOnFail": { "type": "boolean", "description": "Determines if the process should stop on failure." }, "template": { "description": "Message template used in this notification step.", "allOf": [ { "$ref": "#/components/schemas/MessageTemplate" } ] }, "filters": { "description": "Filters applied to this notification step.", "type": "array", "items": { "$ref": "#/components/schemas/StepFilterDto" } }, "_parentId": { "type": "string", "description": "ID of the parent notification step, if applicable." }, "metadata": { "description": "Metadata associated with the workflow step. Can vary based on the type of step.", "oneOf": [ { "$ref": "#/components/schemas/DigestRegularMetadata" }, { "$ref": "#/components/schemas/DigestTimedMetadata" }, { "$ref": "#/components/schemas/DelayRegularMetadata" }, { "$ref": "#/components/schemas/DelayScheduledMetadata" } ] }, "replyCallback": { "description": "Callback information for replies, including whether it is active and the callback URL.", "allOf": [ { "$ref": "#/components/schemas/ReplyCallback" } ] }, "variants": { "type": "array", "items": { "$ref": "#/components/schemas/NotificationStepData" } } } }, "NotificationTrigger": { "type": "object", "properties": { "type": { "type": "string", "enum": ["event"] }, "identifier": { "type": "string" }, "variables": { "type": "array", "items": { "$ref": "#/components/schemas/NotificationTriggerVariable" } }, "subscriberVariables": { "type": "array", "items": { "$ref": "#/components/schemas/NotificationTriggerVariable" } } }, "required": ["type", "identifier", "variables", "subscriberVariables"] }, "WorkflowResponse": { "type": "object", "properties": { "_id": { "type": "string" }, "name": { "type": "string" }, "description": { "type": "string" }, "active": { "type": "boolean" }, "draft": { "type": "boolean" }, "preferenceSettings": { "$ref": "#/components/schemas/PreferenceChannels" }, "critical": { "type": "boolean" }, "tags": { "type": "array", "items": { "type": "string" } }, "steps": { "type": "array", "items": { "$ref": "#/components/schemas/NotificationStepDto" } }, "_organizationId": { "type": "string" }, "_creatorId": { "type": "string" }, "_environmentId": { "type": "string" }, "triggers": { "type": "array", "items": { "$ref": "#/components/schemas/NotificationTrigger" } }, "_notificationGroupId": { "type": "string" }, "_parentId": { "type": "string" }, "deleted": { "type": "boolean" }, "deletedAt": { "type": "string" }, "deletedBy": { "type": "string" }, "notificationGroup": { "$ref": "#/components/schemas/NotificationGroup" }, "data": { "type": "object" } }, "required": [ "name", "description", "active", "draft", "preferenceSettings", "critical", "tags", "steps", "_organizationId", "_creatorId", "_environmentId", "triggers", "_notificationGroupId", "deleted", "deletedAt", "deletedBy" ] }, "MessageStatusEnum": { "type": "string", "description": "Status of the message", "enum": ["sent", "error", "warning"] }, "MessageResponseDto": { "type": "object", "properties": { "_id": { "type": "string", "description": "Unique identifier for the message" }, "_templateId": { "type": "string", "description": "Template ID associated with the message" }, "_environmentId": { "type": "string", "description": "Environment ID where the message is sent" }, "_messageTemplateId": { "type": "string", "description": "Message template ID" }, "_organizationId": { "type": "string", "description": "Organization ID associated with the message" }, "_notificationId": { "type": "string", "description": "Notification ID associated with the message" }, "_subscriberId": { "type": "string", "description": "Subscriber ID associated with the message" }, "subscriber": { "description": "Subscriber details, if available", "allOf": [ { "$ref": "#/components/schemas/SubscriberResponseDto" } ] }, "template": { "description": "Workflow template associated with the message", "allOf": [ { "$ref": "#/components/schemas/WorkflowResponse" } ] }, "templateIdentifier": { "type": "string", "description": "Identifier for the message template" }, "createdAt": { "type": "string", "description": "Creation date of the message" }, "lastSeenDate": { "type": "string", "description": "Last seen date of the message, if available" }, "lastReadDate": { "type": "string", "description": "Last read date of the message, if available" }, "content": { "oneOf": [ { "$ref": "#/components/schemas/EmailBlock" }, { "type": "string", "description": "String representation of the content" } ], "description": "Content of the message, can be an email block or a string" }, "transactionId": { "type": "string", "description": "Transaction ID associated with the message" }, "subject": { "type": "string", "description": "Subject of the message, if applicable" }, "channel": { "$ref": "#/components/schemas/ChannelTypeEnum" }, "read": { "type": "boolean", "description": "Indicates if the message has been read" }, "seen": { "type": "boolean", "description": "Indicates if the message has been seen" }, "email": { "type": "string", "description": "Email address associated with the message, if applicable" }, "phone": { "type": "string", "description": "Phone number associated with the message, if applicable" }, "directWebhookUrl": { "type": "string", "description": "Direct webhook URL for the message, if applicable" }, "providerId": { "type": "string", "description": "Provider ID associated with the message, if applicable" }, "deviceTokens": { "description": "Device tokens associated with the message, if applicable", "type": "array", "items": { "type": "string" } }, "title": { "type": "string", "description": "Title of the message, if applicable" }, "cta": { "description": "Call to action associated with the message", "allOf": [ { "$ref": "#/components/schemas/MessageCTA" } ] }, "_feedId": { "type": "string", "description": "Feed ID associated with the message, if applicable" }, "status": { "$ref": "#/components/schemas/MessageStatusEnum" }, "errorId": { "type": "string", "description": "Error ID if the message has an error" }, "errorText": { "type": "string", "description": "Error text if the message has an error" }, "payload": { "type": "object", "description": "The payload that was used to send the notification trigger" }, "overrides": { "type": "object", "description": "Provider specific overrides used when triggering the notification" } }, "required": [ "_templateId", "_environmentId", "_messageTemplateId", "_organizationId", "_notificationId", "_subscriberId", "createdAt", "content", "transactionId", "channel", "read", "seen", "cta", "status" ] }, "MessageMarkAsRequestDto": { "type": "object", "properties": { "messageId": { "oneOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ] }, "markAs": { "type": "string", "enum": ["read", "seen", "unread", "unseen"] } }, "required": ["messageId", "markAs"] }, "MarkAllMessageAsRequestDto": { "type": "object", "properties": { "feedIdentifier": { "oneOf": [ { "type": "string" }, { "type": "array", "items": { "type": "string" } } ], "description": "Optional feed identifier or array of feed identifiers" }, "markAs": { "type": "string", "enum": ["read", "seen", "unread", "unseen"], "description": "Mark all subscriber messages as read, unread, seen or unseen" } }, "required": ["markAs"] }, "MarkMessageActionAsSeenDto": { "type": "object", "properties": { "status": { "type": "string", "enum": ["pending", "done"], "description": "Message action status" }, "payload": { "type": "object", "description": "Message action payload" } }, "required": ["status"] }, "String": { "type": "object", "properties": {} }, "ListSubscribersResponseDto": { "type": "object", "properties": { "data": { "description": "List of returned Subscribers", "type": "array", "items": { "$ref": "#/components/schemas/SubscriberResponseDto" } }, "next": { "type": "string", "description": "The cursor for the next page of results, or null if there are no more pages.", "nullable": true }, "previous": { "type": "string", "description": "The cursor for the previous page of results, or null if this is the first page.", "nullable": true } }, "required": ["data", "next", "previous"] }, "DeleteMessageResponseDto": { "type": "object", "properties": { "acknowledged": { "type": "boolean", "description": "A boolean stating the success of the action" }, "status": { "type": "string", "description": "The status enum for the performed action", "enum": ["deleted"] } }, "required": ["acknowledged", "status"] }, "CreateTopicResponseDto": { "type": "object", "properties": { "_id": { "type": "string", "description": "The unique identifier for the Topic created." }, "key": { "type": "string", "description": "User defined custom key and provided by the user that will be an unique identifier for the Topic created." } }, "required": ["key"] }, "CreateTopicRequestDto": { "type": "object", "properties": { "key": { "type": "string", "description": "User defined custom key and provided by the user that will be an unique identifier for the Topic created." }, "name": { "type": "string", "description": "User defined custom name and provided by the user that will name the Topic created." } }, "required": ["key", "name"] }, "AddSubscribersRequestDto": { "type": "object", "properties": { "subscribers": { "description": "List of subscriber identifiers that will be associated to the topic", "type": "array", "items": { "type": "string" } } }, "required": ["subscribers"] }, "FailedAssignmentsDto": { "type": "object", "properties": { "notFound": { "description": "List of subscriber IDs that were not found", "type": "array", "items": { "type": "string" } } } }, "AssignSubscriberToTopicDto": { "type": "object", "properties": { "succeeded": { "description": "List of successfully assigned subscriber IDs", "type": "array", "items": { "type": "string" } }, "failed": { "description": "Details about failed assignments", "allOf": [ { "$ref": "#/components/schemas/FailedAssignmentsDto" } ] } }, "required": ["succeeded"] }, "TopicSubscriberDto": { "type": "object", "properties": { "_organizationId": { "type": "string", "description": "Unique identifier for the organization", "example": "org_123456789" }, "_environmentId": { "type": "string", "description": "Unique identifier for the environment", "example": "env_123456789" }, "_subscriberId": { "type": "string", "description": "Unique identifier for the subscriber", "example": "sub_123456789" }, "_topicId": { "type": "string", "description": "Unique identifier for the topic", "example": "topic_123456789" }, "topicKey": { "type": "string", "description": "Key associated with the topic", "example": "my_topic_key" }, "externalSubscriberId": { "type": "string", "description": "External identifier for the subscriber", "example": "external_subscriber_123" } }, "required": [ "_organizationId", "_environmentId", "_subscriberId", "_topicId", "topicKey", "externalSubscriberId" ] }, "RemoveSubscribersRequestDto": { "type": "object", "properties": { "subscribers": { "description": "List of subscriber identifiers that will be removed to the topic", "type": "array", "items": { "type": "string" } } }, "required": ["subscribers"] }, "TopicDto": { "type": "object", "properties": { "_id": { "type": "string" }, "_organizationId": { "type": "string" }, "_environmentId": { "type": "string" }, "key": { "type": "string" }, "name": { "type": "string" }, "subscribers": { "type": "array", "items": { "type": "string" } } }, "required": ["_organizationId", "_environmentId", "key", "name", "subscribers"] }, "FilterTopicsResponseDto": { "type": "object", "properties": { "data": { "example": [], "description": "The list of topics", "type": "array", "items": { "$ref": "#/components/schemas/TopicDto" } }, "page": { "type": "number", "example": 1, "description": "The current page number" }, "pageSize": { "type": "number", "example": 10, "description": "The number of items per page" }, "totalCount": { "type": "number", "example": 10, "description": "The total number of items" } }, "required": ["data", "page", "pageSize", "totalCount"] }, "GetTopicResponseDto": { "type": "object", "properties": { "_id": { "type": "string" }, "_organizationId": { "type": "string" }, "_environmentId": { "type": "string" }, "key": { "type": "string" }, "name": { "type": "string" }, "subscribers": { "type": "array", "items": { "type": "string" } } }, "required": ["_organizationId", "_environmentId", "key", "name", "subscribers"] }, "RenameTopicResponseDto": { "type": "object", "properties": { "_id": { "type": "string" }, "_organizationId": { "type": "string" }, "_environmentId": { "type": "string" }, "key": { "type": "string" }, "name": { "type": "string" }, "subscribers": { "type": "array", "items": { "type": "string" } } }, "required": ["_organizationId", "_environmentId", "key", "name", "subscribers"] }, "RenameTopicRequestDto": { "type": "object", "properties": { "name": { "type": "string", "description": "User defined custom name and provided by the user to rename the topic." } }, "required": ["name"] } }, "headers": { "Content-Type": { "required": true, "description": "The MIME type of the response body.", "schema": { "type": "string" }, "example": "application/json" }, "RateLimit-Limit": { "required": false, "description": "The number of requests that the client is permitted to make per second. The actual maximum may differ when burst is enabled.", "schema": { "type": "string" }, "example": "100" }, "RateLimit-Remaining": { "required": false, "description": "The number of requests remaining until the next window.", "schema": { "type": "string" }, "example": "93" }, "RateLimit-Reset": { "required": false, "description": "The remaining seconds until a request of the same cost will be refreshed.", "schema": { "type": "string" }, "example": "8" }, "RateLimit-Policy": { "required": false, "description": "The rate limit policy that was used to evaluate the request.", "schema": { "type": "string" }, "example": "100;w=1;burst=110;comment=\"token bucket\";category=\"trigger\";cost=\"single\"" }, "Retry-After": { "required": false, "description": "The number of seconds after which the client may retry the request that was previously rejected.", "schema": { "type": "string" }, "example": "8" }, "Idempotency-Key": { "required": false, "description": "The idempotency key used to evaluate the request.", "schema": { "type": "string" }, "example": "8" }, "Idempotency-Replay": { "required": false, "description": "Whether the request was a replay of a previous request.", "schema": { "type": "string" }, "example": "true" }, "Link": { "required": false, "description": "A link to the documentation.", "schema": { "type": "string" }, "example": "https://docs.novu.co/" } } }, "externalDocs": { "description": "Novu Documentation", "url": "https://docs.novu.co" }, "security": [ { "secretKey": [] }, { "bearerAuth": [] } ], "x-speakeasy-name-override": [ { "operationId": "^.*get.*", "methodNameOverride": "retrieve" }, { "operationId": "^.*retrieve.*", "methodNameOverride": "retrieve" }, { "operationId": "^.*create.*", "methodNameOverride": "create" }, { "operationId": "^.*update.*", "methodNameOverride": "update" }, { "operationId": "^.*list.*", "methodNameOverride": "list" }, { "operationId": "^.*delete.*", "methodNameOverride": "delete" }, { "operationId": "^.*remove.*", "methodNameOverride": "delete" } ], "x-speakeasy-retries": { "strategy": "backoff", "backoff": { "initialInterval": 1000, "maxInterval": 30000, "maxElapsedTime": 3600000, "exponent": 1.5 }, "statusCodes": [408, 409, 429, "5XX"], "retryConnectionErrors": true } } ================================================ FILE: apps/api/swc-register.js ================================================ /** biome-ignore-all lint/style/noCommonJs: */ const { transformFileSync } = require('@swc/core'); const { addHook } = require('pirates'); require('ts-node').register({ transpileOnly: true, compilerOptions: { module: 'commonjs', target: 'es5', esModuleInterop: false, }, }); addHook( (code, filename) => { try { const result = transformFileSync(filename, { jsc: { target: 'es5', parser: { syntax: 'typescript', tsx: true, decorators: true, dynamicImport: true, }, transform: { decoratorMetadata: true, useDefineForClassFields: false, }, keepClassNames: true, preserveAllComments: true, }, module: { type: 'commonjs', strictMode: false, noInterop: false, }, sourceMaps: true, inlineSourcesContent: true, minify: false, }); return result.code; } catch (error) { console.error(`Error transforming file ${filename}:`, error); return code; } }, { exts: ['.ts', '.tsx'], matcher: (filename) => { if (filename.includes('.source')) { return false; } return filename.endsWith('.ts') || filename.endsWith('.tsx'); }, } ); ================================================ FILE: apps/api/tsconfig.build.json ================================================ { "extends": "../../tsconfig.json", "compilerOptions": { "module": "commonjs", "declaration": true, "noImplicitAny": false, "removeComments": true, "allowSyntheticDefaultImports": true, "emitDecoratorMetadata": true, "experimentalDecorators": true, "strictNullChecks": true, "target": "es6", "esModuleInterop": false, "sourceMap": true, "skipLibCheck": true, "outDir": "./dist", "baseUrl": "./src" }, "include": ["src/**/*", "src/**/*.d.ts"], "exclude": ["node_modules", "**/*.spec.ts", "**/*.e2e.ts", "**/*.e2e-ee.ts"] } ================================================ FILE: apps/api/tsconfig.json ================================================ { "extends": "../../tsconfig.json", "compilerOptions": { "allowJs": false, "allowSyntheticDefaultImports": true, "emitDecoratorMetadata": true, "esModuleInterop": true, "module": "commonjs", "skipLibCheck": true, "sourceMap": true, "strictNullChecks": true, "target": "es6" }, "exclude": ["node_modules"] } ================================================ FILE: apps/api/tsconfig.spec.json ================================================ { "extends": "./tsconfig.json", "compilerOptions": {}, "exclude": ["node_modules"] } ================================================ FILE: apps/api/webpack.config.js ================================================ // biome-ignore lint/style/noCommonJs: module.exports = (options) => ({ ...options, devtool: 'source-map', }); ================================================ FILE: apps/dashboard/.example.env ================================================ VITE_SENTRY_DSN= VITE_LAUNCH_DARKLY_CLIENT_SIDE_ID= VITE_HUBSPOT_EMBED= VITE_API_HOSTNAME=http://localhost:3000 VITE_WEBSOCKET_HOSTNAME=http://localhost:3002 VITE_CLERK_PUBLISHABLE_KEY= VITE_NOVU_APP_ID= VITE_GTM= VITE_SELF_HOSTED= VITE_PLAIN_SUPPORT_CHAT_APP_ID= # Multi-Region Configuration # List of region codes (comma-separated). FIRST region is the base/default region. # Use SHORT codes that match your env var suffixes (e.g., 'sg' not 'singapore') # The base region uses env vars WITHOUT suffix (VITE_API_HOSTNAME, not VITE_API_HOSTNAME_XX) VITE_REGIONS=us,sg # Base Region - NO suffix required (everything uses base env vars) VITE_DASHBOARD_URL=http://localhost:4201 # VITE_API_HOSTNAME and VITE_WEBSOCKET_HOSTNAME are already defined above VITE_AWS_REGION=us-east-1 # VITE_REGION_NAME=US # VITE_REGION_FLAG=🇺🇸 # Additional Region Configuration # For each additional region, add variables with _REGIONCODE suffix (uppercase): # Singapore Region VITE_DASHBOARD_URL_SG=http://localhost:4202 VITE_API_HOSTNAME_SG=http://localhost:3200 VITE_WEBSOCKET_HOSTNAME_SG=http://localhost:3003 VITE_AWS_REGION_SG=ap-southeast-1 VITE_REGION_NAME_SG=Singapore VITE_REGION_FLAG_SG=🇸🇬 # To add more regions, follow this pattern: # VITE_DASHBOARD_URL_= # VITE_API_HOSTNAME_= # VITE_WEBSOCKET_HOSTNAME_= # VITE_AWS_REGION_= # VITE_REGION_NAME_= # VITE_REGION_FLAG_= # See MULTI_REGION_SETUP.md for detailed instructions ================================================ FILE: apps/dashboard/.gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* node_modules dist dist-ssr *.local # Editor directories and files !.vscode/extensions.json .idea .DS_Store *.suo *.ntvs* *.njsproj *.sln *.sw? tsconfig.app.tsbuildinfo tsconfig.node.tsbuildinfo /test-results/ /playwright-report/ /blob-report/ /playwright/ .env.test .env.playwright ================================================ FILE: apps/dashboard/.vscode/settings.json ================================================ { "typescript.preferences.importModuleSpecifier": "non-relative", "tailwindCSS.experimental.classRegex": [ ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"], ["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"] ], "editor.codeActionsOnSave": { "source.organizeImports": "always" }, "editor.defaultFormatter": "biomejs.biome", "editor.formatOnSave": true, "[javascript]": { "editor.defaultFormatter": "biomejs.biome" }, "[typescript]": { "editor.defaultFormatter": "biomejs.biome" }, "[typescriptreact]": { "editor.defaultFormatter": "biomejs.biome" } } ================================================ FILE: apps/dashboard/README.md ================================================ # React + TypeScript + Vite This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. Currently, two official plugins are available: - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh ## Expanding the ESLint configuration If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: - Configure the top-level `parserOptions` property like this: ```js export default tseslint.config({ languageOptions: { // other options... parserOptions: { project: ['./tsconfig.node.json', './tsconfig.app.json'], tsconfigRootDir: import.meta.dirname, }, }, }); ``` - Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` - Optionally add `...tseslint.configs.stylisticTypeChecked` - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: ```js // eslint.config.js import react from 'eslint-plugin-react'; export default tseslint.config({ // Set the react version settings: { react: { version: '18.3' } }, plugins: { // Add the react plugin react, }, rules: { // other rules... // Enable its recommended rules ...react.configs.recommended.rules, ...react.configs['jsx-runtime'].rules, }, }); ``` ================================================ FILE: apps/dashboard/components.json ================================================ { "$schema": "https://ui.shadcn.com/schema.json", "style": "new-york", "rsc": false, "tsx": true, "tailwind": { "config": "tailwind.config.js", "css": "src/index.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" }, "iconLibrary": "radix", "aliases": { "components": "@/components", "utils": "@/utils/ui", "ui": "@/components/primitives", "lib": "@/utils", "hooks": "@/hooks" }, "registries": { "@ai-elements": "https://ai-sdk.dev/elements/api/registry/{name}.json" } } ================================================ FILE: apps/dashboard/docker-entrypoint.sh ================================================ #!/bin/sh # Build window._env_ object dynamically from all VITE_ environment variables ENV_VARS="" for var in $(printenv | grep '^VITE_' | cut -d= -f1); do # Get the value of the environment variable eval value=\$$var # Escape single quotes in the value for safe JavaScript escaped_value=$(printf '%s\n' "$value" | sed "s/'/\\\\'/g") # Add to the ENV_VARS string ENV_VARS="${ENV_VARS} ${var}: '${escaped_value}',\n" done # Build the complete script block ENV_SCRIPT="" # Escape newlines for safe sed usage ESCAPED_SCRIPT=$(printf "%s\n" "$ENV_SCRIPT" | sed ':a;N;$!ba;s/\n/\\n/g') # Inject just before the first <% } %> <% if (env.VITE_GTM) { %> <% } %> <% if (env.VITE_SELF_HOSTED === 'false' ) { %> <% } %> <% if (env.VITE_PLAIN_SUPPORT_CHAT_APP_ID) { %> <% } %>
================================================ FILE: apps/dashboard/netlify.toml ================================================ [build] command = "pnpm run build:dashboard --skip-nx-cache" [build.environment] NODE_OPTIONS="--max_old_space_size=4096" [context.deploy-preview] command = "pnpm run build:dashboard --skip-nx-cache" ignore = "git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF apps/dashboard" [[redirects]] from = "/legacy/*" to = "/legacy/index.html" status = 200 [[redirects]] from = "/*" to = "/index.html" status = 200 [[headers]] for = "/*" [headers.values] Document-Policy = "js-profiling" X-XSS-Protection = "1; mode=block" Referrer-Policy = "no-referrer-when-downgrade" X-Content-Type-Options = "nosniff" Content-Security-Policy = "frame-ancestors 'none';" Permissions-Policy = "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=(), interest-cohort=()" Strict-Transport-Security = ''' max-age=63072000; includeSubDomains; preload''' ================================================ FILE: apps/dashboard/package.json ================================================ { "name": "@novu/dashboard", "private": true, "version": "3.14.2", "type": "module", "scripts": { "start": "vite", "start:test": "vite --mode test", "start:static:build": "http-server dist -p 4201 --proxy http://127.0.0.1:4201?", "dev": "pnpm start", "build": "NODE_OPTIONS='--max-old-space-size=8192' tsc -b && NODE_OPTIONS='--max-old-space-size=8192' vite build", "docker:build": "docker buildx build --load -f ./dockerfile -t novu-dashboard ./../.. $DOCKER_BUILD_ARGUMENTS", "lint": "biome lint .", "lint:fix": "biome lint --write .", "format": "biome format .", "format:fix": "biome format --write .", "check": "biome check .", "check:fix": "biome check --write .", "preview": "vite preview", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "test:e2e:debug": "playwright test --debug", "test:e2e:install": "playwright install --with-deps", "test:e2e:codegen": "playwright codegen", "test:e2e:show-report": "npx playwright show-report", "test:e2e:merge-report": "playwright merge-reports --reporter html" }, "dependencies": { "@ai-sdk/react": "^3.0.51", "@better-auth/sso": "^1.3.0", "@calcom/embed-react": "1.5.2", "@clerk/clerk-react": "^5.59.3", "@codemirror/autocomplete": "^6.18.3", "@codemirror/lang-html": "^6.4.9", "@codemirror/lang-liquid": "^6.2.3", "@codemirror/language": "^6.11.1", "@customerio/cdp-analytics-browser": "^0.3.18", "@hookform/resolvers": "^5.2.2", "@inkeep/cxkit-react": "^0.5.107", "@langchain/langgraph-sdk": "^1.5.5", "@lezer/highlight": "^1.2.1", "@novu/api": "workspace:*", "@novu/framework": "workspace:*", "@novu/js": "workspace:*", "@novu/maily-core": "workspace:*", "@novu/react": "workspace:*", "@novu/shared": "workspace:*", "@number-flow/react": "^0.5.10", "@radix-ui/react-accordion": "^1.2.1", "@radix-ui/react-alert-dialog": "^1.1.6", "@radix-ui/react-avatar": "^1.1.2", "@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-collapsible": "^1.1.1", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-progress": "^1.1.0", "@radix-ui/react-radio-group": "^1.2.1", "@radix-ui/react-scroll-area": "^1.2.2", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.6", "@radix-ui/react-use-controllable-state": "^1.2.2", "@radix-ui/react-visually-hidden": "^1.1.0", "@rive-app/react-webgl2": "^4.26.1", "@rjsf/core": "^5.22.3", "@rjsf/utils": "^5.20.0", "@rjsf/validator-ajv8": "^5.17.1", "@segment/analytics-next": "^1.81.0", "@sentry/react": "^8.35.0", "@shopify/prettier-plugin-liquid": "^1.9.3", "@streamdown/cjk": "^1.0.1", "@streamdown/code": "^1.0.1", "@streamdown/math": "^1.0.1", "@streamdown/mermaid": "^1.0.1", "@tailwindcss/postcss": "4.1.18", "@tanstack/react-query": "^5.59.6", "@tiptap/react": "^2.6.6", "@types/js-cookie": "^3.0.6", "@types/lodash.isequal": "^4.5.8", "@uiw/codemirror-extensions-langs": "^4.23.6", "@uiw/codemirror-theme-material": "^4.23.6", "@uiw/codemirror-theme-white": "^4.23.6", "@uiw/codemirror-themes": "^4.23.6", "@uiw/react-codemirror": "^4.23.6", "@xyflow/react": "^12.3.2", "ai": "^6.0.34", "ajv": "^8.18.0", "ajv-formats": "^2.1.1", "ansi-to-react": "^6.2.6", "better-auth": "^1.3.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "cmdk": "1.0.0", "cron-parser": "^4.9.0", "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", "flat": "^6.0.1", "install": "^0.13.0", "js-cookie": "^3.0.5", "json-edit-react": "^1.26.2", "json-schema": "^0.4.0", "json5": "^2.2.3", "launchdarkly-react-client-sdk": "^3.9.0", "liquidjs": "^10.25.0", "lodash.debounce": "^4.0.8", "lodash.isequal": "^4.5.0", "lodash.merge": "^4.6.2", "lucide-react": "^0.562.0", "media-chrome": "^4.17.2", "merge-refs": "^1.3.0", "mixpanel-browser": "^2.52.0", "motion": "^11.18.2", "nanoid": "^3.3.8", "next-themes": "^0.3.0", "npm": "^11.8.0", "prettier": "~3.3.3", "prism-react-renderer": "^2.4.1", "react": "^19.2.3", "react-colorful": "^5.6.1", "react-confetti": "^6.1.0", "react-dom": "^19.2.3", "react-helmet-async": "^1.3.0", "react-hook-form": "^7.71.1", "react-icons": "^5.3.0", "react-phone-number-input": "^3.4.11", "react-querybuilder": "^8.3.0", "react-resizable-panels": "^4.7.6", "react-router-dom": "^7.12.0", "react-timezone-select": "^3.2.8", "recharts": "2.15.4", "shiki": "^3.21.0", "sonner": "^1.7.0", "streamdown": "^2.1.0", "svix-react": "^1.13.4", "tailwind-merge": "^2.4.0", "tailwind-variants": "^0.3.0", "tailwindcss-animate": "^1.0.7", "tokenlens": "^1.3.1", "use-deep-compare-effect": "^1.8.1", "use-stick-to-bottom": "^1.1.2", "uuid": "^11.1.0", "zod": "^4.0.0" }, "devDependencies": { "@biomejs/biome": "2.2.0", "@clerk/backend": "^1.25.2", "@clerk/testing": "^1.3.27", "@clerk/types": "^4.48.0", "@faker-js/faker": "^9.5.0", "@hookform/devtools": "^4.3.0", "@novu/dal": "workspace:*", "@novu/ee-auth": "workspace:*", "@novu/testing": "workspace:*", "@playwright/test": "^1.55.1", "@sentry/vite-plugin": "^2.22.6", "@tiptap/core": "^2.11.5", "@types/json-schema": "^7.0.15", "@types/lodash.debounce": "^4.0.9", "@types/lodash.isequal": "^4.5.8", "@types/lodash.merge": "^4.6.6", "@types/mixpanel-browser": "^2.49.0", "@types/node": "^22.7.0", "@types/react": "^19.2.8", "@types/react-dom": "^19.2.3", "@types/react-window": "^1.8.8", "@types/uuid": "^8.3.4", "@vitejs/plugin-react": "^4.3.1", "cross-fetch": "^4.0.0", "dotenv": "^16.4.5", "express": "^4.21.0", "globals": "^15.9.0", "http-proxy-middleware": "^3.0.5", "http-server": "^0.13.0", "pm2": "^6.0.6", "postcss": "^8.4.47", "rimraf": "^3.0.2", "tailwindcss": "^4.1.18", "typescript": "5.6.2", "vite": "^5.4.21", "vite-plugin-ejs": "^1.7.0", "vite-plugin-static-copy": "^2.3.2" }, "nx": { "tags": [ "type:app" ], "targets": { "lint": { "executor": "nx:run-commands", "options": { "command": "npx biome lint apps/dashboard" } } } } } ================================================ FILE: apps/dashboard/playwright.config.ts ================================================ import { defineConfig, devices } from '@playwright/test'; /** * Read environment variables from file. * https://github.com/motdotla/dotenv */ import dotenv from 'dotenv'; import path, { dirname } from 'path'; import { fileURLToPath } from 'url'; const fileName = fileURLToPath(import.meta.url); const dirName = dirname(fileName); dotenv.config({ path: path.resolve(dirName, '.env.playwright') }); const baseURL = `http://localhost:4201`; /** * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ testDir: './tests', /* Run tests in files in parallel */ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, /* Retry on CI only */ retries: process.env.CI ? 5 : 3, /* Use 1 workers in CI, 50% of CPU count in local */ workers: process.env.CI ? 1 : '25%', /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: process.env.CI ? 'blob' : 'html', /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ webServer: { command: 'pnpm start:test', url: baseURL, timeout: 180 * 1000, reuseExistingServer: !process.env.CI, }, use: { /* Base URL to use in actions like `await page.goto('/')`. */ baseURL: baseURL, /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'retain-on-failure', permissions: ['clipboard-read'], }, timeout: 180_000, expect: { timeout: 30_000, }, /* Configure projects for major browsers */ projects: [ { name: 'chromium', testMatch: /.*\.e2e\.ts/, use: { ...devices['Desktop Chrome'], viewport: { width: 1512, height: 982 }, video: { mode: 'retain-on-failure', size: { width: 1512, height: 982 }, }, }, }, ], }); ================================================ FILE: apps/dashboard/postcss.config.js ================================================ export default { plugins: { '@tailwindcss/postcss': {}, }, }; ================================================ FILE: apps/dashboard/public/manifest.json ================================================ { "short_name": "Novu Dashboard", "name": "Novu Dashboard application", "icons": [ { "src": "favicon.ico", "sizes": "64x64 32x32 24x24 16x16", "type": "image/x-icon" } ], "start_url": ".", "display": "standalone", "theme_color": "#000000", "background_color": "#ffffff" } ================================================ FILE: apps/dashboard/src/api/activity.ts ================================================ import { getDateRangeInMs, type IActivity, type IEnvironment, SeverityLevelEnum } from '@novu/shared'; import { get } from './api.client'; export type ActivityFilters = { channels?: string[]; workflows?: string[]; email?: string; subscriberId?: string; transactionId?: string; dateRange?: string; topicKey?: string; subscriptionId?: string; severity?: SeverityLevelEnum[]; contextKeys?: string[]; }; export interface ActivityResponse { data: IActivity[]; hasMore: boolean; pageSize: number; next?: string | null; previous?: string | null; } export interface StepRunDto { stepRunId: string; stepId: string; stepType: string; providerId?: string; status: StepRunStatus; createdAt: Date; updatedAt: Date; executionDetails: any[]; digest?: any; scheduleExtensionsCount?: number; } export interface GetWorkflowRunsDto { id: string; workflowRunId: string; workflowId: string; workflowName: string; organizationId: string; environmentId: string; internalSubscriberId: string; subscriberId?: string; status: 'success' | 'error' | 'pending' | 'skipped' | 'canceled' | 'merged'; triggerIdentifier: string; transactionId: string; createdAt: string; updatedAt: string; steps: StepRunDto[]; severity: SeverityLevelEnum; critical: boolean; contextKeys?: string[]; topics?: { _topicId: string; topicKey: string }[]; } export type GetWorkflowRunResponse = GetWorkflowRunsDto & { payload: Record; overrides?: Record; }; export interface GetWorkflowRunsResponseDto { data: GetWorkflowRunsDto[]; next: string | null; previous: string | null; } function mapWorkflowRunToActivity(workflowRun: GetWorkflowRunResponse | GetWorkflowRunsDto): IActivity { const resolvedOverrides = ('overrides' in workflowRun ? (workflowRun.overrides ?? {}) : {}) as Record< string, Record >; return { _id: workflowRun.id, severity: workflowRun.severity, critical: workflowRun.critical, _templateId: workflowRun.workflowId, _environmentId: workflowRun.environmentId, _organizationId: workflowRun.organizationId, _subscriberId: workflowRun.internalSubscriberId, transactionId: workflowRun.transactionId, channels: [], // Not available in workflow runs, empty array for compatibility to: { subscriberId: workflowRun.subscriberId || workflowRun.internalSubscriberId, }, payload: 'payload' in workflowRun ? workflowRun.payload : {}, tags: [], // Not available in workflow runs, empty array for compatibility createdAt: workflowRun.createdAt, updatedAt: workflowRun.updatedAt, contextKeys: workflowRun.contextKeys || [], topics: workflowRun.topics || [], template: { _id: workflowRun.workflowId, name: workflowRun.workflowName, triggers: [ { type: 'event' as any, identifier: workflowRun.triggerIdentifier, variables: [], }, ], origin: undefined, }, subscriber: workflowRun.subscriberId ? { _id: workflowRun.internalSubscriberId, subscriberId: workflowRun.subscriberId, firstName: '', lastName: '', } : undefined, jobs: workflowRun.steps.map((step: StepRunDto) => ({ _id: step.stepRunId, identifier: step.stepRunId, subscriberId: workflowRun.subscriberId || workflowRun.internalSubscriberId, _subscriberId: workflowRun.internalSubscriberId, type: step.stepType as any, digest: step.digest, executionDetails: step.executionDetails || [], step: { _id: step.stepRunId, active: true, shouldStopOnFail: false, template: { _environmentId: workflowRun.environmentId, _organizationId: workflowRun.organizationId, _creatorId: '', type: step.stepType as any, content: '', variables: [], name: step.stepType, subject: '', title: step.stepType, preheader: '', senderName: '', _feedId: '', cta: { type: 'redirect' as any, data: { url: '' }, }, _layoutId: null, active: true, }, filters: [], _templateId: workflowRun.workflowId, _parentId: '', }, _organizationId: workflowRun.organizationId, _environmentId: workflowRun.environmentId, _userId: '', // delay: step.delay, _notificationId: workflowRun.id, status: step.status === 'queued' ? 'pending' : (step.status as any), _templateId: workflowRun.workflowId, payload: 'payload' in workflowRun ? workflowRun.payload : {}, providerId: step.providerId, overrides: resolvedOverrides, transactionId: workflowRun.transactionId, createdAt: workflowRun.createdAt, updatedAt: workflowRun.updatedAt, scheduleExtensionsCount: step.scheduleExtensionsCount, })), }; } // Mapping function to convert workflow runs to activities (legacy format) function mapWorkflowRunsToActivity(workflowRun: GetWorkflowRunsDto): IActivity { // Override the job _id to use the legacy step.id field const activity = mapWorkflowRunToActivity(workflowRun); activity.jobs = activity.jobs.map((job, index) => ({ ...job, _id: workflowRun.steps[index].stepId, })); return activity; } export function getActivityList({ environment, page, limit, filters, signal, }: { environment: IEnvironment; page: number; limit: number; filters?: ActivityFilters; signal?: AbortSignal; }): Promise { const searchParams = new URLSearchParams(); searchParams.append('page', page.toString()); searchParams.append('limit', limit.toString()); if (filters?.channels?.length) { for (const channel of filters.channels) { searchParams.append('channels', channel); } } if (filters?.severity?.length) { for (const severity of filters.severity) { searchParams.append('severity', severity); } } if (filters?.workflows?.length) { for (const workflow of filters.workflows) { searchParams.append('templates', workflow); } } if (filters?.email) { searchParams.append('emails', filters.email); } if (filters?.subscriberId) { searchParams.append('subscriberIds', filters.subscriberId); } if (filters?.transactionId) { // Parse comma-delimited string into array for backend const transactionIds = filters.transactionId .split(',') .map((id) => id.trim()) .filter(Boolean); if (transactionIds.length > 1) { for (const id of transactionIds) { searchParams.append('transactionId', id); } } else { searchParams.append('transactionId', filters.transactionId); } } if (filters?.topicKey) { searchParams.append('topicKey', filters.topicKey); } if (filters?.subscriptionId) { searchParams.append('subscriptionId', filters.subscriptionId); } if (filters?.contextKeys?.length) { for (const key of filters.contextKeys) { searchParams.append('contextKeys', key); } } if (filters?.dateRange) { const after = new Date(Date.now() - getDateRangeInMs(filters?.dateRange)); searchParams.append('after', after.toISOString()); } return get(`/notifications?${searchParams.toString()}`, { environment, signal, }); } // Types for the new workflow run endpoint export type StepRunStatus = | 'pending' | 'queued' | 'running' | 'completed' | 'failed' | 'delayed' | 'canceled' | 'merged' | 'skipped'; export type GetWorkflowRunResponseDto = { data: GetWorkflowRunResponse; }; export async function getWorkflowRunsList({ environment, page, limit, filters, signal, cursor, }: { environment: IEnvironment; page?: number; limit: number; filters?: ActivityFilters; signal?: AbortSignal; cursor?: string | null; }): Promise { const searchParams = new URLSearchParams(); searchParams.append('limit', limit.toString()); if (filters?.channels?.length) { for (const channel of filters.channels) { searchParams.append('channels', channel); } } if (filters?.topicKey) { searchParams.append('topicKey', filters.topicKey); } if (filters?.subscriptionId) { searchParams.append('subscriptionId', filters.subscriptionId); } // Use cursor if provided, otherwise fall back to page-based if (cursor) { searchParams.append('cursor', cursor); } else if (page && page > 0) { // For backward compatibility, convert page to cursor searchParams.append('cursor', `page_${page}`); } if (filters?.workflows?.length) { for (const workflow of filters.workflows) { searchParams.append('workflowIds', workflow); } } if (filters?.subscriberId) { searchParams.append('subscriberIds', filters.subscriberId); } if (filters?.transactionId) { // Parse comma-delimited string into array for backend const transactionIds = filters.transactionId .split(',') .map((id) => id.trim()) .filter(Boolean); if (transactionIds.length > 1) { for (const id of transactionIds) { searchParams.append('transactionId', id); } } else { searchParams.append('transactionIds', filters.transactionId); } } if (filters?.dateRange) { const after = new Date(Date.now() - getDateRangeInMs(filters?.dateRange)); searchParams.append('createdGte', after.toISOString()); } if (filters?.severity?.length) { for (const severity of filters.severity) { searchParams.append('severity', severity); } } if (filters?.contextKeys?.length) { for (const key of filters.contextKeys) { searchParams.append('contextKeys', key); } } const response = await get(`/activity/workflow-runs?${searchParams.toString()}`, { environment, signal, }); const mappedData = response.data.map(mapWorkflowRunsToActivity); return { data: mappedData, hasMore: !!response.next, // Convert cursor-based to boolean pageSize: response.data.length, next: response.next, previous: response.previous, }; } export async function getNotification(notificationId: string, environment: IEnvironment): Promise { const { data } = await get<{ data: IActivity }>(`/notifications/${notificationId}`, { environment, }); return data; } export async function getWorkflowRun(workflowRunId: string, environment: IEnvironment): Promise { const data = await get(`/activity/workflow-runs/${workflowRunId}`, { environment, }); return mapWorkflowRunToActivity(data.data); } export type WorkflowRunsCountPeriod = { start: string; end: string; }; export async function getWorkflowRunsCount({ environment, filters, period, signal, }: { environment: IEnvironment; filters?: ActivityFilters; period?: WorkflowRunsCountPeriod; signal?: AbortSignal; }): Promise { let createdAtGte: string | undefined; let createdAtLte: string | undefined; let workflowIds: string[] | undefined; let subscriberIds: string[] | undefined; let transactionIds: string[] | undefined; let channels: string[] | undefined; let topicKey: string | undefined; if (filters?.channels?.length) { channels = filters.channels; } if (filters?.topicKey) { topicKey = filters.topicKey; } if (filters?.workflows?.length) { workflowIds = filters.workflows; } if (filters?.subscriberId) { subscriberIds = [filters.subscriberId]; } if (filters?.transactionId) { transactionIds = filters.transactionId .split(',') .map((id) => id.trim()) .filter(Boolean); } if (period) { createdAtGte = period.start; createdAtLte = period.end; } else if (filters?.dateRange) { const after = new Date(Date.now() - getDateRangeInMs(filters?.dateRange)); createdAtGte = after.toISOString(); } const response = await getCharts({ environment, createdAtGte, createdAtLte, reportType: [ReportTypeEnum.WORKFLOW_RUNS_COUNT], workflowIds, subscriberIds, transactionIds, channels, topicKey, signal, }); const countData = response.data[ReportTypeEnum.WORKFLOW_RUNS_COUNT] as WorkflowRunsCountDataPoint; return countData?.count ?? 0; } // Charts API types and functions export enum ReportTypeEnum { DELIVERY_TREND = 'delivery-trend', INTERACTION_TREND = 'interaction-trend', WORKFLOW_BY_VOLUME = 'workflow-by-volume', PROVIDER_BY_VOLUME = 'provider-by-volume', MESSAGES_DELIVERED = 'messages-delivered', ACTIVE_SUBSCRIBERS = 'active-subscribers', AVG_MESSAGES_PER_SUBSCRIBER = 'avg-messages-per-subscriber', WORKFLOW_RUNS_METRIC = 'workflow-runs-metric', TOTAL_INTERACTIONS = 'total-interactions', WORKFLOW_RUNS_TREND = 'workflow-runs-trend', ACTIVE_SUBSCRIBERS_TREND = 'active-subscribers-trend', WORKFLOW_RUNS_COUNT = 'workflow-runs-count', } export type ChartDataPoint = { timestamp: string; inApp: number; email: number; sms: number; chat: number; push: number; }; export type InteractionTrendDataPoint = { timestamp: string; messageSeen: number; messageRead: number; messageSnoozed: number; messageArchived: number; }; export type WorkflowVolumeDataPoint = { workflowName: string; count: number; }; export type ProviderVolumeDataPoint = { providerId: string; count: number; }; export type MessagesDeliveredDataPoint = { currentPeriod: number; previousPeriod: number; }; export type ActiveSubscribersDataPoint = { currentPeriod: number; previousPeriod: number; }; export type AvgMessagesPerSubscriberDataPoint = { currentPeriod: number; previousPeriod: number; }; export type WorkflowRunsMetricDataPoint = { currentPeriod: number; previousPeriod: number; }; export type TotalInteractionsDataPoint = { currentPeriod: number; previousPeriod: number; }; export type WorkflowRunsTrendDataPoint = { timestamp: string; processing: number; completed: number; error: number; }; export type ActiveSubscribersTrendDataPoint = { timestamp: string; count: number; }; export type WorkflowRunsCountDataPoint = { count: number; }; export type GetChartsRequest = { createdAtGte?: string; createdAtLte?: string; reportType: ReportTypeEnum[]; workflowIds?: string[]; subscriberIds?: string[]; transactionIds?: string[]; statuses?: string[]; channels?: string[]; topicKey?: string; }; export type GetChartsResponse = { data: Record< ReportTypeEnum, | ChartDataPoint[] | InteractionTrendDataPoint[] | WorkflowVolumeDataPoint[] | ProviderVolumeDataPoint[] | MessagesDeliveredDataPoint | ActiveSubscribersDataPoint | AvgMessagesPerSubscriberDataPoint | WorkflowRunsMetricDataPoint | TotalInteractionsDataPoint | WorkflowRunsTrendDataPoint[] | ActiveSubscribersTrendDataPoint[] | WorkflowRunsCountDataPoint >; }; export async function getCharts({ environment, createdAtGte, createdAtLte, reportType, workflowIds, subscriberIds, transactionIds, statuses, channels, topicKey, signal, }: { environment: IEnvironment; createdAtGte?: string; createdAtLte?: string; reportType: ReportTypeEnum[]; workflowIds?: string[]; subscriberIds?: string[]; transactionIds?: string[]; statuses?: string[]; channels?: string[]; topicKey?: string; signal?: AbortSignal; }): Promise { const searchParams = new URLSearchParams(); if (createdAtGte) { searchParams.append('createdAtGte', createdAtGte); } if (createdAtLte) { searchParams.append('createdAtLte', createdAtLte); } for (const type of reportType) { searchParams.append('reportType[]', type); } if (workflowIds?.length) { for (const id of workflowIds) { searchParams.append('workflowIds[]', id); } } if (subscriberIds?.length) { for (const id of subscriberIds) { searchParams.append('subscriberIds[]', id); } } if (transactionIds?.length) { for (const id of transactionIds) { searchParams.append('transactionIds[]', id); } } if (statuses?.length) { for (const status of statuses) { searchParams.append('statuses[]', status); } } if (channels?.length) { for (const channel of channels) { searchParams.append('channels[]', channel); } } if (topicKey) { searchParams.append('topicKey', topicKey); } return get(`/activity/charts?${searchParams.toString()}`, { environment, signal, }); } ================================================ FILE: apps/dashboard/src/api/ai.ts ================================================ import { AiConversationStatusEnum, AiMessageRoleEnum, AiResourceTypeEnum, IEnvironment, WorkflowResponseDto, } from '@novu/shared'; import { UIMessage } from 'ai'; import { getApiBaseUrl, getV2, postV2 } from './api.client'; export type GenerateWorkflowRequest = { prompt: string; }; export type AiMessage = { role: AiMessageRoleEnum; content: string; timestamp: Date; }; export type ChannelRecommendation = { channel: string; reason: string; priority: number; }; export type WorkflowReasoning = { summary: string; channelRecommendations: ChannelRecommendation[]; bestPractices: string[]; }; export type GenerateWorkflowResponse = { messages: AiMessage[]; status: AiConversationStatusEnum; workflow: WorkflowResponseDto; reasoning: WorkflowReasoning; }; export type AiChatSnapshotRef = { _snapshotId: string; messageId: string; checkpointId?: string; }; export type AiChatResponseDto = { _id: string; _organizationId: string; _environmentId: string; _userId: string; resourceType: AiResourceTypeEnum; resourceId?: string; messages: UIMessage[]; activeStreamId?: string | null; snapshots?: AiChatSnapshotRef[]; hasPendingChanges: boolean; createdAt: string; updatedAt: string; }; export async function createAiChat({ environment, resourceType, resourceId, }: { environment: IEnvironment; resourceType: AiResourceTypeEnum; resourceId?: string; }): Promise { const { data: responseData } = await postV2<{ data: AiChatResponseDto }>('/ai/chat', { environment, body: { resourceType, resourceId }, }); return responseData; } export async function fetchLatestChat({ environment, resourceType, resourceId, }: { environment: IEnvironment; resourceType: AiResourceTypeEnum; resourceId: string; }): Promise { const { data: responseData } = await getV2<{ data: AiChatResponseDto }>( `/ai/chat/${resourceType}/${resourceId}/latest`, { environment } ); return responseData; } export async function fetchChat({ environment, id, }: { environment: IEnvironment; id: string; }): Promise { const { data: responseData } = await getV2<{ data: AiChatResponseDto }>(`/ai/chat/${id}`, { environment }); return responseData; } export function getChatStreamUrl(): string { return `${getApiBaseUrl()}/v2/ai/chat-stream`; } export async function keepAiChanges({ environment, chatId, messageId, }: { environment: IEnvironment; chatId: string; messageId: string; }): Promise<{ success: boolean }> { const { data: responseData } = await postV2<{ data: { success: boolean } }>('/ai/keep-changes', { environment, body: { chatId, messageId }, }); return responseData; } export async function revertMessage({ environment, chatId, messageId, type, }: { environment: IEnvironment; chatId: string; messageId: string; type: 'revert' | 'try-again'; }): Promise { await postV2('/ai/revert-message', { environment, body: { chatId, messageId, type }, }); } export async function cancelStream({ environment, chatId, }: { environment: IEnvironment; chatId: string; }): Promise<{ success: boolean }> { const { data: responseData } = await postV2<{ data: { success: boolean } }>('/ai/chat-stream/cancel', { environment, body: { chatId }, }); return responseData; } ================================================ FILE: apps/dashboard/src/api/api.client.ts ================================================ import { IEnvironment } from '@novu/shared'; import { apiHostnameManager } from '@/utils/api-hostname-manager'; import { getToken } from '@/utils/auth'; // This is how we import the speakeasy autogenerated Novu SDK that is CJS in a the Dashboard ESM project with Vite // Read more at https://github.com/vitejs/vite/issues/5668#issuecomment-968117934 /** DO NOT CHANGE THIS CODE START */ // import * as NovuAPI from '@novu/api'; // const { Novu } = NovuAPI; // export const novuClient = new Novu(); /** DO NOT CHANGE THIS CODE END */ export class NovuApiError extends Error { constructor( public message: string, public status: number, public rawError?: unknown ) { super(message); } } type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; const request = async ( endpoint: string, options?: { environment?: IEnvironment; body?: unknown; method?: HttpMethod; headers?: HeadersInit; version?: 'v1' | 'v2'; signal?: AbortSignal; } ): Promise => { const { body, environment, headers, method = 'GET', version = 'v1', signal } = options || {}; try { const jwt = await getToken(); const config: RequestInit = { method, headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json', ...(environment && { 'Novu-Environment-Id': environment._id }), ...headers, }, signal, }; if (body) { if (body instanceof FormData) { // For FormData, don't stringify and don't set Content-Type (let browser handle it) config.body = body; // Remove Content-Type header for FormData delete (config.headers as Record)['Content-Type']; } else { config.body = JSON.stringify(body); } } const baseUrl = apiHostnameManager.getHostname(); const response = await fetch(`${baseUrl}/${version}${endpoint}`, config); if (!response.ok) { const errorData = await response.json(); throw new NovuApiError(parseErrorMessage(errorData), response.status, errorData); } if (response.status === 204) { return {} as T; } return await response.json(); } catch (error) { if (error instanceof NovuApiError) { throw error; } if (typeof error === 'object' && error && 'message' in error) { throw new Error(`Fetch error: ${error.message}`); } throw new Error(`Fetch error: ${JSON.stringify(error)}`); } }; type RequestOptions = { body?: unknown; environment?: IEnvironment; signal?: AbortSignal; headers?: HeadersInit }; export const get = (endpoint: string, { environment, signal, headers }: RequestOptions = {}) => request(endpoint, { method: 'GET', environment, signal, headers }); export const post = (endpoint: string, options: RequestOptions) => request(endpoint, { method: 'POST', ...options }); export const put = (endpoint: string, options: RequestOptions) => request(endpoint, { method: 'PUT', ...options }); export const del = (endpoint: string, { environment, signal }: RequestOptions = {}) => request(endpoint, { method: 'DELETE', environment, signal }); export const patch = (endpoint: string, options: RequestOptions) => request(endpoint, { method: 'PATCH', ...options }); export const getV2 = (endpoint: string, { environment, signal }: RequestOptions = {}) => request(endpoint, { version: 'v2', method: 'GET', environment, signal }); export const postV2 = (endpoint: string, options: RequestOptions) => request(endpoint, { version: 'v2', method: 'POST', ...options }); export const putV2 = (endpoint: string, options: RequestOptions) => request(endpoint, { version: 'v2', method: 'PUT', ...options }); export const delV2 = (endpoint: string, options: RequestOptions) => request(endpoint, { version: 'v2', method: 'DELETE', ...options }); export const patchV2 = (endpoint: string, options: RequestOptions) => request(endpoint, { version: 'v2', method: 'PATCH', ...options }); function parseErrorMessage(errorData: any): string { const DEFAULT_ERROR = 'Novu API error'; if (!errorData?.message) { return DEFAULT_ERROR; } if (Array.isArray(errorData.message)) { return errorData.message.filter(Boolean).join('. ') || DEFAULT_ERROR; } if (typeof errorData.message !== 'string') { return errorData.message?.message || DEFAULT_ERROR; } try { const parsedMessage = JSON.parse(errorData.message); return parsedMessage.message || DEFAULT_ERROR; } catch { return errorData.message?.message || errorData.message || DEFAULT_ERROR; } } export function getApiBaseUrl(): string { return apiHostnameManager.getHostname(); } ================================================ FILE: apps/dashboard/src/api/billing.ts ================================================ import type { GetSubscriptionDto, IEnvironment } from '@novu/shared'; import { get } from './api.client'; export async function getSubscription({ environment }: { environment: IEnvironment }) { const { data } = await get<{ data: GetSubscriptionDto }>('/billing/subscription', { environment }); return data; } ================================================ FILE: apps/dashboard/src/api/bridge.ts ================================================ import type { HealthCheck } from '@novu/framework/internal'; import type { IEnvironment, IValidateBridgeUrlResponse } from '@novu/shared'; import { get, post } from './api.client'; export const getBridgeHealthCheck = async ({ environment }: { environment: IEnvironment }) => { const { data } = await get<{ data: HealthCheck }>('/bridge/status', { environment }); return data; }; export const validateBridgeUrl = async ({ bridgeUrl, environment, }: { bridgeUrl: string; environment: IEnvironment; }) => { const { data } = await post<{ data: IValidateBridgeUrlResponse }>('/bridge/validate', { environment, body: { bridgeUrl }, }); return data; }; ================================================ FILE: apps/dashboard/src/api/contexts.ts ================================================ import { CreateContextRequestDto, GetContextResponseDto, ListContextsResponseDto, UpdateContextRequestDto, } from '@novu/api/models/components'; import type { ContextId, ContextType, DirectionEnum, IEnvironment } from '@novu/shared'; import { delV2, getV2, patchV2, postV2 } from './api.client'; export const getContexts = async ({ environment, limit = 10, after, before, orderDirection, orderBy = 'createdAt', includeCursor, type, id, search, }: { environment: IEnvironment; limit?: number; after?: string; before?: string; orderDirection?: DirectionEnum; orderBy?: 'createdAt' | 'updatedAt'; includeCursor?: boolean; type?: ContextType; id?: ContextId; search?: string; }): Promise => { const params = new URLSearchParams(); params.append('limit', limit.toString()); if (after) { params.append('after', after); } if (before) { params.append('before', before); } if (orderDirection) { params.append('orderDirection', orderDirection); } if (orderBy) { params.append('orderBy', orderBy); } if (includeCursor !== undefined) { params.append('includeCursor', includeCursor.toString()); } if (type) { params.append('type', type); } if (id) { params.append('id', id); } if (search) { params.append('search', search); } const response = await getV2(`/contexts?${params.toString()}`, { environment, }); return response; }; export const getContext = async ({ environment, type, id, }: { environment: IEnvironment; type: ContextType; id: ContextId; }): Promise => { const { data } = await getV2<{ data: GetContextResponseDto }>(`/contexts/${type}/${id}`, { environment, }); return data; }; export const createContext = async ({ environment, type, id, data, }: { environment: IEnvironment; type: ContextType; id: ContextId; data?: CreateContextRequestDto['data']; }): Promise => { const { data: responseData } = await postV2<{ data: GetContextResponseDto }>(`/contexts`, { environment, body: { type, id, data }, }); return responseData; }; export const updateContext = async ({ environment, type, id, data, }: { environment: IEnvironment; type: ContextType; id: ContextId; data: UpdateContextRequestDto['data']; }): Promise => { const { data: responseData } = await patchV2<{ data: GetContextResponseDto }>(`/contexts/${type}/${id}`, { environment, body: { data }, }); return responseData; }; export const deleteContext = async ({ environment, type, id, }: { environment: IEnvironment; type: ContextType; id: ContextId; }): Promise => { await delV2(`/contexts/${type}/${id}`, { environment, }); }; ================================================ FILE: apps/dashboard/src/api/environment-variables.ts ================================================ import { del, get, patch, post } from './api.client'; export type EnvironmentVariableValueDto = { _environmentId: string; value: string; }; export type EnvironmentVariableResponseDto = { _id: string; _organizationId: string; key: string; type: string; isSecret: boolean; defaultValue?: string; values: EnvironmentVariableValueDto[]; createdAt: string; updatedAt: string; }; export type CreateEnvironmentVariableDto = { key: string; type?: string; isSecret?: boolean; defaultValue?: string; values?: EnvironmentVariableValueDto[]; }; export type UpdateEnvironmentVariableDto = { key?: string; type?: string; isSecret?: boolean; defaultValue?: string; values?: EnvironmentVariableValueDto[]; }; export const getEnvironmentVariables = async ({ search, }: { search?: string; } = {}): Promise => { const params = new URLSearchParams(); if (search) { params.append('search', search); } const query = params.toString(); const { data } = await get<{ data: EnvironmentVariableResponseDto[] }>( `/environment-variables${query ? `?${query}` : ''}` ); return data; }; export const getEnvironmentVariable = async (variableId: string): Promise => { const { data } = await get<{ data: EnvironmentVariableResponseDto }>(`/environment-variables/${variableId}`); return data; }; export const createEnvironmentVariable = async ( body: CreateEnvironmentVariableDto ): Promise => { const { data } = await post<{ data: EnvironmentVariableResponseDto }>(`/environment-variables`, { body }); return data; }; export const updateEnvironmentVariable = async ( variableId: string, body: UpdateEnvironmentVariableDto ): Promise => { const { data } = await patch<{ data: EnvironmentVariableResponseDto }>(`/environment-variables/${variableId}`, { body, }); return data; }; export const deleteEnvironmentVariable = async (variableId: string): Promise => { await del(`/environment-variables/${variableId}`); }; export type GetEnvironmentVariableUsageResponse = { workflows: { name: string; workflowId: string }[]; }; export const getEnvironmentVariableUsage = async (variableId: string): Promise => { const { data } = await get<{ data: GetEnvironmentVariableUsageResponse }>( `/environment-variables/${variableId}/usage` ); return data; }; ================================================ FILE: apps/dashboard/src/api/environments.ts ================================================ import { IApiKey, IEnvironment, ITagsResponse } from '@novu/shared'; import { del, get, getV2, post, postV2, put } from './api.client'; export interface IDiffSummary { added: number; modified: number; deleted: number; unchanged: number; } export interface IUserInfo { _id: string; firstName: string; lastName?: string | null; externalId?: string; } export interface IResourceInfo { id: string | null; name: string | null; updatedBy?: IUserInfo | null; updatedAt?: string | null; } export interface IResourceDependency { resourceType: string; resourceId: string; resourceName: string; isBlocking: boolean; reason: 'LAYOUT_REQUIRED_FOR_WORKFLOW' | 'LAYOUT_EXISTS_IN_TARGET'; } export interface IResourceDiffResult { resourceType: string; sourceResource?: IResourceInfo | null; targetResource?: IResourceInfo | null; changes: any[]; summary: IDiffSummary; dependencies?: IResourceDependency[]; } export interface IEnvironmentDiffResponse { sourceEnvironmentId: string; targetEnvironmentId: string; resources: IResourceDiffResult[]; summary: { totalEntities: number; totalChanges: number; hasChanges: boolean; }; } export interface IEnvironmentPublishResponse { sourceEnvironmentId?: string; targetEnvironmentId?: string; results: Array<{ resourceType: string; successful: Array<{ resourceType: string; resourceId: string; resourceName: string; action: string; }>; failed: Array<{ resourceType: string; resourceId: string; resourceName: string; error: string; }>; skipped: Array<{ resourceType: string; resourceId: string; resourceName: string; reason: string; }>; totalProcessed: number; }>; summary: { resources: number; successful: number; failed: number; skipped: number; }; } export type ResourceToPublish = { resourceType: 'workflow' | 'layout' | 'localization_group' | 'step'; resourceId: string; }; export async function getEnvironments() { const { data } = await get<{ data: IEnvironment[] }>('/environments'); return data; } export async function updateEnvironment({ environment, name, color, }: { environment: IEnvironment; name: string; color?: string; }) { return put<{ data: IEnvironment }>(`/environments/${environment._id}`, { body: { name, color } }); } export async function updateBridgeUrl({ environment, url }: { environment: IEnvironment; url?: string }) { return put(`/environments/${environment._id}`, { body: { bridge: { url } } }); } export async function getApiKeys({ environment }: { environment: IEnvironment }): Promise<{ data: IApiKey[] }> { // TODO: This is a technical debt on the API side. // This endpoints should be /environments/:environmentId/api-keys return get<{ data: IApiKey[] }>(`/environments/api-keys`, { environment }); } export async function getTags({ environment }: { environment: IEnvironment }): Promise { const { data } = await getV2<{ data: ITagsResponse }>(`/environments/${environment._id}/tags`); return data; } export async function createEnvironment(payload: { name: string; color: string }): Promise { const response = await post<{ data: IEnvironment }>('/environments', { body: payload }); return response.data; } export async function deleteEnvironment({ environment }: { environment: IEnvironment }): Promise { return del(`/environments/${environment._id}`); } export async function regenerateApiKeys({ environment }: { environment: IEnvironment }): Promise<{ data: IApiKey[] }> { return post<{ data: IApiKey[] }>(`/environments/api-keys/regenerate`, { environment }); } export async function diffEnvironments({ sourceEnvironmentId, targetEnvironmentId, }: { sourceEnvironmentId: string; targetEnvironmentId: string; }): Promise { const { data } = await postV2<{ data: IEnvironmentDiffResponse }>(`/environments/${targetEnvironmentId}/diff`, { body: { sourceEnvironmentId }, }); return data; } export async function publishEnvironments({ sourceEnvironmentId, targetEnvironmentId, resources, }: { sourceEnvironmentId: string; targetEnvironmentId: string; resources?: ResourceToPublish[]; }): Promise { const { data } = await postV2<{ data: IEnvironmentPublishResponse }>(`/environments/${targetEnvironmentId}/publish`, { body: { sourceEnvironmentId, dryRun: false, ...(resources && { resources }), }, }); return data; } ================================================ FILE: apps/dashboard/src/api/integrations.ts ================================================ import { ChannelTypeEnum, IEnvironment, IIntegration } from '@novu/shared'; import { del, get, post, put } from './api.client'; export type CreateIntegrationData = { providerId: string; channel: ChannelTypeEnum; credentials: Record; configurations: Record; name: string; identifier: string; active: boolean; primary?: boolean; _environmentId: string; }; export enum CheckIntegrationResponseEnum { INVALID_EMAIL = 'invalid_email', BAD_CREDENTIALS = 'bad_credentials', SUCCESS = 'success', FAILED = 'failed', } export type UpdateIntegrationData = { name: string; identifier: string; active: boolean; primary: boolean; credentials: Record; configurations: Record; check: boolean; }; export async function getIntegrations({ environment }: { environment: IEnvironment }) { // TODO: This is a technical debt on the API side. // Integrations work across environments, so we should not need to pass the environment ID here. const { data } = await get<{ data: IIntegration[] }>('/integrations', { environment }); return data; } export async function deleteIntegration({ id, environment }: { id: string; environment: IEnvironment }) { return del<{ acknowledged: boolean; status: number }>(`/integrations/${id}`, { environment: environment, }); } export async function createIntegration(data: CreateIntegrationData, environment: IEnvironment) { return await post<{ data: IIntegration }>('/integrations', { body: data, environment: environment, }); } export async function setAsPrimaryIntegration(integrationId: string, environment: IEnvironment) { return post(`/integrations/${integrationId}/set-primary`, { environment: environment, }); } export type AutoConfigureIntegrationResponse = { success: boolean; message?: string; integration?: IIntegration; }; export async function autoConfigureIntegration(integrationId: string, environment: IEnvironment) { const response = await post<{ data: AutoConfigureIntegrationResponse }>( `/integrations/${integrationId}/auto-configure`, { environment: environment, } ); return response.data; } export async function updateIntegration(integrationId: string, data: UpdateIntegrationData, environment: IEnvironment) { return await put(`/integrations/${integrationId}`, { body: data, environment: environment, }); } ================================================ FILE: apps/dashboard/src/api/layouts.ts ================================================ import { CreateLayoutDto, GeneratePreviewResponseDto, IEnvironment, LayoutResponseDto, ListLayoutsResponse, UpdateLayoutDto, } from '@novu/shared'; import { delV2, getV2, postV2, putV2 } from './api.client'; export type WorkflowInfo = { name: string; workflowId: string; }; export type GetLayoutUsageResponse = { workflows: WorkflowInfo[]; }; export const getLayouts = async ({ environment, limit, query, offset, orderBy, orderDirection, }: { environment: IEnvironment; limit: number; offset: number; query: string; orderBy?: string; orderDirection?: string; }): Promise => { const params = new URLSearchParams({ limit: limit.toString(), offset: offset.toString(), query, }); if (orderBy) { params.append('orderBy', orderBy); } if (orderDirection) { params.append('orderDirection', orderDirection.toUpperCase()); } const { data } = await getV2<{ data: ListLayoutsResponse }>(`/layouts?${params.toString()}`, { environment }); return data; }; export const createLayout = async ({ environment, layout }: { environment: IEnvironment; layout: CreateLayoutDto }) => { const { data } = await postV2<{ data: LayoutResponseDto }>(`/layouts`, { environment, body: layout }); return data; }; export const getLayout = async ({ environment, layoutSlug }: { environment: IEnvironment; layoutSlug: string }) => { const { data } = await getV2<{ data: LayoutResponseDto }>(`/layouts/${layoutSlug}`, { environment }); return data; }; export const updateLayout = async ({ environment, layout, layoutSlug, }: { environment: IEnvironment; layout: UpdateLayoutDto; layoutSlug: string; }) => { const { data } = await putV2<{ data: LayoutResponseDto }>(`/layouts/${layoutSlug}`, { environment, body: layout }); return data; }; export const deleteLayout = async ({ environment, layoutSlug }: { environment: IEnvironment; layoutSlug: string }) => { await delV2(`/layouts/${layoutSlug}`, { environment }); }; export const duplicateLayout = async ({ environment, layoutSlug, data, }: { environment: IEnvironment; layoutSlug: string; data: { name: string; isTranslationEnabled: boolean }; }) => { const { data: result } = await postV2<{ data: LayoutResponseDto }>(`/layouts/${layoutSlug}/duplicate`, { environment, body: data, }); return result; }; export const getLayoutUsage = async ({ environment, layoutSlug, }: { environment: IEnvironment; layoutSlug: string; }): Promise => { const { data } = await getV2<{ data: GetLayoutUsageResponse }>(`/layouts/${layoutSlug}/usage`, { environment }); return data; }; export const previewLayout = async ({ environment, layoutSlug, previewData, signal, }: { environment: IEnvironment; layoutSlug: string; previewData: { controlValues: Record; previewPayload: Record }; signal?: AbortSignal; }) => { const { data } = await postV2<{ data: GeneratePreviewResponseDto }>(`/layouts/${layoutSlug}/preview`, { environment, body: previewData, signal, }); return data; }; ================================================ FILE: apps/dashboard/src/api/logs.ts ================================================ import { IEnvironment } from '@novu/shared'; import { RequestLog, RequestTraces } from '../types/logs'; import { get } from './api.client'; export interface GetRequestLogsParams { environment: IEnvironment; page?: number; limit?: number; statusCodes?: string; url?: string; urlPattern?: string; transactionId?: string; search?: string; createdGte?: number; } export interface GetRequestLogsResponse { data: RequestLog[]; total: number; pageSize: number; page: number; } export async function getRequestLogs(params: GetRequestLogsParams): Promise { const { environment, ...queryParams } = params; const searchParams = new URLSearchParams(); Object.entries(queryParams).forEach(([key, value]) => { if (value !== undefined && value !== null && value !== '') { searchParams.append(key, String(value)); } }); const queryString = searchParams.toString(); const endpoint = `/activity/requests${queryString ? `?${queryString}` : ''}`; return get(endpoint, { environment }); } export interface GetRequestTracesParams { environment: IEnvironment; requestId: string; } export async function getRequestTraces(params: GetRequestTracesParams): Promise { const { environment, requestId } = params; const endpoint = `/activity/requests/${requestId}`; const response = await get<{ data: RequestTraces }>(endpoint, { environment }); return response?.data; } ================================================ FILE: apps/dashboard/src/api/organization.ts ================================================ import type { IEnvironment, UpdateExternalOrganizationDto } from '@novu/shared'; import { get, patch, post } from './api.client'; export type GetOrganizationSettingsDto = { removeNovuBranding: boolean; defaultLocale: string; targetLocales: string[]; }; export type UpdateOrganizationSettingsDto = { removeNovuBranding?: boolean; defaultLocale?: string; targetLocales?: string[]; }; export function updateClerkOrgMetadata({ data, environment, }: { data: UpdateExternalOrganizationDto; environment: IEnvironment; }) { return post('/clerk/organization', { environment, body: data }); } export async function getOrganizationSettings({ environment, }: { environment: IEnvironment; }): Promise<{ data: GetOrganizationSettingsDto }> { return get('/organizations/settings', { environment }); } export async function updateOrganizationSettings({ data, environment, }: { data: UpdateOrganizationSettingsDto; environment: IEnvironment; }): Promise<{ data: GetOrganizationSettingsDto }> { return patch('/organizations/settings', { environment, body: data }); } ================================================ FILE: apps/dashboard/src/api/partner-integrations.ts ================================================ import type { IEnvironment } from '@novu/shared'; import { get, post, put } from './api.client'; const partnerIntegrationBaseUrl = '/partner-integrations'; export type GetVercelConfigurationDetails = { organizationId: string; projectIds: string[]; }; export type GetVercelProjects = { projects: { id: string; name: string; }[]; pagination: { next: number; }; }; export async function createVercelIntegration({ code, configurationId, environment, }: { code: string; configurationId: string; environment?: IEnvironment; }): Promise<{ data: { success: boolean } }> { return post(`${partnerIntegrationBaseUrl}/vercel`, { body: { vercelIntegrationCode: code, configurationId }, environment, }); } export async function fetchVercelIntegrationProjects({ configurationId, environment, }: { configurationId: string; environment?: IEnvironment; }): Promise<{ data: GetVercelProjects }> { return get(`${partnerIntegrationBaseUrl}/vercel/${configurationId}/projects`, { environment }); } export async function fetchVercelIntegration({ configurationId, environment, }: { configurationId?: string | null; environment?: IEnvironment; }): Promise<{ data: GetVercelConfigurationDetails[] }> { return get(`${partnerIntegrationBaseUrl}/vercel/${configurationId}`, { environment }); } export async function updateVercelIntegration({ data, configurationId, environment, }: { data: Record; configurationId: string; environment?: IEnvironment; }) { return put(`${partnerIntegrationBaseUrl}/vercel`, { body: { data, configurationId }, environment, }); } ================================================ FILE: apps/dashboard/src/api/step-resolvers.ts ================================================ import { IEnvironment, StepTypeEnum } from '@novu/shared'; import { delV2, getV2 } from './api.client'; export const getStepResolversCount = async ({ environment, }: { environment: IEnvironment; }): Promise<{ count: number }> => { const { data } = await getV2<{ data: { count: number } }>('/step-resolvers/count', { environment }); return data; }; export const disconnectStepResolver = async ({ environment, stepInternalId, stepType, }: { environment: IEnvironment; stepInternalId: string; stepType: StepTypeEnum; }): Promise => { await delV2(`/step-resolvers/${stepInternalId}/disconnect`, { environment, body: { stepType }, }); }; ================================================ FILE: apps/dashboard/src/api/steps.ts ================================================ import type { GeneratePreviewRequestDto, GeneratePreviewResponseDto, IEnvironment, StepResponseDto, } from '@novu/shared'; import { getV2, postV2 } from './api.client'; export type TestHttpEndpointResponse = { statusCode: number; body: unknown; headers: Record; durationMs: number; resolvedRequest: { url: string; method: string; headers?: Record; body?: Record; }; }; export const getStep = async ({ environment, stepSlug, workflowSlug, }: { environment: IEnvironment; stepSlug: string; workflowSlug: string; }): Promise => { const { data } = await getV2<{ data: StepResponseDto }>(`/workflows/${workflowSlug}/steps/${stepSlug}`, { environment, }); return data; }; export const previewStep = async ({ environment, previewData, stepSlug, workflowSlug, signal, }: { environment: IEnvironment; previewData?: GeneratePreviewRequestDto; stepSlug: string; workflowSlug: string; signal?: AbortSignal; }): Promise => { const { data } = await postV2<{ data: GeneratePreviewResponseDto }>( `/workflows/${workflowSlug}/step/${stepSlug}/preview`, { environment, body: previewData, signal } ); return data; }; export const testHttpEndpoint = async ({ environment, controlValues, previewPayload, signal, }: { environment: IEnvironment; controlValues?: Record; previewPayload?: GeneratePreviewRequestDto['previewPayload']; signal?: AbortSignal; }): Promise => { const { data } = await postV2<{ data: TestHttpEndpointResponse }>(`/workflows/steps/test-http-request`, { environment, body: { controlValues, previewPayload }, signal, }); return data; }; ================================================ FILE: apps/dashboard/src/api/subscribers.ts ================================================ import { CreateSubscriberRequestDto, GetSubscriberPreferencesDto, PatchSubscriberPreferencesDto, PatchSubscriberRequestDto, RemoveSubscriberResponseDto, SubscriberResponseDto, } from '@novu/api/models/components'; import type { DirectionEnum, IEnvironment, ISubscriberResponseDto } from '@novu/shared'; import { delV2, getV2, patchV2, postV2 } from './api.client'; import { ListTopicSubscriptionsResponse } from './topics'; export type ListSubscribersResponse = { data: Array; next: string | null; previous: string | null; totalCount: number; totalCountCapped: boolean; }; export const getSubscribers = async ({ environment, after, before, limit, email, orderDirection, orderBy, phone, subscriberId, name, includeCursor, }: { environment: IEnvironment; after?: string; before?: string; limit: number; email?: string; phone?: string; subscriberId?: string; name?: string; orderDirection?: DirectionEnum; orderBy?: string; includeCursor?: boolean; }): Promise => { const params = new URLSearchParams({ limit: limit.toString(), ...(after && { after }), ...(before && { before }), ...(orderDirection && { orderDirection }), ...(email && { email }), ...(phone && { phone }), ...(subscriberId && { subscriberId }), ...(name && { name }), ...(orderBy && { orderBy }), ...(orderDirection && { orderDirection }), ...(includeCursor && { includeCursor: includeCursor.toString() }), }); const response = await getV2(`/subscribers?${params}`, { environment, }); return response; }; export const deleteSubscriber = async ({ environment, subscriberId, }: { environment: IEnvironment; subscriberId: string; }) => { const response = await delV2(`/subscribers/${encodeURIComponent(subscriberId)}`, { environment, }); return response; }; export const getSubscriber = async ({ environment, subscriberId, }: { environment: IEnvironment; subscriberId: string; }) => { const { data } = await getV2<{ data: SubscriberResponseDto }>(`/subscribers/${encodeURIComponent(subscriberId)}`, { environment, }); return data; }; export const patchSubscriber = async ({ environment, subscriberId, subscriber, }: { environment: IEnvironment; subscriberId: string; subscriber: Partial; }) => { const { data } = await patchV2<{ data: SubscriberResponseDto }>(`/subscribers/${encodeURIComponent(subscriberId)}`, { environment, body: subscriber, }); return data; }; export const getSubscriberPreferences = async ({ environment, subscriberId, contextKeys, }: { environment: IEnvironment; subscriberId: string; contextKeys?: string[]; }) => { const params = new URLSearchParams(); if (contextKeys !== undefined) { if (contextKeys.length === 0) { params.append('contextKeys', ''); } else { for (const key of contextKeys) { params.append('contextKeys', key); } } } const url = `/subscribers/${encodeURIComponent(subscriberId)}/preferences${params.toString() ? `?${params}` : ''}`; const { data } = await getV2<{ data: GetSubscriberPreferencesDto }>(url, { environment, }); return data; }; export const patchSubscriberPreferences = async ({ environment, subscriberId, preferences, }: { environment: IEnvironment; subscriberId: string; preferences: Partial; }) => { const { data } = await patchV2<{ data: GetSubscriberPreferencesDto }>( `/subscribers/${encodeURIComponent(subscriberId)}/preferences`, { environment, body: preferences, } ); return data; }; export const createSubscriber = async ({ environment, subscriber, }: { environment: IEnvironment; subscriber: Partial; }) => { const queryParams = new URLSearchParams(); queryParams.append('failIfExists', 'true'); const { data } = await postV2<{ data: SubscriberResponseDto }>(`/subscribers?${queryParams}`, { environment, body: subscriber, }); return data; }; export const getSubscriberSubscriptions = async ({ environment, subscriberId, limit = 10, after, before, orderDirection, orderBy, key, includeCursor, contextKeys, }: { environment: IEnvironment; subscriberId: string; limit?: number; after?: string; before?: string; orderDirection?: DirectionEnum; orderBy?: string; key?: string; includeCursor?: boolean; contextKeys?: string[]; }) => { const params = new URLSearchParams({ limit: limit.toString(), ...(after && { after }), ...(before && { before }), ...(orderDirection && { orderDirection }), ...(orderBy && { orderBy }), ...(key && { key }), ...(includeCursor && { includeCursor: includeCursor.toString() }), }); if (contextKeys?.length) { for (const contextKey of contextKeys) { params.append('contextKeys', contextKey); } } const response = await getV2( `/subscribers/${encodeURIComponent(subscriberId)}/subscriptions?${params}`, { environment, } ); return response; }; ================================================ FILE: apps/dashboard/src/api/telemetry.ts ================================================ import { CompanySizeEnum, JobTitleEnum, OrganizationTypeEnum } from '@novu/shared'; import * as Sentry from '@sentry/react'; import { post } from './api.client'; export const measure = async (event: string, data?: Record): Promise => { await post('/telemetry/measure', { body: { event, data, }, }); }; interface IdentifyUserProps { pageUri: string; pageName: string; jobTitle: JobTitleEnum; organizationType: OrganizationTypeEnum; companySize?: CompanySizeEnum | string; anonymousId?: string | null; } export const identifyUser = async (userData: IdentifyUserProps) => { try { await post('/telemetry/identify', { body: userData }); } catch (error) { console.error('Error identifying user:', error); Sentry.captureException(error); } }; ================================================ FILE: apps/dashboard/src/api/topics.ts ================================================ import { RulesLogic } from '@novu/js'; import type { CustomDataType, DirectionEnum, IEnvironment, SeverityLevelEnum } from '@novu/shared'; import { Topic } from '@/components/topics/types'; import { convertContextKeysToPayload } from '@/utils/context-variable-utils'; import { delV2, getV2, patchV2, postV2 } from './api.client'; export type ListTopicsResponse = { data: Array; next: string | null; previous: string | null; totalCount: number; totalCountCapped: boolean; }; export type DeleteTopicSubscriptionsResponseDto = { acknowledged: boolean; }; export const getTopics = async ({ environment, after, before, limit, key, name, orderDirection, orderBy, includeCursor, signal, }: { environment: IEnvironment; after?: string; before?: string; limit?: number; key?: string; name?: string; orderDirection?: DirectionEnum; orderBy?: string; includeCursor?: boolean; signal?: AbortSignal; }): Promise => { const params = new URLSearchParams({ ...(limit && { limit: limit.toString() }), ...(after && { after }), ...(before && { before }), ...(orderDirection && { orderDirection }), ...(key && { key }), ...(name && { name }), ...(orderBy && { orderBy }), ...(orderDirection && { orderDirection }), ...(includeCursor && { includeCursor: includeCursor.toString() }), }); const response = await getV2(`/topics?${params}`, { environment, signal, }); return response; }; export const deleteTopic = async ({ environment, topicKey }: { environment: IEnvironment; topicKey: string }) => { const response = await delV2<{ acknowledged: boolean }>(`/topics/${encodeURIComponent(topicKey)}`, { environment, }); return response; }; export const getTopic = async ({ environment, topicKey }: { environment: IEnvironment; topicKey: string }) => { const { data } = await getV2<{ data: Topic }>(`/topics/${encodeURIComponent(topicKey)}`, { environment, }); return data; }; export const createTopic = async ({ environment, topic }: { environment: IEnvironment; topic: Partial }) => { const queryParams = new URLSearchParams(); queryParams.append('failIfExists', 'true'); const { data } = await postV2<{ data: Topic }>(`/topics?${queryParams}`, { environment, body: topic, }); return data; }; export const updateTopic = async ({ environment, topicKey, topic, }: { environment: IEnvironment; topicKey: string; topic: Partial; }) => { const { data } = await patchV2<{ data: Topic }>(`/topics/${topicKey}`, { environment, body: topic, }); return data; }; export const addSubscribersToTopic = async ({ environment, topicKey, subscribers, contextKeys, }: { environment: IEnvironment; topicKey: string; subscribers: string[]; contextKeys?: string[]; }) => { const context = convertContextKeysToPayload(contextKeys); const { data } = await postV2<{ data: { succeeded: string[]; failed?: { notFound: string[]; }; }; }>(`/topics/${encodeURIComponent(topicKey)}/subscriptions`, { environment, body: { subscriberIds: subscribers, ...(context && { context }), }, }); return data; }; export const removeSubscribersFromTopic = async ({ environment, topicKey, subscribers, }: { environment: IEnvironment; topicKey: string; subscribers: string[]; }) => { await delV2(`/topics/${encodeURIComponent(topicKey)}/subscriptions`, { environment, body: { subscriberIds: subscribers }, }); return { acknowledged: true }; }; export const deleteTopicSubscription = async ({ environment, topicKey, identifier, subscriberId, }: { environment: IEnvironment; topicKey: string; identifier: string; subscriberId: string; }) => { await delV2(`/topics/${encodeURIComponent(topicKey)}/subscriptions`, { environment, body: { subscriptions: [{ identifier, subscriberId }] }, }); return { acknowledged: true }; }; export type TopicSubscription = { _id: string; identifier: string; createdAt: string; topic: { _id: string; key: string; name: string; createdAt: string; updatedAt: string; }; subscriber: { _id: string; subscriberId: string; firstName?: string; lastName?: string; email?: string; avatar?: string; }; }; export type ListTopicSubscriptionsResponse = { data: TopicSubscription[]; next: string | null; previous: string | null; totalCount: number; totalCountCapped: boolean; }; export type WorkflowDto = { id: string; identifier: string; name: string; critical: boolean; tags?: string[]; data?: CustomDataType; severity: SeverityLevelEnum; }; export type TopicSubscriptionPreference = { workflow: WorkflowDto; subscriptionId: string; enabled: boolean; condition?: RulesLogic; }; export type TopicSubscriptionDetailsResponse = { id: string; identifier?: string; name?: string; preferences: TopicSubscriptionPreference[]; }; export const getTopicSubscriptions = async ({ environment, topicKey, limit = 100, after, before, subscriberId, contextKeys, }: { environment: IEnvironment; topicKey: string; limit?: number; after?: string; before?: string; subscriberId?: string; contextKeys?: string[]; }): Promise => { const params = new URLSearchParams(); if (limit) params.append('limit', limit.toString()); if (after) params.append('after', after); if (before) params.append('before', before); if (subscriberId) params.append('subscriberId', subscriberId); if (contextKeys?.length) { for (const contextKey of contextKeys) { params.append('contextKeys', contextKey); } } const query = params.toString() ? `?${params.toString()}` : ''; const response = await getV2( `/topics/${encodeURIComponent(topicKey)}/subscriptions${query}`, { environment, } ); return response; }; export const getTopicSubscription = async ({ environment, topicKey, subscriptionId, }: { environment: IEnvironment; topicKey: string; subscriptionId: string; }): Promise => { const response = await getV2<{ data: TopicSubscriptionDetailsResponse }>( `/topics/${encodeURIComponent(topicKey)}/subscriptions/${subscriptionId}`, { environment, } ); return response.data; }; ================================================ FILE: apps/dashboard/src/api/translations.ts ================================================ import { CreateTranslationRequestDto, GetMasterJsonResponseDto, ImportMasterJsonResponseDto, TranslationGroupDto, TranslationResponseDto, UploadTranslationsResponseDto, } from '@novu/api/models/components'; import { IEnvironment } from '@novu/shared'; import { delV2, getV2, postV2 } from './api.client'; // Shared resource type from SDK type ResourceType = TranslationGroupDto['resourceType']; // Request types export type TranslationsFilter = { query?: string; limit?: number; offset?: number; }; export type SaveTranslationRequest = CreateTranslationRequestDto; export type DeleteTranslationRequest = { resourceId: string; resourceType: ResourceType; locale: string; }; export type DeleteTranslationGroupRequest = { resourceId: string; resourceType: ResourceType; }; export type UploadTranslationsRequest = { resourceId: string; resourceType: ResourceType; files: File[]; }; export type UploadMasterJsonRequest = { file: File; }; // Response types export type GetTranslationsListResponse = { data: TranslationGroupDto[]; total: number; limit: number; offset: number; }; // API functions export const getTranslationsList = async ({ environment, query, limit = 50, offset = 0, }: TranslationsFilter & { environment: IEnvironment }): Promise => { const searchParams = new URLSearchParams(); if (query) { searchParams.append('query', query); } searchParams.append('limit', limit.toString()); searchParams.append('offset', offset.toString()); const queryString = searchParams.toString(); const endpoint = `/translations/list${queryString ? `?${queryString}` : ''}`; return getV2(endpoint, { environment }); }; export const getTranslationGroup = async ({ environment, resourceId, resourceType, }: { environment: IEnvironment; resourceId: string; resourceType: ResourceType; }): Promise => { const endpoint = `/translations/group/${resourceType}/${resourceId}`; const response = await getV2<{ data: TranslationGroupDto }>(endpoint, { environment }); return response.data; }; export const getTranslation = async ({ environment, resourceId, resourceType, locale, }: { environment: IEnvironment; resourceId: string; resourceType: ResourceType; locale: string; }): Promise => { const endpoint = `/translations/${resourceType}/${resourceId}/${locale}`; const response = await getV2<{ data: TranslationResponseDto }>(endpoint, { environment }); return response.data; }; export const saveTranslation = async ({ environment, resourceId, resourceType, locale, content, }: SaveTranslationRequest & { environment: IEnvironment }): Promise => { const endpoint = '/translations'; const response = await postV2<{ data: TranslationResponseDto }>(endpoint, { body: { resourceId, resourceType, locale, content }, environment, }); return response.data; }; export const deleteTranslation = async ({ environment, resourceId, resourceType, locale, }: DeleteTranslationRequest & { environment: IEnvironment }): Promise => { const endpoint = `/translations/${resourceType}/${resourceId}/${locale}`; await delV2(endpoint, { environment }); }; export const deleteTranslationGroup = async ({ environment, resourceId, resourceType, }: DeleteTranslationGroupRequest & { environment: IEnvironment }): Promise => { const endpoint = `/translations/${resourceType}/${resourceId}`; await delV2(endpoint, { environment }); }; export const uploadTranslations = async ({ environment, resourceId, resourceType, files, }: UploadTranslationsRequest & { environment: IEnvironment }): Promise => { const formData = new FormData(); formData.append('resourceId', resourceId); formData.append('resourceType', resourceType); for (const file of files) { formData.append('files', file); } const endpoint = '/translations/upload'; const response = await postV2<{ data: UploadTranslationsResponseDto }>(endpoint, { body: formData, environment, }); return response.data; }; export const getMasterJson = async ({ environment, locale, }: { environment: IEnvironment; locale: string; }): Promise => { const searchParams = new URLSearchParams(); searchParams.append('locale', locale); const endpoint = `/translations/master-json?${searchParams.toString()}`; const response = await getV2<{ data: GetMasterJsonResponseDto }>(endpoint, { environment }); return response.data; }; export const uploadMasterJson = async ({ environment, file, }: UploadMasterJsonRequest & { environment: IEnvironment }): Promise => { const formData = new FormData(); formData.append('file', file); const endpoint = '/translations/master-json/upload'; const response = await postV2<{ data: ImportMasterJsonResponseDto }>(endpoint, { body: formData, environment, }); return response.data; }; ================================================ FILE: apps/dashboard/src/api/webhooks.ts ================================================ import { IEnvironment } from '@novu/shared'; import { getV2, postV2 } from './api.client'; // Matches the response DTO defined in the API interface GetWebhookPortalTokenResponse { url: string; token: string; appId: string; } export const getWebhookPortalToken = async (environment: IEnvironment): Promise => { const { data } = await getV2<{ data: GetWebhookPortalTokenResponse }>('/outbound-webhooks/portal/token', { environment, }); return data; }; export const createWebhookPortalToken = async (environment: IEnvironment): Promise => { const { data } = await postV2<{ data: GetWebhookPortalTokenResponse }>('/outbound-webhooks/portal/token', { environment, body: {}, }); return data; }; ================================================ FILE: apps/dashboard/src/api/workflows.ts ================================================ import type { CreateWorkflowDto, DuplicateWorkflowDto, IEnvironment, ListWorkflowResponse, PatchWorkflowDto, SyncWorkflowDto, UpdateWorkflowDto, WorkflowResponseDto, WorkflowTestDataResponseDto, } from '@novu/shared'; import { delV2, getV2, patchV2, post, postV2, putV2 } from './api.client'; export const getWorkflow = async ({ environment, workflowSlug, targetEnvironmentId, }: { environment: IEnvironment; workflowSlug?: string; targetEnvironmentId?: string; }): Promise => { const { data } = await getV2<{ data: WorkflowResponseDto }>( `/workflows/${workflowSlug}?${targetEnvironmentId ? `environmentId=${targetEnvironmentId}` : ''}`, { environment, } ); return data; }; export const getWorkflows = async ({ environment, limit, query, offset, orderBy, orderDirection, tags, status, }: { environment: IEnvironment; limit: number; offset: number; query: string; orderBy?: string; orderDirection?: string; tags?: string[]; status?: string[]; }): Promise => { const params = new URLSearchParams({ limit: limit.toString(), offset: offset.toString(), query, }); if (orderBy) { params.append('orderBy', orderBy); } if (orderDirection) { params.append('orderDirection', orderDirection.toUpperCase()); } if (tags && tags.length > 0) { for (const tag of tags) { params.append('tags[]', tag); } } if (status && status.length > 0) { for (const s of status) { params.append('status[]', s); } } const { data } = await getV2<{ data: ListWorkflowResponse }>(`/workflows?${params.toString()}`, { environment }); return data; }; export const getWorkflowTestData = async ({ environment, workflowSlug, }: { environment: IEnvironment; workflowSlug?: string; }): Promise => { const { data } = await getV2<{ data: WorkflowTestDataResponseDto }>(`/workflows/${workflowSlug}/test-data`, { environment, }); return data; }; export async function triggerWorkflow({ environment, name, payload, to, context, overrides, }: { environment: IEnvironment; name: string; payload: unknown; to: unknown; context?: unknown; overrides?: Record; }) { return post<{ data: { transactionId?: string } }>(`/events/trigger`, { environment, body: { name, to, payload: { ...(payload ?? {}), __source: (payload as any)?.__source ?? 'dashboard' }, context: context ?? undefined, ...(overrides && Object.keys(overrides).length > 0 ? { overrides } : {}), }, }); } export async function createWorkflow({ environment, workflow, }: { environment: IEnvironment; workflow: CreateWorkflowDto; }) { return postV2<{ data: WorkflowResponseDto }>(`/workflows`, { environment, body: workflow }); } export async function syncWorkflow({ environment, workflowSlug, payload, }: { environment: IEnvironment; workflowSlug: string; payload: SyncWorkflowDto; }) { return putV2<{ data: WorkflowResponseDto }>(`/workflows/${workflowSlug}/sync`, { environment, body: payload }); } export const updateWorkflow = async ({ environment, workflow, workflowSlug, }: { environment: IEnvironment; workflow: UpdateWorkflowDto; workflowSlug: string; }): Promise => { const { data } = await putV2<{ data: WorkflowResponseDto }>(`/workflows/${workflowSlug}`, { environment, body: workflow, }); return data; }; export const deleteWorkflow = async ({ environment, workflowSlug, }: { environment: IEnvironment; workflowSlug: string; }): Promise => { return delV2(`/workflows/${workflowSlug}`, { environment }); }; export const patchWorkflow = async ({ environment, workflow, workflowSlug, }: { environment: IEnvironment; workflow: PatchWorkflowDto; workflowSlug: string; }): Promise => { const res = await patchV2<{ data: WorkflowResponseDto }>(`/workflows/${workflowSlug}`, { environment, body: workflow, }); return res.data; }; export const duplicateWorkflow = async ({ environment, workflow, workflowSlug, }: { environment: IEnvironment; workflow: DuplicateWorkflowDto; workflowSlug: string; }) => { return postV2<{ data: WorkflowResponseDto }>(`/workflows/${workflowSlug}/duplicate`, { environment, body: workflow, }); }; ================================================ FILE: apps/dashboard/src/components/activity/activity-detail-card.tsx ================================================ import { ChevronDown } from 'lucide-react'; import { ReactNode, useState } from 'react'; import { RiInformation2Line } from 'react-icons/ri'; import { cn } from '@/utils/ui'; interface ActivityDetailCardProps { title: ReactNode; timestamp?: string; expandable?: boolean; open?: boolean; children?: ReactNode; footer?: string | null; } export function ActivityDetailCard({ title, timestamp, expandable = false, open, children, footer, }: ActivityDetailCardProps) { const [internalOpen, setInternalOpen] = useState(false); const isExpanded = open ?? internalOpen; return (
setInternalOpen(!internalOpen) : undefined} > {title}
{timestamp && ( {timestamp} )} {expandable && ( )}
{isExpanded && children && ( <>
{children}
{footer && (
{footer}
)} )}
); } ================================================ FILE: apps/dashboard/src/components/activity/activity-empty-state.tsx ================================================ import { PermissionsEnum } from '@novu/shared'; import { AnimatePresence, motion } from 'motion/react'; import { useMemo } from 'react'; import { RiCloseCircleLine, RiPlayCircleLine } from 'react-icons/ri'; import { useNavigate } from 'react-router-dom'; import { ActivityFilters } from '@/api/activity'; import { defaultActivityFilters } from '@/components/activity/constants'; import { Button } from '@/components/primitives/button'; import { useEnvironment } from '@/context/environment/hooks'; import { Protect } from '@/utils/protect'; import { buildRoute, ROUTES } from '@/utils/routes'; import { cn } from '@/utils/ui'; import { ExternalLink } from '../shared/external-link'; interface ActivityEmptyStateProps { className?: string; filters?: ActivityFilters; emptySearchResults?: boolean; emptySearchTitle?: string; emptySearchDescription?: string; emptyFiltersDescription?: string; onClearFilters?: () => void; onTriggerWorkflow?: () => void; } export function ActivityEmptyState({ className, filters = defaultActivityFilters, emptySearchResults, onClearFilters, onTriggerWorkflow, emptySearchTitle = 'No activity matches that filter', emptySearchDescription = 'Try adjusting your filters to see more results.', emptyFiltersDescription = 'Your activity feed is empty. Once you trigger your first workflow, you can monitor notifications and view delivery details.', }: ActivityEmptyStateProps) { const navigate = useNavigate(); const { currentEnvironment } = useEnvironment(); const handleNavigateToWorkflows = () => { navigate(buildRoute(ROUTES.WORKFLOWS, { environmentSlug: currentEnvironment?.slug ?? '' })); }; const handleTriggerWorkflow = onTriggerWorkflow || handleNavigateToWorkflows; const emptyFiltersTitle = useMemo(() => { return `No activity in the past ${filters?.dateRange}`; }, [filters]); return (

{emptySearchResults ? emptySearchTitle : emptyFiltersTitle}

{emptySearchResults ? emptySearchDescription : emptyFiltersDescription}

{emptySearchResults && onClearFilters && ( )} {!emptySearchResults && ( View Docs )}
); } function ActivityIllustration() { return ( ); } ================================================ FILE: apps/dashboard/src/components/activity/activity-error.tsx ================================================ import { motion } from 'motion/react'; import { fadeIn } from '@/utils/animation'; export const ActivityError = () => { return (
Failed to load activity details
); }; ================================================ FILE: apps/dashboard/src/components/activity/activity-feed-content.tsx ================================================ /** biome-ignore-all lint/correctness/useUniqueElementIds: expected */ import { useQueryClient } from '@tanstack/react-query'; import { AnimatePresence, motion } from 'motion/react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { ActivityError } from '@/components/activity/activity-error'; import { ActivityFilters } from '@/components/activity/activity-filters'; import { ActivityHeader } from '@/components/activity/activity-header'; import { ActivityLogs } from '@/components/activity/activity-logs'; import { ActivityPanel } from '@/components/activity/activity-panel'; import { ActivitySkeleton } from '@/components/activity/activity-skeleton'; import { ActivityTable } from '@/components/activity/activity-table'; import { ActivityOverview } from '@/components/activity/components/activity-overview'; import { defaultActivityFilters } from '@/components/activity/constants'; import { ResizablePanel, ResizablePanelGroup } from '@/components/primitives/resizable'; import { UpdatedAgo } from '@/components/updated-ago'; import { useEnvironment } from '@/context/environment/hooks'; import { useActivityUrlState } from '@/hooks/use-activity-url-state'; import { usePullActivity } from '@/hooks/use-pull-activity'; import { ActivityFiltersData } from '@/types/activity'; import { QueryKeys } from '@/utils/query-keys'; import { cn } from '../../utils/ui'; import { EmptyTopicsIllustration } from '../topics/empty-topics-illustration'; type ActivityFeedContentProps = { initialFilters?: Partial; hideFilters?: Array<'dateRange' | 'workflows' | 'channels' | 'transactionId' | 'subscriberId' | 'topicKey'>; className?: string; contentHeight?: string; onTriggerWorkflow?: () => void; }; export function ActivityFeedContent({ initialFilters = {}, hideFilters = [], className, contentHeight = 'h-[calc(100vh-140px)]', onTriggerWorkflow, }: ActivityFeedContentProps) { const { activityItemId, filters, filterValues, handleActivitySelect, handleFiltersChange } = useActivityUrlState(); const { activity, isPending, error } = usePullActivity(activityItemId); const [showDetailPanel, setShowDetailPanel] = useState(false); const onListStateChange = useCallback((hasActivities: boolean) => setShowDetailPanel(hasActivities), []); const queryClient = useQueryClient(); const { currentEnvironment } = useEnvironment(); // Track last updated time for the activities list const [lastUpdated, setLastUpdated] = useState(new Date()); useEffect(() => { setLastUpdated(new Date()); }, [filters]); // Merge initial filters with current filters const mergedFilterValues = useMemo( () => ({ ...defaultActivityFilters, ...initialFilters, ...filterValues, }), [initialFilters, filterValues] ); const mergedFilters = useMemo( () => ({ ...filters, // Apply initial filters that should always be present ...(initialFilters.workflows?.length && { workflows: initialFilters.workflows }), ...(initialFilters.subscriberId && { subscriberId: initialFilters.subscriberId }), ...(initialFilters.topicKey && { topicKey: initialFilters.topicKey }), }), [filters, initialFilters] ); const hasActiveFilters = Object.entries(mergedFilters).some(([key, value]) => { // Ignore dateRange as it's always present if (key === 'dateRange') return false; // Ignore initial filters that are always applied if (key === 'workflows' && initialFilters.workflows?.length) { return Array.isArray(value) && value.length > (initialFilters.workflows?.length || 0); } if (key === 'subscriberId' && initialFilters.subscriberId) { return value !== initialFilters.subscriberId; } if (key === 'topicKey' && initialFilters.topicKey) { return value !== initialFilters.topicKey; } // For arrays, check if they have any items if (Array.isArray(value)) return value.length > 0; // For other values, check if they exist return !!value; }); const handleClearFilters = () => { handleFiltersChange({ ...defaultActivityFilters, ...initialFilters, }); }; const hasChanges = useMemo(() => { const baseFilters = { ...defaultActivityFilters, ...initialFilters }; return ( mergedFilterValues.dateRange !== baseFilters.dateRange || mergedFilterValues.channels.length > 0 || mergedFilterValues.workflows.length > (baseFilters.workflows?.length || 0) || mergedFilterValues.transactionId !== (baseFilters.transactionId || '') || mergedFilterValues.subscriberId !== (baseFilters.subscriberId || '') || mergedFilterValues.severity.length > 0 ); }, [mergedFilterValues, initialFilters]); const handleTransactionIdChange = useCallback( (newTransactionId: string, activityId?: string) => { if (activityId) { handleActivitySelect(activityId); } else { handleFiltersChange({ ...mergedFilterValues, ...(newTransactionId && { transactionId: newTransactionId }), }); } }, [mergedFilterValues, handleFiltersChange, handleActivitySelect] ); const handleRefresh = async () => { await queryClient.invalidateQueries({ queryKey: [QueryKeys.fetchActivities, currentEnvironment?._id] }); setLastUpdated(new Date()); }; return (
{showDetailPanel && ( {activityItemId ? ( {isPending ? ( ) : error || !activity ? ( ) : ( <> )} ) : (

Nothing to show,
Select a log on the left to view detailed info here

)}
)}
); } ================================================ FILE: apps/dashboard/src/components/activity/activity-filters.tsx ================================================ import { useOrganization } from '@clerk/clerk-react'; import { ChannelTypeEnum, FeatureFlagsKeysEnum, SeverityLevelEnum } from '@novu/shared'; import { CalendarIcon } from 'lucide-react'; import { useMemo } from 'react'; import { useForm } from 'react-hook-form'; import { Link } from 'react-router-dom'; import { Badge } from '@/components/primitives/badge'; import { Tooltip, TooltipContent, TooltipPortal, TooltipTrigger } from '@/components/primitives/tooltip'; import { useDebouncedForm } from '@/hooks/use-debounced-form'; import { useFeatureFlag } from '@/hooks/use-feature-flag'; import { useFetchSubscription } from '@/hooks/use-fetch-subscription'; import { ActivityFiltersData } from '@/types/activity'; import { buildActivityDateFilters } from '@/utils/activityFilters'; import { ROUTES } from '@/utils/routes'; import { capitalize } from '@/utils/string'; import { cn } from '@/utils/ui'; import { IS_SELF_HOSTED } from '../../config'; import { useFetchWorkflows } from '../../hooks/use-fetch-workflows'; import { ContextFilter } from '../contexts/context-filter'; import { Button } from '../primitives/button'; import { FacetedFormFilter } from '../primitives/form/faceted-filter/facated-form-filter'; import { Form, FormField, FormItem, FormRoot } from '../primitives/form/form'; import { CHANNEL_OPTIONS } from './constants'; type Fields = | 'dateRange' | 'workflows' | 'channels' | 'transactionId' | 'subscriberId' | 'topicKey' | 'subscriptionId' | 'severity' | 'contextKeys'; export type ActivityFilters = { filters: ActivityFiltersData; showReset?: boolean; onFiltersChange: (filters: ActivityFiltersData) => void; onReset?: () => void; hide?: Fields[]; className?: string; defaultContextOnClear?: boolean; }; const UpgradeCtaIcon: React.ComponentType<{ className?: string }> = () => { return ( Upgrade Upgrade your plan to unlock extended retention periods ); }; export function ActivityFilters({ onFiltersChange, filters, onReset, showReset = false, hide = [], className, defaultContextOnClear = false, }: ActivityFilters) { const { data: workflowTemplates } = useFetchWorkflows({ limit: 100 }); const { organization } = useOrganization(); const { subscription } = useFetchSubscription(); const isSubscriptionPreferencesEnabled = useFeatureFlag( FeatureFlagsKeysEnum.IS_SUBSCRIPTION_PREFERENCES_ENABLED, false ); const form = useForm({ values: filters, defaultValues: filters, }); const { watch, setValue } = form; useDebouncedForm(watch, onFiltersChange, 400); const maxActivityFeedRetentionOptions = useMemo(() => { const missingSubscription = !subscription && !IS_SELF_HOSTED; if (!organization || missingSubscription) { return []; } return buildActivityDateFilters({ organization, apiServiceLevel: subscription?.apiServiceLevel, }).map((option) => ({ ...option, icon: option.disabled ? UpgradeCtaIcon : undefined, })); }, [organization, subscription]); const handleReset = () => { if (onReset) { onReset(); } }; return (
{!hide.includes('dateRange') && ( ( setValue('dateRange', values[0])} icon={CalendarIcon} /> )} /> )} {!hide.includes('workflows') && ( ( ({ label: workflow.name, value: workflow._id, })) || [] } selected={field.value} onSelect={(values) => setValue('workflows', values)} /> )} /> )} {!hide.includes('channels') && ( ( setValue('channels', values as ChannelTypeEnum[])} /> )} /> )} {!hide.includes('transactionId') && ( ( setValue('transactionId', value)} placeholder="Search by full Transaction ID" /> )} /> )} {!hide.includes('subscriberId') && ( ( setValue('subscriberId', value)} placeholder="Search by full Subscriber ID" /> )} /> )} {!hide.includes('topicKey') && ( ( setValue('topicKey', value)} placeholder="Search by full Topic Key" /> )} /> )} {isSubscriptionPreferencesEnabled && !hide.includes('subscriptionId') && ( ( setValue('subscriptionId', value)} placeholder="Search by full Subscription ID" /> )} /> )} {!hide.includes('severity') && ( ( ({ label: capitalize(severity), value: severity, }))} selected={field.value} onSelect={(values) => setValue('severity', values as SeverityLevelEnum[])} /> )} /> )} {!hide.includes('contextKeys') && ( ( )} /> )} {showReset && ( )}
); } ================================================ FILE: apps/dashboard/src/components/activity/activity-header.tsx ================================================ import { type ContextPayload, IActivity } from '@novu/shared'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { motion } from 'motion/react'; import { RiCloseLine, RiRouteFill } from 'react-icons/ri'; import { getActivityList } from '@/api/activity'; import { Button } from '@/components/primitives/button'; import { showErrorToast, showSuccessToast } from '@/components/primitives/sonner-helpers'; import { useEnvironment } from '@/context/environment/hooks'; import { fadeIn } from '@/utils/animation'; import { QueryKeys } from '@/utils/query-keys'; import { cn } from '@/utils/ui'; import { triggerWorkflow } from '../../api/workflows'; import { RepeatPlay } from '../icons/repeat-play'; function contextKeysToContextPayload(contextKeys: string[] | undefined): ContextPayload | undefined { if (!contextKeys?.length) { return undefined; } const payload: ContextPayload = {}; for (const key of contextKeys) { const [type, ...idParts] = key.split(':'); const id = idParts.join(':'); if (!type || !id) { continue; } payload[type] = id; } return Object.keys(payload).length > 0 ? payload : undefined; } type ActivityHeaderProps = { className?: string; activity?: IActivity; onTransactionIdChange?: (transactionId: string, activityId: string) => void; onClose?: () => void; }; export const ActivityHeader = ({ className, activity, onTransactionIdChange, onClose }: ActivityHeaderProps) => { const queryClient = useQueryClient(); const { currentEnvironment } = useEnvironment(); const resentMetadata = activity?.payload ? { __resent_transaction_id: activity.transactionId, __resent_at: new Date().toISOString(), } : {}; const resentPayload = activity?.payload ? { ...activity.payload, ...resentMetadata } : resentMetadata; const resentOverrides = activity?.jobs?.[0]?.overrides as Record | undefined; const workflowExists = !!activity?.template; const { mutate: handleResend, isPending } = useMutation({ mutationFn: async () => { if (!activity) throw new Error('No activity data available'); if (!currentEnvironment) { throw new Error('No environment selected'); } const { data: { transactionId: newTransactionId }, } = await triggerWorkflow({ name: activity.template?.triggers[0].identifier ?? '', to: activity.subscriber?.subscriberId, payload: resentPayload, environment: currentEnvironment, context: contextKeysToContextPayload(activity.contextKeys), overrides: resentOverrides, }); if (!newTransactionId) { throw new Error( `Workflow ${activity.template?.name} cannot be triggered. Ensure that it is active and requires not further actions` ); } return newTransactionId; }, onSuccess: async (newTransactionId) => { showSuccessToast( `A new notification has been triggered with transaction ID: ${newTransactionId}`, 'Notification resent successfully' ); const checkAndUpdateTransaction = async () => { if (currentEnvironment) { const { data: activities } = await getActivityList({ environment: currentEnvironment, page: 0, limit: 1, filters: { transactionId: newTransactionId, }, }); if (activities.length > 0) { queryClient.invalidateQueries({ queryKey: [QueryKeys.fetchActivities, activity?._environmentId], }); onTransactionIdChange?.(newTransactionId, activities[0]._id); } } }; setTimeout(checkAndUpdateTransaction, 1000); }, onError: (error: Error) => { showErrorToast( error.message || 'There was an error triggering the resend workflow.', 'Failed to trigger resend workflow' ); }, }); return (
Workflow run
{activity && workflowExists && ( )} {onClose && ( )}
); }; ================================================ FILE: apps/dashboard/src/components/activity/activity-job-item.tsx ================================================ import { type IActivityJob, type IDelayRegularMetadata, type IDigestRegularMetadata, IDigestTimedMetadata, JobStatusEnum, StepTypeEnum, } from '@novu/shared'; import { format } from 'date-fns'; import { ChevronDown, Info, Route } from 'lucide-react'; import { useState } from 'react'; import { Badge } from '@/components/primitives/badge'; import { Button } from '@/components/primitives/button'; import { cn } from '@/utils/ui'; import { type ProviderColorToken, STEP_TYPE_TO_COLOR } from '../../utils/color'; import { formatJSONString } from '../../utils/string'; import { STEP_TYPE_TO_ICON } from '../icons/utils'; import { Card, CardContent, CardHeader } from '../primitives/card'; import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip'; import { TimeDisplayHoverCard } from '../time-display-hover-card'; import TruncatedText from '../truncated-text'; import { JOB_STATUS_CONFIG } from './constants'; import { ExecutionDetailItem } from './execution-detail-item'; interface ActivityJobItemProps { job: IActivityJob; isFirst: boolean; isLast: boolean; } export function ActivityJobItem({ job, isFirst, isLast }: ActivityJobItemProps) { const [isExpanded, setIsExpanded] = useState(false); return (
setIsExpanded(!isExpanded)} >
{getJobIcon(job)}
{getJobDisplayLabel(job)}
{!isExpanded && (
{getStatusMessage(job)} {format(new Date(job.updatedAt), 'MMM d yyyy, HH:mm:ss')}
)} {isExpanded && }
); } function formatJobType(type?: StepTypeEnum): string { return type?.replace(/_/g, ' ') || ''; } function getJobDisplayLabel(job: IActivityJob): string { return job?.step?.name || formatJobType(job.type); } function getStatusMessage(job: IActivityJob): string | React.ReactNode { if (job.status === JobStatusEnum.MERGED) { return 'Step merged with another execution'; } if (job.status === JobStatusEnum.PENDING) { return 'Job is pending'; } if (job.status === JobStatusEnum.SKIPPED) { return 'Step was skipped'; } if (job.status === JobStatusEnum.CANCELED && (!job.executionDetails || job.executionDetails.length === 0)) { return 'Step was canceled'; } if ( (job.status === JobStatusEnum.FAILED || job.status === JobStatusEnum.CANCELED) && job.executionDetails?.length > 0 ) { const lastExecutionDetail = job.executionDetails[job.executionDetails.length - 1]; return lastExecutionDetail ? (
{lastExecutionDetail.raw ? ( ) : ( {lastExecutionDetail.detail} )}
) : job.status === JobStatusEnum.FAILED ? ( 'Step execution failed' ) : ( 'Step was skipped' ); } switch (job.type?.toLowerCase()) { case StepTypeEnum.TRIGGER: if (job.status === JobStatusEnum.COMPLETED) { return 'Step completed'; } return ''; case StepTypeEnum.THROTTLE: if (job.status === JobStatusEnum.COMPLETED) { return 'Throttle step completed'; } return ''; case StepTypeEnum.DIGEST: if (job.status === JobStatusEnum.COMPLETED) { if ((job.digest as IDigestTimedMetadata).timed?.untilDate) { return `Digested events until scheduled time${job.scheduleExtensionsCount && job.scheduleExtensionsCount > 0 ? `, extended to subscriber schedule` : ''}`; } return `Digested ${job.digest?.events?.length ?? 0} events for ${(job.digest as IDigestRegularMetadata)?.amount ?? 0} ${ (job.digest as IDigestRegularMetadata)?.unit ?? '' }${job.scheduleExtensionsCount && job.scheduleExtensionsCount > 0 ? `, extended to subscriber schedule` : ''}`; } if (job.status === JobStatusEnum.DELAYED) { const untilDate = (job.digest as IDigestTimedMetadata).timed?.untilDate; if (untilDate) { const untilDateFormatted = format(new Date(untilDate), 'MMM d yyyy, HH:mm:ss'); return `Collecting events until ${untilDateFormatted}${job.scheduleExtensionsCount && job.scheduleExtensionsCount > 0 ? `, extended to subscriber schedule` : ''}`; } return job.scheduleExtensionsCount && job.scheduleExtensionsCount > 0 ? 'Extended to subscriber schedule' : `Collecting Digest events for ${(job.digest as IDigestRegularMetadata)?.amount ?? 0} ${ (job.digest as IDigestRegularMetadata)?.unit ?? '' }`; } return ''; case StepTypeEnum.DELAY: { const { unit, amount } = (job.digest || {}) as IDelayRegularMetadata; if (job.status === JobStatusEnum.COMPLETED) { if ((job.digest as IDigestTimedMetadata)?.timed?.untilDate) { return `Delayed until scheduled time${job.scheduleExtensionsCount && job.scheduleExtensionsCount > 0 ? `, extended to subscriber schedule` : ''}`; } if (unit && amount) { return `Delayed for ${amount} ${unit}${job.scheduleExtensionsCount && job.scheduleExtensionsCount > 0 ? `, extended to subscriber schedule` : ''}`; } return 'Delay completed'; } if (job.status === JobStatusEnum.DELAYED) { let msg = 'Waiting'; const untilDate = (job.digest as IDigestTimedMetadata)?.timed?.untilDate; if (untilDate) { const untilDateFormatted = format(new Date(untilDate), 'MMM d yyyy, HH:mm:ss'); return `Waiting until ${untilDateFormatted}${job.scheduleExtensionsCount && job.scheduleExtensionsCount > 0 ? `, extended to subscriber schedule` : ''}`; } if (unit && amount) { msg = job.scheduleExtensionsCount && job.scheduleExtensionsCount > 0 ? 'Extended to subscriber schedule' : `Waiting for ${amount} ${unit}`; } return msg; } return ''; } default: if (job.status === JobStatusEnum.COMPLETED) { return 'Message sent successfully'; } return ''; } } function TraceTooltip({ message, raw, variant = 'error' }: { message: string; raw: any; variant?: 'error' | 'info' }) { return (
          {formatJSONString(raw)}
        
); } const JOB_COLOR_CLASSES: Record = { neutral: { border: 'border-neutral', text: 'text-neutral' }, stable: { border: 'border-stable', text: 'text-stable' }, information: { border: 'border-information', text: 'text-information' }, feature: { border: 'border-feature', text: 'text-feature' }, destructive: { border: 'border-destructive', text: 'text-destructive' }, verified: { border: 'border-verified', text: 'text-verified' }, alert: { border: 'border-alert', text: 'text-alert' }, highlighted: { border: 'border-highlighted', text: 'text-highlighted' }, warning: { border: 'border-warning', text: 'text-warning' }, }; function getJobColorClasses(job: IActivityJob): { border: string; text: string } { const colorKey = STEP_TYPE_TO_COLOR[job.type as keyof typeof STEP_TYPE_TO_COLOR] || 'neutral'; return JOB_COLOR_CLASSES[colorKey]; } function getJobIcon(job: IActivityJob) { const Icon = STEP_TYPE_TO_ICON[job.type?.toLowerCase() as keyof typeof STEP_TYPE_TO_ICON] || Route; return ; } function getJobClasses(status: JobStatusEnum) { switch (status) { case JobStatusEnum.COMPLETED: return 'text-success'; case JobStatusEnum.FAILED: return 'text-destructive'; case JobStatusEnum.DELAYED: return 'text-warning'; case JobStatusEnum.MERGED: return 'text-neutral-300'; default: return 'text-neutral-300'; } } function JobDetails({ job }: { job: IActivityJob }) { return (
{job.executionDetails && job.executionDetails.length > 0 && (
{job.executionDetails.map((detail, index) => ( ))}
)} {/* TODO: Missing backend support for digest events widget {job.type === 'digest' && job.digest?.events && (
{job.digest.events.map((event: DigestEvent, index: number) => (
{event.type} {`${format(new Date(job.updatedAt), 'HH:mm')} UTC`}
))}
)} */}
); } interface JobStatusIndicatorProps { status: JobStatusEnum; } function JobStatusIndicator({ status }: JobStatusIndicatorProps) { const { icon: Icon, animationClass } = JOB_STATUS_CONFIG[status] || JOB_STATUS_CONFIG[JobStatusEnum.PENDING]; return (
); } ================================================ FILE: apps/dashboard/src/components/activity/activity-logs.tsx ================================================ import { IActivity } from '@novu/shared'; import { motion } from 'motion/react'; import { useRef, useState } from 'react'; import { RiCloseFill, RiFullscreenLine } from 'react-icons/ri'; import { ActivityJobItem } from '@/components/activity/activity-job-item'; import { CodeBlock } from '@/components/primitives/code-block'; import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '@/components/primitives/dialog'; import { InlineToast } from '@/components/primitives/inline-toast'; import { Popover, PopoverContent } from '@/components/primitives/popover'; import { fadeIn } from '@/utils/animation'; import { cn } from '@/utils/ui'; import { CollapsibleSection } from '../http-logs/logs-detail-content'; import { CompactButton } from '../primitives/button-compact'; import { CopyToClipboard } from '../primitives/copy-to-clipboard'; export function ActivityLogs({ activity, className, onActivitySelect, children, }: { activity: IActivity; className?: string; onActivitySelect: (activityId: string) => void; children?: React.ReactNode; }): JSX.Element { const isMerged = activity.jobs.some((job) => job.status === 'merged'); const { jobs, payload } = activity; const [isFullscreenOpen, setIsFullscreenOpenState] = useState(false); const popoverCloseRef = useRef(null); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [isExecutionDetailsExpanded, setIsExecutionDetailsExpanded] = useState(false); const formattedPayload = payload ? JSON.stringify(payload, null, 2) : '{}'; const setIsFullscreenOpen = (isOpen: boolean) => { if (isOpen && popoverCloseRef.current) { popoverCloseRef.current.click(); } setIsFullscreenOpenState(isOpen); }; return ( <>
setIsExecutionDetailsExpanded(!isExecutionDetailsExpanded)} />

Execution details

setIsPopoverOpen(open)}>

Request payload

{isMerged && ( { e.stopPropagation(); e.preventDefault(); if (activity._digestedNotificationId) { onActivitySelect(activity._digestedNotificationId); } }} description="Remaining execution has been merged to an active Digest of an existing workflow execution." /> )} {jobs.map((job, index) => ( ))} {children}
Request payload Close
} />
); } ================================================ FILE: apps/dashboard/src/components/activity/activity-panel.tsx ================================================ import { motion } from 'motion/react'; export interface ActivityPanelProps { children: React.ReactNode; } export function ActivityPanel({ children }: ActivityPanelProps) { return ( {children} ); } ================================================ FILE: apps/dashboard/src/components/activity/activity-skeleton.tsx ================================================ import { motion } from 'motion/react'; import { Skeleton } from '@/components/primitives/skeleton'; import { fadeIn } from '@/utils/animation'; import { cn } from '@/utils/ui'; export function ActivitySkeleton({ headerClassName }: { headerClassName?: string }) { return (
{[...Array(5)].map((_, i) => (
))}
{[...Array(2)].map((_, i) => (
))}
); } ================================================ FILE: apps/dashboard/src/components/activity/activity-table.tsx ================================================ import { FeatureFlagsKeysEnum } from '@novu/shared'; import { AnimatePresence, motion } from 'motion/react'; import { useEffect } from 'react'; import { createSearchParams, useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import type { ActivityFilters } from '@/api/activity'; import { Skeleton } from '@/components/primitives/skeleton'; import { showErrorToast } from '@/components/primitives/sonner-helpers'; import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from '@/components/primitives/table'; import { TablePaginationFooter } from '@/components/primitives/table-pagination-footer'; import { useFeatureFlag } from '@/hooks/use-feature-flag'; import { usePersistedPageSize } from '@/hooks/use-persisted-page-size'; import { parsePageParam } from '@/utils/parse-page-param'; import { useFetchActivities } from '../../hooks/use-fetch-activities'; import { ActivityEmptyState } from './activity-empty-state'; import { ActivityTableRow } from './components/activity-table-row'; const ACTIVITY_TABLE_ID = 'activity-table'; export interface ActivityTableProps { selectedActivityId: string | null; onActivitySelect: (activityItemId: string) => void; filters?: ActivityFilters; hasActiveFilters: boolean; onClearFilters: () => void; isLoading?: boolean; onTriggerWorkflow?: () => void; onListStateChange?: (hasActivities: boolean) => void; } export function ActivityTable({ selectedActivityId, onActivitySelect, filters, hasActiveFilters, onClearFilters, onTriggerWorkflow, onListStateChange, }: ActivityTableProps) { const [searchParams] = useSearchParams(); const location = useLocation(); const navigate = useNavigate(); const isWorkflowRunMigrationEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_WORKFLOW_RUN_PAGE_MIGRATION_ENABLED); const { pageSize, setPageSize } = usePersistedPageSize({ tableId: ACTIVITY_TABLE_ID, defaultPageSize: 10, }); // Get pagination parameters from URL const page = parsePageParam(searchParams.get('page')); const cursor = searchParams.get('cursor'); const { activities, isLoading, hasMore, next, previous, error } = useFetchActivities( { filters, page: isWorkflowRunMigrationEnabled ? undefined : page, cursor: isWorkflowRunMigrationEnabled ? cursor : undefined, limit: pageSize, }, { refetchOnWindowFocus: false, } ); useEffect(() => { if (error) { showErrorToast( error instanceof Error ? error.message : 'There was an error loading the activities.', 'Failed to fetch activities' ); } }, [error]); useEffect(() => { onListStateChange?.(!isLoading && activities.length > 0); }, [isLoading, activities.length, onListStateChange]); function handlePageChange(newPage: number) { const newParams = createSearchParams({ ...Object.fromEntries(searchParams), page: newPage.toString(), }); // Remove cursor when using page-based pagination newParams.delete('cursor'); navigate(`${location.pathname}?${newParams}`); } function handleCursorNavigation(newCursor: string | null, action: 'next' | 'previous' | 'first') { const newParams = createSearchParams({ ...Object.fromEntries(searchParams), }); // Remove page when using cursor-based pagination newParams.delete('page'); if (action === 'first') { // Go to first page by removing cursor newParams.delete('cursor'); } else if (newCursor) { newParams.set('cursor', newCursor); } else { newParams.delete('cursor'); } navigate(`${location.pathname}?${newParams}`); } function handleNext() { if (next) { handleCursorNavigation(next, 'next'); } } function handlePrevious() { if (previous) { handleCursorNavigation(previous, 'previous'); } } function handlePageSizeChange(newPageSize: number) { setPageSize(newPageSize); if (isWorkflowRunMigrationEnabled) { handleCursorNavigation(null, 'first'); } else { handlePageChange(0); } } return ( {!isLoading && activities.length === 0 ? ( ) : ( } containerClassname="bg-transparent w-full flex flex-col overflow-y-auto overflow-x-hidden max-h-full rounded-lg border border-neutral-200 bg-white" > Workflow runs {activities.map((activity) => ( ))} handlePageChange(Math.max(0, page - 1)) } onNextPage={isWorkflowRunMigrationEnabled ? handleNext : () => handlePageChange(page + 1)} onPageSizeChange={handlePageSizeChange} hasPreviousPage={isWorkflowRunMigrationEnabled ? !!previous : page > 0} hasNextPage={hasMore} className="bg-transparent shadow-none" itemName="workflow runs" pageSizeOptions={[10, 20, 50]} />
)}
); } function SkeletonRow() { return (
{[...Array(3)].map((_, i) => (
))}
); } ================================================ FILE: apps/dashboard/src/components/activity/components/activity-overview.tsx ================================================ import { FeatureFlagsKeysEnum, IActivity } from '@novu/shared'; import { format } from 'date-fns'; import { motion } from 'motion/react'; import React from 'react'; import { Link } from 'react-router-dom'; import { ContextDrawerButton } from '@/components/contexts'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/primitives/popover'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/primitives/tooltip'; import { SubscriberDrawerButton } from '@/components/subscribers/subscriber-drawer'; import { TimeDisplayHoverCard } from '@/components/time-display-hover-card'; import { TopicDrawerButton } from '@/components/topics/topic-drawer'; import { useEnvironment } from '@/context/environment/hooks'; import { useFeatureFlag } from '@/hooks/use-feature-flag'; import { fadeIn } from '@/utils/animation'; import { buildRoute, ROUTES } from '@/utils/routes'; import { capitalize } from '@/utils/string'; import { cn } from '@/utils/ui'; import { JOB_STATUS_CONFIG } from '../constants'; import { getActivityStatus } from '../helpers'; import { OverviewItem } from './overview-item'; export interface ActivityOverviewProps { activity: IActivity; } export function ActivityOverview({ activity }: ActivityOverviewProps) { const { currentEnvironment } = useEnvironment(); const status = getActivityStatus(activity.jobs); const workflowPath = buildRoute(ROUTES.EDIT_WORKFLOW, { environmentSlug: currentEnvironment?.slug ?? '', workflowSlug: activity?.template?._id ?? '', }); const renderTopicsContent = () => { if (!activity.topics?.length) { return -; } if (activity.topics.length === 1) { return ( {activity.topics[0].topicKey} ); } const firstTopic = activity.topics[0].topicKey; const othersCount = activity.topics.length - 1; return ( "{firstTopic}" + {othersCount} {othersCount === 1 ? 'other' : 'others'}
{activity.topics.map((topic, index) => ( {index > 0 && ', '} "{topic.topicKey}" ))}
); }; const renderContextKeysContent = () => { if (!activity.contextKeys?.length) { return -; } if (activity.contextKeys.length === 1) { return ( {activity.contextKeys[0]} ); } const firstContextKey = activity.contextKeys[0]; const othersCount = activity.contextKeys.length - 1; return ( {firstContextKey} + {othersCount} {othersCount === 1 ? 'other' : 'others'}
{activity.contextKeys.map((contextKey, index) => ( {index > 0 && ', '} {contextKey} ))}
); }; return (
{activity.template?.triggers?.[0]?.identifier || 'Deleted workflow'} {renderTopicsContent()} {(activity.subscriber?.subscriberId || activity._subscriberId) ?? ''} {format(new Date(activity.createdAt), 'MMM d yyyy, HH:mm:ss')} {status || 'QUEUED'} {typeof activity.severity !== 'undefined' && ( {capitalize(activity.severity.toString())} )} {typeof activity.critical === 'boolean' && ( {activity.critical ? 'true' : 'false'} )} {renderContextKeysContent()}
); } ================================================ FILE: apps/dashboard/src/components/activity/components/activity-table-row.tsx ================================================ import type { ISubscriber } from '@novu/shared'; import { TableCell, TableRow } from '@/components/primitives/table'; import { formatDateSimple } from '@/utils/format-date'; import { cn } from '@/utils/ui'; import { ActivityStatusBadge } from './status-badge'; import { StepIndicators } from './step-indicators'; type ActivityTableRowProps = { activity: any; isSelected?: boolean; onClick?: (activityId: string) => void; className?: string; }; function truncateText(text: string, maxLength: number = 26): string { if (text.length <= maxLength) return text; return text.slice(0, maxLength) + '...'; } function getSubscriberDisplay( subscriber?: Pick, variant: 'default' | 'compact' = 'default' ) { if (!subscriber) return variant === 'compact' ? 'Deleted' : ''; if (variant === 'compact') { return subscriber.subscriberId || 'Deleted'; } if (subscriber.firstName || subscriber.lastName) { return `${subscriber.firstName || ''} ${subscriber.lastName || ''}`.trim(); } if (subscriber.subscriberId) { return subscriber.subscriberId; } return ''; } export function ActivityTableRow({ activity, isSelected, onClick, className }: ActivityTableRowProps) { const handleClick = () => { onClick?.(activity._id); }; const subscriberDisplay = getSubscriberDisplay( activity.subscriber as Pick ); const truncatedTransactionId = truncateText(activity.transactionId); const truncatedSubscriberDisplay = subscriberDisplay ? truncateText(subscriberDisplay) : ''; return (
{activity.template?.name || 'Deleted workflow'}
{truncatedTransactionId} {truncatedSubscriberDisplay ? ` • ${truncatedSubscriberDisplay}` : ''}
{formatDateSimple(activity.createdAt)}
); } ================================================ FILE: apps/dashboard/src/components/activity/components/overview-item.tsx ================================================ import { ReactNode } from 'react'; import { CopyButton } from '@/components/primitives/copy-button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/primitives/tooltip'; import { cn } from '@/utils/ui'; interface OverviewItemProps { children?: ReactNode; className?: string; isCopyable?: boolean; isDeleted?: boolean; isMonospace?: boolean; label: string; value?: string; } export function OverviewItem({ children, className = '', isCopyable = false, isDeleted = false, isMonospace = true, label, value, }: OverviewItemProps) { const childrenComponent = children || ( {value} ); const wrappedChildren = isDeleted ? ( {childrenComponent} Resource was deleted. ) : ( childrenComponent ); return (
{label}
{isCopyable && value && } {wrappedChildren}
); } ================================================ FILE: apps/dashboard/src/components/activity/components/status-badge.tsx ================================================ import { type IActivityJob, JobStatusEnum } from '@novu/shared'; import { StatusBadge as StatusBadgeComponent, StatusBadgeIcon } from '../../primitives/status-badge'; import { JOB_STATUS_CONFIG } from '../constants'; import { getActivityStatus } from '../helpers'; export interface StatusBadgeProps { jobs: IActivityJob[]; } export function ActivityStatusBadge({ jobs }: StatusBadgeProps) { const status = getActivityStatus(jobs); const { variant, icon: Icon } = JOB_STATUS_CONFIG[status] || JOB_STATUS_CONFIG[JobStatusEnum.PENDING]; return ( ); } ================================================ FILE: apps/dashboard/src/components/activity/components/status-preview-card.tsx ================================================ import { IActivityJob, JobStatusEnum, StepTypeEnum } from '@novu/shared'; import { format } from 'date-fns'; import { RiCheckLine, RiCloseCircleLine, RiLoader4Line, RiPauseLine, RiStopLine } from 'react-icons/ri'; import { STEP_TYPE_TO_ICON } from '@/components/icons/utils'; import { Badge } from '@/components/primitives/badge'; import { STEP_TYPE_LABELS } from '@/utils/constants'; import { cn } from '@/utils/ui'; import { JOB_STATUS_CONFIG } from '../constants'; function getStepIcon(type?: StepTypeEnum) { const Icon = STEP_TYPE_TO_ICON[type as keyof typeof STEP_TYPE_TO_ICON]; return ; } function getStatusIcon(status: JobStatusEnum) { switch (status) { case JobStatusEnum.COMPLETED: return ; case JobStatusEnum.FAILED: return ; case JobStatusEnum.PENDING: case JobStatusEnum.QUEUED: return ; case JobStatusEnum.CANCELED: case JobStatusEnum.SKIPPED: return ; default: return ; } } function getStatusVariant(status: JobStatusEnum): 'success' | 'destructive' | 'warning' | 'neutral' { switch (status) { case JobStatusEnum.COMPLETED: return 'success'; case JobStatusEnum.FAILED: return 'destructive'; case JobStatusEnum.PENDING: case JobStatusEnum.QUEUED: return 'warning'; default: return 'neutral'; } } export interface StatusPreviewCardProps { jobs: IActivityJob[]; } export function StatusPreviewCard({ jobs }: StatusPreviewCardProps) { return (
{jobs.map((job) => { const lastExecutionDetail = job.executionDetails?.at(-1); const status = job.status; const statusVariant = getStatusVariant(status); return (
{/* Step Icon with Status Overlay */}
{getStepIcon(job.type)}
{/* Status indicator overlay */}
{getStatusIcon(status)}
{/* Step Name and Status */}
{STEP_TYPE_LABELS[job.type!] || job.type} {job.createdAt && ( {format(new Date(job.createdAt), 'HH:mm:ss')} )}
{/* Execution Detail */} {lastExecutionDetail?.detail && (
{lastExecutionDetail.detail}
)} {/* Status Badge */}
{JOB_STATUS_CONFIG[status]?.label || status}
); })}
{jobs.length > 0 && (
Click on the workflow run to see more details
)}
); } ================================================ FILE: apps/dashboard/src/components/activity/components/step-indicators.tsx ================================================ import { IActivityJob, JobStatusEnum, StepTypeEnum } from '@novu/shared'; import { useEffect, useRef, useState } from 'react'; import { STEP_TYPE_TO_ICON } from '@/components/icons/utils'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/primitives/popover'; import { cn } from '@/utils/ui'; import { STATUS_STYLES } from '../constants'; import { StatusPreviewCard } from './status-preview-card'; export interface StepIndicatorsProps { jobs: IActivityJob[]; size?: 'sm' | 'md'; } export function StepIndicators({ jobs, size = 'md' }: StepIndicatorsProps) { const [isOpen, setIsOpen] = useState(false); const timeoutRef = useRef | null>(null); const handleMouseEnter = () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } timeoutRef.current = setTimeout(() => { setIsOpen(true); }, 200); }; const handleMouseLeave = () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } timeoutRef.current = setTimeout(() => { setIsOpen(false); }, 150); }; useEffect(() => { return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } }; }, []); const visibleJobs = jobs.slice(0, 4); const remainingJobs = jobs.slice(4); const hasRemainingJobs = remainingJobs.length > 0; const remainingJobsStatus = getRemainingJobsStatus(remainingJobs); const sizeClasses = { sm: 'h-5 w-5', md: 'h-7.5 w-7.5', }; const remainingSizeClasses = { sm: 'h-5 min-w-5', md: 'h-7.5 min-w-7.5', }; return (
{visibleJobs.map((job) => (
{getStepIcon(job.type, size)}
))} {hasRemainingJobs && (
+{remainingJobs.length}
)}
setIsOpen(true)} onMouseLeave={handleMouseLeave} >
); } function getStepIcon(type?: StepTypeEnum, size: 'sm' | 'md' = 'md') { const Icon = STEP_TYPE_TO_ICON[type as keyof typeof STEP_TYPE_TO_ICON]; const iconSizeClasses = { sm: 'h-2.5 w-2.5', md: 'h-4 w-4', }; return ; } function getRemainingJobsStatus(jobs: IActivityJob[]): 'completed' | 'failed' | 'default' { const hasFailedJob = jobs.some((job) => job.status === JobStatusEnum.FAILED); const allCompleted = jobs.every((job) => job.status === JobStatusEnum.COMPLETED); if (hasFailedJob) return 'failed'; if (allCompleted) return 'completed'; return 'default'; } ================================================ FILE: apps/dashboard/src/components/activity/constants.ts ================================================ import { ChannelTypeEnum, JobStatusEnum } from '@novu/shared'; import { IconType } from 'react-icons/lib'; import { RiCheckboxCircleFill, RiErrorWarningFill, RiForbidFill, RiLoader3Line, RiLoader4Fill } from 'react-icons/ri'; import { ActivityFiltersData } from '@/types/activity'; import { StatusBadgeProps } from '../primitives/status-badge'; export const STATUS_STYLES = { completed: 'border-[#99e3bb] bg-[#e9faf0] text-[#99e3bb]', failed: 'border-[#ec98a0] bg-[#ffebed] text-[#ec98a0]', delayed: 'border-[#F5A524] bg-[#FEF4E6] text-[#F8C16E]', default: 'border-[#e0e4ea] bg-[#fbfbfb] text-[#e0e4ea]', } as const; export const JOB_STATUS_CONFIG: Record< JobStatusEnum, { variant: StatusBadgeProps['status']; color: string; icon: IconType; label: string; animationClass?: string; } > = { [JobStatusEnum.COMPLETED]: { variant: 'completed' as const, color: 'success', icon: RiCheckboxCircleFill, label: 'SUCCESS', }, [JobStatusEnum.FAILED]: { variant: 'failed' as const, color: 'destructive', icon: RiErrorWarningFill, label: `ERROR`, }, [JobStatusEnum.MERGED]: { variant: 'disabled' as const, color: 'success', icon: RiForbidFill, label: 'MERGED', }, [JobStatusEnum.PENDING]: { variant: 'pending' as const, icon: RiLoader3Line, color: 'neutral-300', label: 'PENDING', }, [JobStatusEnum.CANCELED]: { variant: 'disabled' as const, icon: RiLoader3Line, color: 'neutral-300', label: 'CANCELED', }, [JobStatusEnum.SKIPPED]: { variant: 'disabled' as const, icon: RiLoader3Line, color: 'neutral-300', label: 'SKIPPED', }, [JobStatusEnum.RUNNING]: { variant: 'pending' as const, icon: RiLoader3Line, color: 'warning', label: 'RUNNING', animationClass: 'animate-spin', }, [JobStatusEnum.DELAYED]: { variant: 'pending' as const, icon: RiLoader4Fill, label: 'DELAYED', color: 'warning', animationClass: 'animate-spin-slow', }, [JobStatusEnum.QUEUED]: { variant: 'pending' as const, icon: RiLoader3Line, color: 'warning', label: 'QUEUED', }, }; export const DATE_RANGE_OPTIONS = [ { value: '24h', label: 'Last 24 hours', ms: 24 * 60 * 60 * 1000 }, { value: '7d', label: 'Last 7 days', ms: 7 * 24 * 60 * 60 * 1000 }, { value: '30d', label: 'Last 30 days', ms: 30 * 24 * 60 * 60 * 1000 }, { value: '90d', label: 'Last 90 days', ms: 90 * 24 * 60 * 60 * 1000 }, ]; export const DEFAULT_DATE_RANGE = '24h'; export const CHANNEL_OPTIONS = [ { value: ChannelTypeEnum.SMS, label: 'SMS' }, { value: ChannelTypeEnum.EMAIL, label: 'Email' }, { value: ChannelTypeEnum.IN_APP, label: 'In-App' }, { value: ChannelTypeEnum.PUSH, label: 'Push' }, { value: ChannelTypeEnum.CHAT, label: 'Chat' }, ]; export const defaultActivityFilters: ActivityFiltersData = { dateRange: DEFAULT_DATE_RANGE, channels: [], workflows: [], transactionId: '', subscriberId: '', topicKey: '', severity: [], contextKeys: [], subscriptionId: '', } as const; ================================================ FILE: apps/dashboard/src/components/activity/execution-detail-item.tsx ================================================ import { IExecutionDetail } from '@novu/shared'; import { format } from 'date-fns'; import { useMemo } from 'react'; import { formatJSONString } from '../../utils/string'; import { ActivityDetailCard } from './activity-detail-card'; interface ExecutionDetailItemProps { detail: IExecutionDetail; } export function ExecutionDetailItem(props: ExecutionDetailItemProps) { const { detail } = props; const footer = useMemo(() => { if (detail.eventType === 'topic_subscription_preference_evaluation') { return 'Preferences are evaluated in order. Only the first matching preference is shown.'; } return null; }, [detail.eventType]); return (
{detail.raw && (
            {formatJSONString(detail.raw)}
          
)}
); } ================================================ FILE: apps/dashboard/src/components/activity/helpers.ts ================================================ import { IActivityJob, JobStatusEnum } from '@novu/shared'; export const getActivityStatus = (jobs: IActivityJob[]) => { if (!jobs.length) return JobStatusEnum.PENDING; const hasFailedJob = jobs.some((job) => job.status === JobStatusEnum.FAILED); if (hasFailedJob) { return JobStatusEnum.FAILED; } const lastJob = jobs[jobs.length - 1]; if (lastJob.status === JobStatusEnum.SKIPPED || lastJob.status === JobStatusEnum.CANCELED) { const previousJobs = jobs.slice(0, -1); const hasPreviousCompletedJobs = previousJobs.some((job) => job.status === JobStatusEnum.COMPLETED); if (hasPreviousCompletedJobs || !previousJobs.length) { return JobStatusEnum.COMPLETED; } } return lastJob.status; }; ================================================ FILE: apps/dashboard/src/components/ai-drawer/ai-drawer-provider.tsx ================================================ import { ReactNode, useCallback, useState } from 'react'; import { AiDrawer } from './ai-drawer'; import { AiDrawerContext } from './use-ai-drawer'; type AiDrawerProviderProps = { children: ReactNode; }; export function AiDrawerProvider({ children }: AiDrawerProviderProps) { const [isOpen, setIsOpen] = useState(false); const [initialQuery, setInitialQuery] = useState(''); const openAiDrawer = useCallback((query?: string) => { setInitialQuery(query || ''); setIsOpen(true); }, []); const closeAiDrawer = useCallback(() => { setIsOpen(false); setInitialQuery(''); }, []); return ( {children} ); } ================================================ FILE: apps/dashboard/src/components/ai-drawer/ai-drawer.tsx ================================================ import { InkeepEmbeddedSearchAndChat, InkeepEmbeddedSearchAndChatProps } from '@inkeep/cxkit-react'; import { forwardRef, useEffect, useRef } from 'react'; import { RiCloseLine } from 'react-icons/ri'; import { CompactButton } from '@/components/primitives/button-compact'; import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/primitives/sheet'; import { VisuallyHidden } from '@/components/primitives/visually-hidden'; type AiDrawerProps = { isOpen: boolean; onOpenChange: (open: boolean) => void; initialQuery?: string; }; export const AiDrawer = forwardRef(({ isOpen, onOpenChange, initialQuery }, ref) => { const searchFunctionsRef = useRef(null); const chatFunctionsRef = useRef(null); const hasInkeep = !!import.meta.env.VITE_INKEEP_API_KEY; useEffect(() => { if (isOpen && hasInkeep) { setTimeout(() => { if (initialQuery?.trim()) { chatFunctionsRef.current?.updateInputMessage(initialQuery); } chatFunctionsRef.current?.focusInput(); }, 100); } }, [isOpen, initialQuery]); if (!hasInkeep) { return null; } const inkeepConfig: InkeepEmbeddedSearchAndChatProps = { defaultView: 'chat', baseSettings: { apiKey: import.meta.env.VITE_INKEEP_API_KEY, organizationDisplayName: 'Novu', primaryBrandColor: '#DD2476', theme: { styles: [ { key: 'custom-theme', type: 'style', value: ` .ikp-ai-chat-wrapper { height: 100%; } `, }, ], }, }, searchSettings: { searchFunctionsRef, }, aiChatSettings: { aiAssistantName: 'Novu AI', chatFunctionsRef, }, shouldAutoFocusInput: true, }; return ( AI Assistant Get help and answers from Novu AI
); }); AiDrawer.displayName = 'AiDrawer'; ================================================ FILE: apps/dashboard/src/components/ai-drawer/index.ts ================================================ export { AiDrawer } from './ai-drawer'; export { AiDrawerProvider } from './ai-drawer-provider'; export { useAiDrawer } from './use-ai-drawer'; ================================================ FILE: apps/dashboard/src/components/ai-drawer/use-ai-drawer.ts ================================================ import { createContext, useContext } from 'react'; type AiDrawerContextType = { isOpen: boolean; openAiDrawer: (query?: string) => void; closeAiDrawer: () => void; }; export const AiDrawerContext = createContext(null); export function useAiDrawer() { const context = useContext(AiDrawerContext); if (!context) { throw new Error('useAiDrawer must be used within an AiDrawerProvider'); } return context; } ================================================ FILE: apps/dashboard/src/components/ai-elements/chain-of-thought.tsx ================================================ 'use client'; import { useControllableState } from '@radix-ui/react-use-controllable-state'; import { BrainIcon, DotIcon, type LucideIcon } from 'lucide-react'; import type { ComponentProps, ReactNode } from 'react'; import { createContext, memo, useContext, useEffect, useMemo, useState } from 'react'; import { IconType } from 'react-icons/lib'; import { RiArrowDownSLine, RiArrowRightSLine } from 'react-icons/ri'; import { Badge } from '@/components/primitives/badge'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/primitives/collapsible'; import { cn } from '@/utils/ui'; interface ChainOfThoughtContextValue { isOpen: boolean; setIsOpen: (open: boolean) => void; } const ChainOfThoughtContext = createContext(null); const useChainOfThought = () => { const context = useContext(ChainOfThoughtContext); if (!context) { throw new Error('ChainOfThought components must be used within ChainOfThought'); } return context; }; export type ChainOfThoughtProps = ComponentProps<'div'> & { open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; }; export const ChainOfThought = memo( ({ className, open, defaultOpen = false, onOpenChange, children, ...props }: ChainOfThoughtProps) => { const [isOpen, setIsOpen] = useControllableState({ prop: open, defaultProp: defaultOpen, onChange: onOpenChange, }); const chainOfThoughtContext = useMemo(() => ({ isOpen, setIsOpen }), [isOpen, setIsOpen]); return (
{children}
); } ); export type ChainOfThoughtHeaderProps = ComponentProps & { icon?: IconType | LucideIcon; }; export const ChainOfThoughtHeader = memo( ({ className, children, icon: Icon = BrainIcon, ...props }: ChainOfThoughtHeaderProps) => { const { isOpen, setIsOpen } = useChainOfThought(); return ( {children ?? 'Chain of Thought'} ); } ); export type ChainOfThoughtStepProps = ComponentProps<'div'> & { icon?: IconType | LucideIcon; label?: ReactNode; description?: ReactNode; status?: 'complete' | 'active' | 'pending' | 'error'; collapsible?: boolean; defaultOpen?: boolean; autoCollapse?: boolean; }; export const ChainOfThoughtStep = memo( ({ className, icon: Icon = DotIcon, label, description, status = 'complete', collapsible = false, autoCollapse = false, defaultOpen = true, children, ...props }: ChainOfThoughtStepProps) => { const [isOpen, setIsOpen] = useState(defaultOpen); useEffect(() => { if (autoCollapse && (status === 'complete' || status === 'error')) { setIsOpen(false); } }, [autoCollapse, status]); const statusStyles = { complete: 'text-muted-foreground', active: 'text-foreground', pending: 'text-muted-foreground/50', error: 'text-muted-foreground', }; return (
{collapsible && children ? (
{!!label && (
{label}
)}
{description &&
{description}
} {children}
) : ( <>
{label &&
{label}
} {description &&
{description}
} {children}
)}
); } ); export type ChainOfThoughtSearchResultsProps = ComponentProps<'div'>; export const ChainOfThoughtSearchResults = memo(({ className, ...props }: ChainOfThoughtSearchResultsProps) => (
)); export type ChainOfThoughtSearchResultProps = ComponentProps; export const ChainOfThoughtSearchResult = memo(({ className, children, ...props }: ChainOfThoughtSearchResultProps) => ( {children} )); export type ChainOfThoughtContentProps = ComponentProps; export const ChainOfThoughtContent = memo(({ className, children, ...props }: ChainOfThoughtContentProps) => { const { isOpen } = useChainOfThought(); return ( {children} ); }); export type ChainOfThoughtImageProps = ComponentProps<'div'> & { caption?: string; }; export const ChainOfThoughtImage = memo(({ className, children, caption, ...props }: ChainOfThoughtImageProps) => (
{children}
{caption &&

{caption}

}
)); ChainOfThought.displayName = 'ChainOfThought'; ChainOfThoughtHeader.displayName = 'ChainOfThoughtHeader'; ChainOfThoughtStep.displayName = 'ChainOfThoughtStep'; ChainOfThoughtSearchResults.displayName = 'ChainOfThoughtSearchResults'; ChainOfThoughtSearchResult.displayName = 'ChainOfThoughtSearchResult'; ChainOfThoughtContent.displayName = 'ChainOfThoughtContent'; ChainOfThoughtImage.displayName = 'ChainOfThoughtImage'; ================================================ FILE: apps/dashboard/src/components/ai-elements/conversation.tsx ================================================ 'use client'; import { ArrowDownIcon } from 'lucide-react'; import type { ComponentProps } from 'react'; import { useCallback } from 'react'; import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom'; import { Button } from '@/components/primitives/button'; import { cn } from '@/utils/ui'; export type ConversationProps = ComponentProps; export const Conversation = ({ className, ...props }: ConversationProps) => ( ); export type ConversationContentProps = ComponentProps; export const ConversationContent = ({ className, ...props }: ConversationContentProps) => ( ); export type ConversationEmptyStateProps = ComponentProps<'div'> & { title?: string; description?: string; icon?: React.ReactNode; }; export const ConversationEmptyState = ({ className, title = 'No messages yet', description = 'Start a conversation to see messages here', icon, children, ...props }: ConversationEmptyStateProps) => (
{children ?? ( <> {icon &&
{icon}
}

{title}

{description &&

{description}

}
)}
); export type ConversationScrollButtonProps = ComponentProps; export const ConversationScrollButton = ({ className, ...props }: ConversationScrollButtonProps) => { const { isAtBottom, scrollToBottom } = useStickToBottomContext(); const handleScrollToBottom = useCallback(() => { scrollToBottom(); }, [scrollToBottom]); return ( !isAtBottom && ( ) ); }; ================================================ FILE: apps/dashboard/src/components/ai-elements/message.tsx ================================================ 'use client'; import { cjk } from '@streamdown/cjk'; import { code } from '@streamdown/code'; import { math } from '@streamdown/math'; import { mermaid } from '@streamdown/mermaid'; import type { UIMessage } from 'ai'; import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; import type { ComponentProps, HTMLAttributes, ReactElement } from 'react'; import { createContext, memo, useContext, useEffect, useState } from 'react'; import { Streamdown } from 'streamdown'; import { Button } from '@/components/primitives/button'; import { ButtonGroupRoot, ButtonGroupText } from '@/components/primitives/button-group'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/primitives/tooltip'; import { cn } from '@/utils/ui'; export type MessageProps = HTMLAttributes & { from: UIMessage['role']; }; export const Message = ({ className, from, ...props }: MessageProps) => (
); export type MessageContentProps = HTMLAttributes; export const MessageContent = ({ children, className, ...props }: MessageContentProps) => (
{children}
); export type MessageActionsProps = ComponentProps<'div'>; export const MessageActions = ({ className, children, ...props }: MessageActionsProps) => (
{children}
); export type MessageActionProps = ComponentProps & { tooltip?: string; label?: string; }; export const MessageAction = ({ tooltip, children, label, variant = 'primary', mode = 'ghost', size = 'sm', ...props }: MessageActionProps) => { const button = ( ); if (tooltip) { return ( {button}

{tooltip}

); } return button; }; interface MessageBranchContextType { currentBranch: number; totalBranches: number; goToPrevious: () => void; goToNext: () => void; branches: ReactElement[]; setBranches: (branches: ReactElement[]) => void; } const MessageBranchContext = createContext(null); const useMessageBranch = () => { const context = useContext(MessageBranchContext); if (!context) { throw new Error('MessageBranch components must be used within MessageBranch'); } return context; }; export type MessageBranchProps = HTMLAttributes & { defaultBranch?: number; onBranchChange?: (branchIndex: number) => void; }; export const MessageBranch = ({ defaultBranch = 0, onBranchChange, className, ...props }: MessageBranchProps) => { const [currentBranch, setCurrentBranch] = useState(defaultBranch); const [branches, setBranches] = useState([]); const handleBranchChange = (newBranch: number) => { setCurrentBranch(newBranch); onBranchChange?.(newBranch); }; const goToPrevious = () => { const newBranch = currentBranch > 0 ? currentBranch - 1 : branches.length - 1; handleBranchChange(newBranch); }; const goToNext = () => { const newBranch = currentBranch < branches.length - 1 ? currentBranch + 1 : 0; handleBranchChange(newBranch); }; const contextValue: MessageBranchContextType = { currentBranch, totalBranches: branches.length, goToPrevious, goToNext, branches, setBranches, }; return (
div]:pb-0', className)} {...props} /> ); }; export type MessageBranchContentProps = HTMLAttributes; export const MessageBranchContent = ({ children, ...props }: MessageBranchContentProps) => { const { currentBranch, setBranches, branches } = useMessageBranch(); const childrenArray = Array.isArray(children) ? children : [children]; // Use useEffect to update branches when they change useEffect(() => { if (branches.length !== childrenArray.length) { setBranches(childrenArray); } }, [childrenArray, branches, setBranches]); return childrenArray.map((branch, index) => (
div]:pb-0', index === currentBranch ? 'block' : 'hidden')} key={branch.key} {...props} > {branch}
)); }; export type MessageBranchSelectorProps = HTMLAttributes & { from: UIMessage['role']; }; export const MessageBranchSelector = ({ className, from, ...props }: MessageBranchSelectorProps) => { const { totalBranches } = useMessageBranch(); // Don't render if there's only one branch if (totalBranches <= 1) { return null; } return ( ); }; export type MessageBranchPreviousProps = ComponentProps; export const MessageBranchPrevious = ({ children, ...props }: MessageBranchPreviousProps) => { const { goToPrevious, totalBranches } = useMessageBranch(); return ( ); }; export type MessageBranchNextProps = ComponentProps; export const MessageBranchNext = ({ children, ...props }: MessageBranchNextProps) => { const { goToNext, totalBranches } = useMessageBranch(); return ( ); }; export type MessageBranchPageProps = HTMLAttributes; export const MessageBranchPage = ({ className, ...props }: MessageBranchPageProps) => { const { currentBranch, totalBranches } = useMessageBranch(); return ( {currentBranch + 1} of {totalBranches} ); }; export type MessageResponseProps = ComponentProps; export const MessageResponse = memo( ({ className, ...props }: MessageResponseProps) => ( *:first-child]:mt-0 [&>*:last-child]:mb-0', className)} plugins={{ code, mermaid, math, cjk }} {...props} /> ), (prevProps, nextProps) => prevProps.children === nextProps.children ); MessageResponse.displayName = 'MessageResponse'; export type MessageToolbarProps = ComponentProps<'div'>; export const MessageToolbar = ({ className, children, ...props }: MessageToolbarProps) => (
{children}
); ================================================ FILE: apps/dashboard/src/components/ai-elements/prompt-input.tsx ================================================ 'use client'; import type { ChatStatus, FileUIPart, SourceDocumentUIPart } from 'ai'; import { CornerDownLeftIcon, ImageIcon, Loader2Icon, PlusIcon, SquareIcon, XIcon } from 'lucide-react'; import { nanoid } from 'nanoid'; import { type ChangeEvent, type ChangeEventHandler, Children, type ClipboardEventHandler, type ComponentProps, ComponentPropsWithRef, createContext, type FormEvent, type FormEventHandler, type HTMLAttributes, type KeyboardEventHandler, type PropsWithChildren, type RefObject, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, } from '@/components/primitives/command'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/primitives/dropdown-menu'; import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/primitives/hover-card'; import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from '@/components/primitives/input-group'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/primitives/select'; import { cn } from '@/utils/ui'; // ============================================================================ // Provider Context & Types // ============================================================================ export interface AttachmentsContext { files: (FileUIPart & { id: string })[]; add: (files: File[] | FileList) => void; remove: (id: string) => void; clear: () => void; openFileDialog: () => void; fileInputRef: RefObject; } export interface TextInputContext { value: string; setInput: (v: string) => void; clear: () => void; } export interface PromptInputControllerProps { textInput: TextInputContext; attachments: AttachmentsContext; /** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */ __registerFileInput: (ref: RefObject, open: () => void) => void; } const PromptInputController = createContext(null); const ProviderAttachmentsContext = createContext(null); export const usePromptInputController = () => { const ctx = useContext(PromptInputController); if (!ctx) { throw new Error('Wrap your component inside to use usePromptInputController().'); } return ctx; }; // Optional variants (do NOT throw). Useful for dual-mode components. const useOptionalPromptInputController = () => useContext(PromptInputController); export const useProviderAttachments = () => { const ctx = useContext(ProviderAttachmentsContext); if (!ctx) { throw new Error('Wrap your component inside to use useProviderAttachments().'); } return ctx; }; const useOptionalProviderAttachments = () => useContext(ProviderAttachmentsContext); export type PromptInputProviderProps = PropsWithChildren<{ initialInput?: string; }>; /** * Optional global provider that lifts PromptInput state outside of PromptInput. * If you don't use it, PromptInput stays fully self-managed. */ export function PromptInputProvider({ initialInput: initialTextInput = '', children }: PromptInputProviderProps) { // ----- textInput state const [textInput, setTextInput] = useState(initialTextInput); const clearInput = useCallback(() => setTextInput(''), []); // ----- attachments state (global when wrapped) const [attachmentFiles, setAttachmentFiles] = useState<(FileUIPart & { id: string })[]>([]); const fileInputRef = useRef(null); const openRef = useRef<() => void>(() => undefined); const add = useCallback((files: File[] | FileList) => { const incoming = Array.from(files); if (incoming.length === 0) { return; } setAttachmentFiles((prev) => prev.concat( incoming.map((file) => ({ id: nanoid(), type: 'file' as const, url: URL.createObjectURL(file), mediaType: file.type, filename: file.name, })) ) ); }, []); const remove = useCallback((id: string) => { setAttachmentFiles((prev) => { const found = prev.find((f) => f.id === id); if (found?.url) { URL.revokeObjectURL(found.url); } return prev.filter((f) => f.id !== id); }); }, []); const clear = useCallback(() => { setAttachmentFiles((prev) => { for (const f of prev) { if (f.url) { URL.revokeObjectURL(f.url); } } return []; }); }, []); // Keep a ref to attachments for cleanup on unmount (avoids stale closure) const attachmentsRef = useRef(attachmentFiles); attachmentsRef.current = attachmentFiles; // Cleanup blob URLs on unmount to prevent memory leaks useEffect( () => () => { for (const f of attachmentsRef.current) { if (f.url) { URL.revokeObjectURL(f.url); } } }, [] ); const openFileDialog = useCallback(() => { openRef.current?.(); }, []); const attachments = useMemo( () => ({ files: attachmentFiles, add, remove, clear, openFileDialog, fileInputRef, }), [attachmentFiles, add, remove, clear, openFileDialog] ); const __registerFileInput = useCallback((ref: RefObject, open: () => void) => { fileInputRef.current = ref.current; openRef.current = open; }, []); const controller = useMemo( () => ({ textInput: { value: textInput, setInput: setTextInput, clear: clearInput, }, attachments, __registerFileInput, }), [textInput, clearInput, attachments, __registerFileInput] ); return ( {children} ); } // ============================================================================ // Component Context & Hooks // ============================================================================ const LocalAttachmentsContext = createContext(null); export const usePromptInputAttachments = () => { // Prefer local context (inside PromptInput) as it has validation, fall back to provider const provider = useOptionalProviderAttachments(); const local = useContext(LocalAttachmentsContext); const context = local ?? provider; if (!context) { throw new Error('usePromptInputAttachments must be used within a PromptInput or PromptInputProvider'); } return context; }; // ============================================================================ // Referenced Sources (Local to PromptInput) // ============================================================================ export interface ReferencedSourcesContext { sources: (SourceDocumentUIPart & { id: string })[]; add: (sources: SourceDocumentUIPart[] | SourceDocumentUIPart) => void; remove: (id: string) => void; clear: () => void; } export const LocalReferencedSourcesContext = createContext(null); export const usePromptInputReferencedSources = () => { const ctx = useContext(LocalReferencedSourcesContext); if (!ctx) { throw new Error('usePromptInputReferencedSources must be used within a LocalReferencedSourcesContext.Provider'); } return ctx; }; export type PromptInputActionAddAttachmentsProps = ComponentProps & { label?: string; }; export const PromptInputActionAddAttachments = ({ label = 'Add photos or files', ...props }: PromptInputActionAddAttachmentsProps) => { const attachments = usePromptInputAttachments(); return ( { e.preventDefault(); attachments.openFileDialog(); }} > {label} ); }; export interface PromptInputMessage { text: string; files: FileUIPart[]; } export type PromptInputProps = Omit, 'onSubmit' | 'onError'> & { accept?: string; // e.g., "image/*" or leave undefined for any multiple?: boolean; // When true, accepts drops anywhere on document. Default false (opt-in). globalDrop?: boolean; // Render a hidden input with given name and keep it in sync for native form posts. Default false. syncHiddenInput?: boolean; // Minimal constraints maxFiles?: number; maxFileSize?: number; // bytes onError?: (err: { code: 'max_files' | 'max_file_size' | 'accept'; message: string }) => void; onSubmit: (message: PromptInputMessage, event: FormEvent) => void | Promise; }; export const PromptInput = ({ className, accept, multiple, globalDrop, syncHiddenInput, maxFiles, maxFileSize, onError, onSubmit, children, ...props }: PromptInputProps) => { // Try to use a provider controller if present const controller = useOptionalPromptInputController(); const usingProvider = !!controller; // Refs const inputRef = useRef(null); const formRef = useRef(null); // ----- Local attachments (only used when no provider) const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]); const files = usingProvider ? controller.attachments.files : items; // ----- Local referenced sources (always local to PromptInput) const [referencedSources, setReferencedSources] = useState<(SourceDocumentUIPart & { id: string })[]>([]); // Keep a ref to files for cleanup on unmount (avoids stale closure) const filesRef = useRef(files); filesRef.current = files; const openFileDialogLocal = useCallback(() => { inputRef.current?.click(); }, []); const matchesAccept = useCallback( (f: File) => { if (!accept || accept.trim() === '') { return true; } const patterns = accept .split(',') .map((s) => s.trim()) .filter(Boolean); return patterns.some((pattern) => { if (pattern.endsWith('/*')) { const prefix = pattern.slice(0, -1); // e.g: image/* -> image/ return f.type.startsWith(prefix); } return f.type === pattern; }); }, [accept] ); const addLocal = useCallback( (fileList: File[] | FileList) => { const incoming = Array.from(fileList); const accepted = incoming.filter((f) => matchesAccept(f)); if (incoming.length && accepted.length === 0) { onError?.({ code: 'accept', message: 'No files match the accepted types.', }); return; } const withinSize = (f: File) => (maxFileSize ? f.size <= maxFileSize : true); const sized = accepted.filter(withinSize); if (accepted.length > 0 && sized.length === 0) { onError?.({ code: 'max_file_size', message: 'All files exceed the maximum size.', }); return; } setItems((prev) => { const capacity = typeof maxFiles === 'number' ? Math.max(0, maxFiles - prev.length) : undefined; const capped = typeof capacity === 'number' ? sized.slice(0, capacity) : sized; if (typeof capacity === 'number' && sized.length > capacity) { onError?.({ code: 'max_files', message: 'Too many files. Some were not added.', }); } const next: (FileUIPart & { id: string })[] = []; for (const file of capped) { next.push({ id: nanoid(), type: 'file', url: URL.createObjectURL(file), mediaType: file.type, filename: file.name, }); } return prev.concat(next); }); }, [matchesAccept, maxFiles, maxFileSize, onError] ); const removeLocal = useCallback( (id: string) => setItems((prev) => { const found = prev.find((file) => file.id === id); if (found?.url) { URL.revokeObjectURL(found.url); } return prev.filter((file) => file.id !== id); }), [] ); // Wrapper that validates files before calling provider's add const addWithProviderValidation = useCallback( (fileList: File[] | FileList) => { const incoming = Array.from(fileList); const accepted = incoming.filter((f) => matchesAccept(f)); if (incoming.length && accepted.length === 0) { onError?.({ code: 'accept', message: 'No files match the accepted types.', }); return; } const withinSize = (f: File) => (maxFileSize ? f.size <= maxFileSize : true); const sized = accepted.filter(withinSize); if (accepted.length > 0 && sized.length === 0) { onError?.({ code: 'max_file_size', message: 'All files exceed the maximum size.', }); return; } const currentCount = files.length; const capacity = typeof maxFiles === 'number' ? Math.max(0, maxFiles - currentCount) : undefined; const capped = typeof capacity === 'number' ? sized.slice(0, capacity) : sized; if (typeof capacity === 'number' && sized.length > capacity) { onError?.({ code: 'max_files', message: 'Too many files. Some were not added.', }); } if (capped.length > 0) { controller?.attachments.add(capped); } }, [matchesAccept, maxFileSize, maxFiles, onError, files.length, controller] ); const clearAttachments = useCallback( () => usingProvider ? controller?.attachments.clear() : setItems((prev) => { for (const file of prev) { if (file.url) { URL.revokeObjectURL(file.url); } } return []; }), [usingProvider, controller] ); const clearReferencedSources = useCallback(() => setReferencedSources([]), []); const add = usingProvider ? addWithProviderValidation : addLocal; const remove = usingProvider ? controller.attachments.remove : removeLocal; const openFileDialog = usingProvider ? controller.attachments.openFileDialog : openFileDialogLocal; const clear = useCallback(() => { clearAttachments(); clearReferencedSources(); }, [clearAttachments, clearReferencedSources]); // Let provider know about our hidden file input so external menus can call openFileDialog() useEffect(() => { if (!usingProvider) { return; } controller.__registerFileInput(inputRef, () => inputRef.current?.click()); }, [usingProvider, controller]); // Note: File input cannot be programmatically set for security reasons // The syncHiddenInput prop is no longer functional useEffect(() => { if (syncHiddenInput && inputRef.current && files.length === 0) { inputRef.current.value = ''; } }, [files, syncHiddenInput]); // Attach drop handlers on nearest form and document (opt-in) useEffect(() => { const form = formRef.current; if (!form) { return; } if (globalDrop) { return; // when global drop is on, let the document-level handler own drops } const onDragOver = (e: DragEvent) => { if (e.dataTransfer?.types?.includes('Files')) { e.preventDefault(); } }; const onDrop = (e: DragEvent) => { if (e.dataTransfer?.types?.includes('Files')) { e.preventDefault(); } if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { add(e.dataTransfer.files); } }; form.addEventListener('dragover', onDragOver); form.addEventListener('drop', onDrop); return () => { form.removeEventListener('dragover', onDragOver); form.removeEventListener('drop', onDrop); }; }, [add, globalDrop]); useEffect(() => { if (!globalDrop) { return; } const onDragOver = (e: DragEvent) => { if (e.dataTransfer?.types?.includes('Files')) { e.preventDefault(); } }; const onDrop = (e: DragEvent) => { if (e.dataTransfer?.types?.includes('Files')) { e.preventDefault(); } if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { add(e.dataTransfer.files); } }; document.addEventListener('dragover', onDragOver); document.addEventListener('drop', onDrop); return () => { document.removeEventListener('dragover', onDragOver); document.removeEventListener('drop', onDrop); }; }, [add, globalDrop]); useEffect( () => () => { if (!usingProvider) { for (const f of filesRef.current) { if (f.url) { URL.revokeObjectURL(f.url); } } } }, // eslint-disable-next-line react-hooks/exhaustive-deps -- cleanup only on unmount; filesRef always current [usingProvider] ); const handleChange: ChangeEventHandler = (event) => { if (event.currentTarget.files) { add(event.currentTarget.files); } // Reset input value to allow selecting files that were previously removed event.currentTarget.value = ''; }; const convertBlobUrlToDataUrl = async (url: string): Promise => { try { const response = await fetch(url); const blob = await response.blob(); return new Promise((resolve) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result as string); reader.onerror = () => resolve(null); reader.readAsDataURL(blob); }); } catch { return null; } }; const attachmentsCtx = useMemo( () => ({ files: files.map((item) => ({ ...item, id: item.id })), add, remove, clear: clearAttachments, openFileDialog, fileInputRef: inputRef, }), [files, add, remove, clearAttachments, openFileDialog] ); const refsCtx = useMemo( () => ({ sources: referencedSources, add: (incoming: SourceDocumentUIPart[] | SourceDocumentUIPart) => { const array = Array.isArray(incoming) ? incoming : [incoming]; setReferencedSources((prev) => prev.concat(array.map((s) => ({ ...s, id: nanoid() })))); }, remove: (id: string) => { setReferencedSources((prev) => prev.filter((s) => s.id !== id)); }, clear: clearReferencedSources, }), [referencedSources, clearReferencedSources] ); const handleSubmit: FormEventHandler = (event) => { event.preventDefault(); event.stopPropagation(); const form = event.currentTarget; const text = usingProvider ? controller.textInput.value : (() => { const formData = new FormData(form); return (formData.get('message') as string) || ''; })(); // Reset form immediately after capturing text to avoid race condition // where user input during async blob conversion would be lost if (!usingProvider) { form.reset(); } // Convert blob URLs to data URLs asynchronously Promise.all( files.map(async ({ id, ...item }) => { if (item.url?.startsWith('blob:')) { const dataUrl = await convertBlobUrlToDataUrl(item.url); // If conversion failed, keep the original blob URL return { ...item, url: dataUrl ?? item.url, }; } return item; }) ) .then((convertedFiles: FileUIPart[]) => { try { const result = onSubmit({ text, files: convertedFiles }, event); // Handle both sync and async onSubmit if (result instanceof Promise) { result .then(() => { clear(); if (usingProvider) { controller.textInput.clear(); } }) .catch(() => { // Don't clear on error - user may want to retry }); } else { // Sync function completed without throwing, clear inputs clear(); if (usingProvider) { controller.textInput.clear(); } } } catch { // Don't clear on error - user may want to retry } }) .catch(() => { // Don't clear on error - user may want to retry }); }; // Render with or without local provider const inner = ( <>
{children}
); const withReferencedSources = ( {inner} ); // Always provide LocalAttachmentsContext so children get validated add function return ( {withReferencedSources} ); }; export type PromptInputBodyProps = HTMLAttributes; export const PromptInputBody = ({ className, ...props }: PromptInputBodyProps) => (
); export type PromptInputTextareaProps = ComponentProps; export const PromptInputTextarea = ({ onChange, onKeyDown, className, placeholder = 'What would you like to know?', ...props }: PromptInputTextareaProps) => { const controller = useOptionalPromptInputController(); const attachments = usePromptInputAttachments(); const [isComposing, setIsComposing] = useState(false); const handleKeyDown: KeyboardEventHandler = (e) => { // Call the external onKeyDown handler first onKeyDown?.(e); // If the external handler prevented default, don't run internal logic if (e.defaultPrevented) { return; } if (e.key === 'Enter') { if (isComposing || e.nativeEvent.isComposing) { return; } if (e.shiftKey) { return; } e.preventDefault(); // Check if the submit button is disabled before submitting const form = e.currentTarget.form; const submitButton = form?.querySelector('button[type="submit"]') as HTMLButtonElement | null; if (submitButton?.disabled) { return; } form?.requestSubmit(); } // Remove last attachment when Backspace is pressed and textarea is empty if (e.key === 'Backspace' && e.currentTarget.value === '' && attachments.files.length > 0) { e.preventDefault(); const lastAttachment = attachments.files.at(-1); if (lastAttachment) { attachments.remove(lastAttachment.id); } } }; const handlePaste: ClipboardEventHandler = (event) => { const items = event.clipboardData?.items; if (!items) { return; } const files: File[] = []; for (const item of items) { if (item.kind === 'file') { const file = item.getAsFile(); if (file) { files.push(file); } } } if (files.length > 0) { event.preventDefault(); attachments.add(files); } }; const controlledProps = controller ? { value: controller.textInput.value, onChange: (e: ChangeEvent) => { controller.textInput.setInput(e.currentTarget.value); onChange?.(e); }, } : { onChange, }; return ( setIsComposing(false)} onCompositionStart={() => setIsComposing(true)} onKeyDown={handleKeyDown} onPaste={handlePaste} placeholder={placeholder} resize={false} {...props} {...controlledProps} /> ); }; export type PromptInputHeaderProps = Omit, 'align'>; export const PromptInputHeader = ({ className, ...props }: PromptInputHeaderProps) => ( ); export type PromptInputFooterProps = Omit, 'align'>; export const PromptInputFooter = ({ className, ...props }: PromptInputFooterProps) => ( ); export type PromptInputToolsProps = HTMLAttributes; export const PromptInputTools = ({ className, ...props }: PromptInputToolsProps) => (
); export type PromptInputButtonProps = ComponentProps; export const PromptInputButton = ({ variant = 'primary', mode = 'ghost', className, size, ...props }: PromptInputButtonProps) => { const newSize = size ?? (Children.count(props.children) > 1 ? 'sm' : 'icon-sm'); return ( ); }; export type PromptInputActionMenuProps = ComponentProps; export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => ; export type PromptInputActionMenuTriggerProps = PromptInputButtonProps; export const PromptInputActionMenuTrigger = ({ className, children, ...props }: PromptInputActionMenuTriggerProps) => ( {children ?? } ); export type PromptInputActionMenuContentProps = ComponentProps; export const PromptInputActionMenuContent = ({ className, ...props }: PromptInputActionMenuContentProps) => ( ); export type PromptInputActionMenuItemProps = ComponentProps; export const PromptInputActionMenuItem = ({ className, ...props }: PromptInputActionMenuItemProps) => ( ); // Note: Actions that perform side-effects (like opening a file dialog) // are provided in opt-in modules (e.g., prompt-input-attachments). export type PromptInputSubmitProps = ComponentProps & { status?: ChatStatus; onStop?: () => void; }; export const PromptInputSubmit = ({ className, variant = 'secondary', size = 'icon-xs', status, onStop, onClick, children, mode = 'filled', ...props }: PromptInputSubmitProps) => { const isGenerating = status === 'submitted' || status === 'streaming'; let Icon = ; if (status === 'submitted') { Icon = ; } else if (status === 'streaming') { Icon = ; } else if (status === 'error') { Icon = ; } const handleClick = (e: React.MouseEvent) => { if (isGenerating && onStop) { e.preventDefault(); onStop(); return; } onClick?.(e); }; return ( {children ?? Icon} ); }; export type PromptInputSelectProps = ComponentProps; export const PromptInputSelect = (props: PromptInputSelectProps) => { e.preventDefault(); e.stopPropagation(); }} > {options.map(({ label, value }) => ( {label} ))} ); }; const AmountInput = ({ fields, options, defaultOption, className, placeholder, isReadOnly, onValueChange, size = 'sm', min, showError = true, shouldUnregister = false, dataTestId, }: InputWithSelectProps) => { const { getFieldState, setValue, control } = useFormContext(); const input = getFieldState(`${fields.inputKey}`); const select = getFieldState(`${fields.selectKey}`); const error = input.error || select.error; return ( <> ( { onValueChange?.(); }} min={min} dataTestId={dataTestId} /> )} /> ( { setValue(fields.selectKey, value, { shouldDirty: true }); onValueChange?.(); }} /> )} /> {/* TODO: Use instead, see how we did it in */} {showError && error && {String(error?.message || '')}} ); }; export { AmountInput, AmountInputContainer, AmountInputField, AmountUnitSelect }; ================================================ FILE: apps/dashboard/src/components/analytics/charts/active-subscribers-tooltip.tsx ================================================ import type { TooltipProps } from 'recharts'; import { NovuTooltip } from '../../primitives/chart'; const ACTIVE_SUBSCRIBERS_COLOR = '#818cf8'; export function ActiveSubscribersTooltip(props: TooltipProps) { const { active, payload, label } = props; if (!active || !payload?.length) { return null; } const value = payload[0]?.value ?? 0; const rows = [ { key: 'active-subscribers', label: 'Active Subscribers', value: Number(value), color: ACTIVE_SUBSCRIBERS_COLOR, }, ]; return ; } ================================================ FILE: apps/dashboard/src/components/analytics/charts/active-subscribers-trend-chart.tsx ================================================ import { useCallback, useId, useMemo } from 'react'; import { Area, ComposedChart, Line, XAxis } from 'recharts'; import { type ActiveSubscribersTrendDataPoint } from '../../../api/activity'; import { ChartConfig, ChartContainer, ChartTooltip } from '../../primitives/chart'; import { ANALYTICS_TOOLTIPS } from '../constants/analytics-tooltips'; import { createDateBasedHasDataChecker } from '../utils/chart-validation'; import { ActiveSubscribersTooltip } from './active-subscribers-tooltip'; import { generateDummyActiveSubscribersData } from './chart-dummy-data'; import { type ActiveSubscribersChartData } from './chart-types'; import { ChartWrapper } from './chart-wrapper'; import { FlickeringGrid } from './flickering-grid'; const chartConfig = { count: { label: 'Active subscribers', color: '#818cf8', }, } satisfies ChartConfig; type CustomTickProps = { x?: number; y?: number; payload?: { value: string }; index?: number; visibleTicksCount?: number; }; function CustomTick({ x, y, payload, index, visibleTicksCount }: CustomTickProps) { const isFirst = index === 0; const isLast = visibleTicksCount !== undefined && index === visibleTicksCount - 1; let anchor: 'start' | 'middle' | 'end' = 'middle'; if (isFirst) anchor = 'start'; else if (isLast) anchor = 'end'; return ( {payload?.value} ); } type ActiveSubscribersTrendChartProps = { data?: ActiveSubscribersTrendDataPoint[]; isLoading?: boolean; error?: Error | null; }; export function ActiveSubscribersTrendChart({ data, isLoading, error }: ActiveSubscribersTrendChartProps) { const gradientId = useId().replace(/:/g, ''); const chartData = useMemo(() => { return data?.map((dataPoint) => ({ date: new Date(dataPoint.timestamp).toLocaleDateString('en-US', { month: 'short', day: 'numeric', }), count: dataPoint.count, timestamp: dataPoint.timestamp, })); }, [data]); const hasDataChecker = useCallback( createDateBasedHasDataChecker((dataPoint: ActiveSubscribersChartData) => { return (dataPoint.count || 0) > 0; }), [] ); const renderChart = useCallback( (chartDataToRender: ActiveSubscribersChartData[], includeTooltip = true) => { const areaClipData = chartDataToRender.map((d) => ({ total: d.count })); return (
} interval={Math.max(0, Math.floor(chartDataToRender.length / 3) - 1)} padding={{ left: 8, right: 8 }} /> {includeTooltip && } />}
); }, [gradientId] ); const renderEmptyState = useCallback( (dummyData: ActiveSubscribersChartData[]) => { return renderChart(dummyData, false); }, [renderChart] ); return ( {renderChart} ); } ================================================ FILE: apps/dashboard/src/components/analytics/charts/chart-dummy-data.tsx ================================================ import { type ActiveSubscribersChartData, type DeliveryChartData, type InteractionChartData, type ProviderChartData, type WorkflowChartData, type WorkflowRunsChartData, } from './chart-types'; export function generateDummyDeliveryData(): DeliveryChartData[] { const today = new Date(); const dummyData = []; for (let i = 11; i >= 0; i--) { const date = new Date(today); date.setDate(date.getDate() - i); dummyData.push({ date: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', }), email: Math.floor(Math.random() * 150) + 50, push: Math.floor(Math.random() * 100) + 30, sms: Math.floor(Math.random() * 80) + 20, inApp: Math.floor(Math.random() * 120) + 40, chat: Math.floor(Math.random() * 60) + 10, timestamp: date.toISOString(), }); } return dummyData; } export function generateDummyWorkflowData(): WorkflowChartData[] { const workflows = ['Welcome Email', 'Order Confirmation', 'Password Reset', 'Weekly Newsletter', 'Abandoned Cart']; return workflows.map((workflow, index) => ({ workflowName: workflow, count: Math.floor(Math.random() * 1000) + 200, displayName: workflow, fill: ['#8b5cf6', '#06b6d4', '#facc15', '#f97316', '#ef4444'][index], })); } export function generateDummyProviderData(): ProviderChartData[] { const providers = ['sendgrid', 'twilio', 'mailgun', 'fcm', 'slack']; return providers.map((provider, index) => ({ providerId: provider, count: Math.floor(Math.random() * 800) + 150, displayName: provider.charAt(0).toUpperCase() + provider.slice(1), fill: ['#8b5cf6', '#06b6d4', '#facc15', '#f97316', '#ef4444'][index], })); } export function generateDummyInteractionData(): InteractionChartData[] { const today = new Date(); const dummyData = []; for (let i = 14; i >= 0; i--) { const date = new Date(today); date.setDate(date.getDate() - i); const seen = Math.floor(Math.random() * 200) + 100; const read = Math.floor(seen * 0.6) + Math.floor(Math.random() * 15); const snoozed = Math.floor(read * 0.1) + Math.floor(Math.random() * 5); const archived = Math.floor(read * 0.05) + Math.floor(Math.random() * 3); dummyData.push({ date: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', }), messageSeen: seen, messageRead: read, messageSnoozed: snoozed, messageArchived: archived, timestamp: date.toISOString(), }); } return dummyData; } export function generateDummyWorkflowRunsData(): WorkflowRunsChartData[] { const today = new Date(); const dummyData = []; for (let i = 14; i >= 0; i--) { const date = new Date(today); date.setDate(date.getDate() - i); const completed = Math.floor(Math.random() * 300) + 100; const processing = Math.floor(Math.random() * 50) + 10; const error = Math.floor(Math.random() * 30) + 5; dummyData.push({ date: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', }), completed, processing, error, timestamp: date.toISOString(), }); } return dummyData; } export function generateDummyActiveSubscribersData(): ActiveSubscribersChartData[] { const today = new Date(); const dummyData = []; for (let i = 14; i >= 0; i--) { const date = new Date(today); date.setDate(date.getDate() - i); const count = Math.floor(Math.random() * 500) + 100; dummyData.push({ date: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', }), count, timestamp: date.toISOString(), }); } return dummyData; } ================================================ FILE: apps/dashboard/src/components/analytics/charts/chart-empty-state.tsx ================================================ import { ReactNode } from 'react'; import { HelpTooltipIndicator } from '../../primitives/help-tooltip-indicator'; type ChartEmptyStateProps = { title?: string; children: ReactNode; tooltip?: React.ReactNode; }; export function ChartEmptyState({ title = 'Not enough data to show', children, tooltip }: ChartEmptyStateProps) { return (
{children}

{title}

{tooltip && }
); } ================================================ FILE: apps/dashboard/src/components/analytics/charts/chart-types.ts ================================================ export type DeliveryChartData = { date: string; email: number; push: number; sms: number; inApp: number; chat: number; timestamp: string; }; export type WorkflowChartData = { workflowName: string; count: number; displayName: string; fill: string; }; export type ProviderChartData = { providerId: string; count: number; displayName: string; fill: string; }; export type InteractionChartData = { date: string; messageSeen: number; messageRead: number; messageSnoozed: number; messageArchived: number; timestamp: string; }; export type WorkflowRunsChartData = { date: string; processing?: number; completed: number; error: number; timestamp: string; }; export type ActiveSubscribersChartData = { date: string; count: number; timestamp: string; }; ================================================ FILE: apps/dashboard/src/components/analytics/charts/chart-wrapper.tsx ================================================ import { ReactNode, useMemo } from 'react'; import { FlickeringGridPlaceholder } from '../components/flickering-grid-placeholder'; import { Card, CardContent, CardHeader, CardTitle } from '../../primitives/card'; import { HelpTooltipIndicator } from '../../primitives/help-tooltip-indicator'; import { ChartEmptyState } from './chart-empty-state'; type ChartDataPoint = Record; type ChartWrapperProps = { title: string; data?: T[]; isLoading?: boolean; error?: Error | null; hasDataChecker: (data: T[]) => boolean; loadingSkeleton?: ReactNode; dummyDataGenerator: () => T[]; children: (data: T[]) => ReactNode; emptyStateRenderer: (dummyData: T[]) => ReactNode; errorMessage?: string; infoTooltip?: React.ReactNode; emptyStateTitle?: string; emptyStateTooltip?: React.ReactNode; count?: number; countLabel?: string; periodLabel?: string; headerExtra?: ReactNode; footer?: ReactNode; contentMinHeight?: number; }; export function ChartWrapper({ title, data, isLoading, error, hasDataChecker, loadingSkeleton, dummyDataGenerator, children, emptyStateRenderer, errorMessage = 'Failed to load chart data', infoTooltip, emptyStateTitle, emptyStateTooltip, count, countLabel = 'runs', periodLabel, headerExtra, footer, contentMinHeight = 80, }: ChartWrapperProps) { const hasData = useMemo(() => { if (!data || data.length === 0) return false; return hasDataChecker(data); }, [data, hasDataChecker]); const dummyData = useMemo(() => dummyDataGenerator(), [dummyDataGenerator]); const showCountBlock = count !== undefined && periodLabel !== undefined; return (
{title} {infoTooltip && }
{showCountBlock && (
{count.toLocaleString()} {countLabel}
)} {headerExtra}
{isLoading ? ( loadingSkeleton ?? ( ) ) : error ? (
{errorMessage}
) : !hasData ? ( {emptyStateRenderer(dummyData)} ) : ( data && children(data) )}
{footer}
); } ================================================ FILE: apps/dashboard/src/components/analytics/charts/delivery-trends-chart.tsx ================================================ import { StepTypeEnum } from '@novu/shared'; import { useCallback, useMemo, useState } from 'react'; import { Bar, BarChart, CartesianGrid, XAxis } from 'recharts'; import { type ChartDataPoint } from '../../../api/activity'; import { STEP_TYPE_TO_ICON } from '../../icons/utils'; import { ChartConfig, ChartContainer, ChartTooltip, NovuTooltip } from '../../primitives/chart'; import { ANALYTICS_TOOLTIPS } from '../constants/analytics-tooltips'; import { createDateBasedHasDataChecker } from '../utils/chart-validation'; import { generateDummyDeliveryData } from './chart-dummy-data'; import { type DeliveryChartData } from './chart-types'; import { ChartWrapper } from './chart-wrapper'; const SEGMENT_GAP = 2; const chartConfig = { email: { label: 'Email', color: '#818cf8' }, push: { label: 'Push', color: '#22d3ee' }, chat: { label: 'Chat', color: '#34d399' }, sms: { label: 'SMS', color: '#fbbf24' }, inApp: { label: 'In-App', color: '#fb923c' }, } satisfies ChartConfig; const STEP_TYPE_BY_KEY: Record = { email: StepTypeEnum.EMAIL, push: StepTypeEnum.PUSH, chat: StepTypeEnum.CHAT, sms: StepTypeEnum.SMS, inApp: StepTypeEnum.IN_APP, }; const CHANNELS = (Object.keys(chartConfig) as (keyof typeof chartConfig)[]).map((key) => ({ key, label: chartConfig[key].label, color: chartConfig[key].color, icon: STEP_TYPE_TO_ICON[STEP_TYPE_BY_KEY[key]], })); type DeliveryTickProps = { x?: number; y?: number; payload?: { value: string }; index?: number; }; function DeliveryTick({ x, y, payload, index }: DeliveryTickProps) { const anchor = index === 0 ? 'start' : 'middle'; return ( {payload?.value} ); } type DeliveryTooltipProps = { active?: boolean; payload?: Array<{ dataKey?: string; name?: string; value?: number; color?: string; payload?: { email?: number; push?: number; sms?: number; inApp?: number; chat?: number; date?: string; timestamp?: string; }; }>; label?: string; }; function DeliveryTooltip(props: DeliveryTooltipProps) { const data = props.payload?.[0]?.payload; const channels = CHANNELS.map((ch) => ({ key: ch.key, label: ch.label, value: Number(data?.[ch.key as keyof typeof data]) || 0, color: ch.color, icon: ch.icon, })); return ; } type DeliveryTrendsChartProps = { data?: ChartDataPoint[]; isLoading?: boolean; error?: Error | null; }; type ChartContentProps = { data: DeliveryChartData[]; includeTooltip?: boolean; }; const BAR_RADIUS = 2; type StackedBarSegmentShapeProps = { x?: number; y?: number; width?: number; height?: number; fill?: string; segmentIndex: number; totalSegments: number; }; function StackedBarSegmentShape(props: StackedBarSegmentShapeProps) { const { x = 0, y = 0, width = 0, height = 0, fill, segmentIndex, totalSegments } = props; if (height <= 0) return null; let offsetY = 0; let segmentHeight = height; if (totalSegments > 1) { if (segmentIndex === 0) { offsetY = SEGMENT_GAP / 2; segmentHeight = height - SEGMENT_GAP / 2; } else if (segmentIndex === totalSegments - 1) { segmentHeight = height - SEGMENT_GAP / 2; } else { offsetY = SEGMENT_GAP / 2; segmentHeight = height - SEGMENT_GAP; } } return ( ); } function createStackedBarShape(segmentIndex: number, totalSegments: number) { return (props: Omit) => ( ); } function ChartContent({ data, includeTooltip = true }: ChartContentProps) { const [hiddenChannels] = useState>(new Set()); const dataLength = data.length; // Tick interval based on data length const tickInterval = useMemo(() => { if (dataLength <= 4) return 0; if (dataLength <= 7) return 1; if (dataLength <= 14) return 3; if (dataLength <= 21) return 4; return Math.floor(dataLength / 5); }, [dataLength]); // Dynamic bar size const barSize = useMemo(() => { if (dataLength <= 7) return 24; if (dataLength <= 14) return 16; if (dataLength <= 21) return 12; return undefined; }, [dataLength]); const visibleChannels = CHANNELS.filter((ch) => !hiddenChannels.has(ch.key)); return (
} interval={tickInterval} padding={{ left: 2, right: 2 }} /> {includeTooltip && } />} {visibleChannels.map((channel, idx) => ( ))}
); } export function DeliveryTrendsChart({ data, isLoading }: DeliveryTrendsChartProps) { const chartData = useMemo(() => { return data?.map((dataPoint) => ({ date: new Date(dataPoint.timestamp).toLocaleDateString('en-US', { month: 'short', day: 'numeric', }), email: dataPoint.email, push: dataPoint.push, sms: dataPoint.sms, inApp: dataPoint.inApp, chat: dataPoint.chat, timestamp: dataPoint.timestamp, })); }, [data]); const hasDataChecker = useCallback( createDateBasedHasDataChecker((dataPoint: DeliveryChartData) => { return ( (dataPoint.email || 0) > 0 || (dataPoint.push || 0) > 0 || (dataPoint.sms || 0) > 0 || (dataPoint.inApp || 0) > 0 || (dataPoint.chat || 0) > 0 ); }), [] ); return ( } infoTooltip={ANALYTICS_TOOLTIPS.DELIVERY_TREND} emptyStateTitle="Not enough data to show" emptyStateTooltip={ANALYTICS_TOOLTIPS.INSUFFICIENT_DATE_RANGE} > {(data) => } ); } ================================================ FILE: apps/dashboard/src/components/analytics/charts/flickering-grid.tsx ================================================ "use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; export type AreaClipData = { total: number; }[]; type FlickeringGridProps = { squareSize?: number; gridGap?: number; color?: string; width?: number; height?: number; className?: string; maxOpacity?: number; minOpacity?: number; areaClip?: { data: AreaClipData; margin?: { top?: number; right?: number; bottom?: number; left?: number }; }; }; const DEFAULT_MARGIN = { top: 4, right: 2, bottom: 0, left: 2 }; const CLIP_SAFETY_PX = 1; const OPACITY_LEVELS = 4; const UPDATES_PER_FRAME = 2; const UPDATE_EVERY_N_FRAMES = 16; const TARGET_FPS = 24; const BITS_16 = 0xffff; const FRAME_INTERVAL_MS = 1000 / TARGET_FPS; const ANIMATION_LEVEL_MIN = 1; const ANIMATION_LEVEL_MAX = 2; function getLineYAtPixel( px: number, w: number, h: number, data: AreaClipData, margin: { top: number; right: number; bottom: number; left: number } ): number { if (!data.length) return h; let maxTotal = 0; for (let i = 0; i < data.length; i++) { const t = data[i].total; if (t > maxTotal) maxTotal = t; } if (maxTotal === 0) maxTotal = 1; const innerW = w - margin.left - margin.right; const innerH = h - margin.top - margin.bottom; if (innerW <= 0 || innerH <= 0) return h; const dataIndex = Math.max(0, Math.min(((px - margin.left) / innerW) * (data.length - 1), data.length - 1)); const i0 = Math.floor(dataIndex); const i1 = Math.min(i0 + 1, data.length - 1); const t = dataIndex - i0; const total = data[i0].total + t * (data[i1].total - data[i0].total); return margin.top + innerH * (1 - total / maxTotal); } const FLICKER_CHANCE = 0.04; export function FlickeringGrid({ squareSize = 0.8, gridGap = 4, color = "currentColor", width, height, className = "", maxOpacity = 0.18, minOpacity = 0.12, areaClip, }: FlickeringGridProps) { const canvasRef = useRef(null); const containerRef = useRef(null); const [isInView, setIsInView] = useState(false); const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 }); const margin = useMemo( () => areaClip ? { ...DEFAULT_MARGIN, ...areaClip.margin } : null, [areaClip] ); const clipDataSignature = useMemo( () => areaClip?.data ? areaClip.data .map((d) => d.total) .join(",") : null, [areaClip?.data] ); const getColorRgbaPrefix = useCallback( (containerElement: HTMLElement | null) => { if (typeof window === "undefined") return "rgba(128,128,128,"; const colorToResolve = (containerElement && getComputedStyle(containerElement).color) || color; const canvas = document.createElement("canvas"); canvas.width = canvas.height = 1; const ctx = canvas.getContext("2d"); if (!ctx) return "rgba(128,128,128,"; ctx.fillStyle = colorToResolve; ctx.fillRect(0, 0, 1, 1); const [r, g, b] = Array.from(ctx.getImageData(0, 0, 1, 1).data); return `rgba(${r},${g},${b},`; }, [color] ); const setupCanvas = useCallback( (canvas: HTMLCanvasElement, w: number, h: number) => { const dpr = window.devicePixelRatio || 1; canvas.width = w * dpr; canvas.height = h * dpr; canvas.style.width = `${w}px`; canvas.style.height = `${h}px`; const cols = Math.floor(w / (squareSize + gridGap)); const rows = Math.floor(h / (squareSize + gridGap)); const squares = new Uint8Array(cols * rows); for (let i = 0; i < squares.length; i++) { squares[i] = ANIMATION_LEVEL_MIN + Math.floor(Math.random() * (ANIMATION_LEVEL_MAX - ANIMATION_LEVEL_MIN + 1)); } return { cols, rows, squares, dpr }; }, [squareSize, gridGap] ); const updateSquares = useCallback( (squares: Uint8Array, visiblePacked: Uint32Array, rows: number) => { const len = visiblePacked.length; if (len === 0) return; const range = ANIMATION_LEVEL_MAX - ANIMATION_LEVEL_MIN + 1; for (let k = 0; k < UPDATES_PER_FRAME; k++) { if (Math.random() >= FLICKER_CHANCE) continue; const pick = (Math.random() * len) | 0; const pack = visiblePacked[pick]; if (pack === undefined) continue; const idx = (pack >> 16) * rows + (pack & BITS_16); squares[idx] = ANIMATION_LEVEL_MIN + ((Math.random() * range) | 0); } }, [] ); useEffect(() => { const canvas = canvasRef.current; const container = containerRef.current; const clipData = areaClip?.data; if (!canvas || !container) return; const ctx = canvas.getContext("2d"); if (!ctx) return; const rgbaPrefix = getColorRgbaPrefix(containerRef.current); const range = Math.max(0, maxOpacity - minOpacity); const fillStyles: string[] = []; for (let l = 0; l < OPACITY_LEVELS; l++) { const o = minOpacity + (range * (l + 0.5)) / OPACITY_LEVELS; fillStyles.push(`${rgbaPrefix}${o.toFixed(2)})`); } type GridParams = ReturnType & { lineBoundary?: Float32Array; visiblePacked: Uint32Array; }; let animationFrameId: number | undefined; let gridParams: GridParams | undefined; const marginVal = margin; let lastDrawTime = 0; let frameCount = 0; const updateMask = UPDATE_EVERY_N_FRAMES - 1; const animate = (time: number) => { if (!isInView || !gridParams) return; if (time - lastDrawTime < FRAME_INTERVAL_MS) { animationFrameId = requestAnimationFrame(animate); return; } lastDrawTime = time; frameCount += 1; if ((frameCount & updateMask) === 0) { updateSquares(gridParams.squares, gridParams.visiblePacked, gridParams.rows); } const sq = gridParams.squares; const vis = gridParams.visiblePacked; const r = gridParams.rows; const cw = (squareSize + gridGap) * gridParams.dpr; const ch = cw; const sz = squareSize * gridParams.dpr; const fill = fillStyles; const n = vis.length; ctx.clearRect(0, 0, canvas.width, canvas.height); let curStyle = -1; for (let k = 0; k < n; k++) { const pack = vis[k]; if (pack === undefined) continue; const i = pack >> 16; const j = pack & BITS_16; const idx = i * r + j; const level = sq[idx] ?? 0; if (level !== curStyle) { ctx.fillStyle = fill[level] ?? fill[0]; curStyle = level; } ctx.fillRect(i * cw, j * ch, sz, sz); } animationFrameId = requestAnimationFrame(animate); }; const updateCanvasSize = () => { const newWidth = width ?? container.clientWidth; const newHeight = height ?? container.clientHeight; setCanvasSize({ width: newWidth, height: newHeight }); const params = setupCanvas(canvas, newWidth, newHeight) as GridParams; const dpr = params.dpr; const cols = params.cols; const rows = params.rows; const cellH = (squareSize + gridGap) * dpr; const total = cols * rows; const packed: number[] = []; if (clipData && marginVal && newWidth > 0 && newHeight > 0) { const safetyCanvas = CLIP_SAFETY_PX * dpr; params.lineBoundary = new Float32Array(cols); for (let i = 0; i < cols; i++) { const pxCss = i * (squareSize + gridGap) + squareSize / 2; const lineYCss = getLineYAtPixel(pxCss, newWidth, newHeight, clipData, marginVal); params.lineBoundary[i] = lineYCss * dpr + safetyCanvas; } for (let i = 0; i < cols; i++) { const topBound = params.lineBoundary[i] ?? 0; for (let j = 0; j < rows; j++) { if (j * cellH >= topBound) packed.push((i << 16) | j); } } } else { for (let idx = 0; idx < total; idx++) { const i = (idx / rows) | 0; packed.push((i << 16) | (idx - i * rows)); } } params.visiblePacked = new Uint32Array(packed); gridParams = params; lastDrawTime = performance.now(); if (isInView) { animationFrameId = requestAnimationFrame(animate); } }; updateCanvasSize(); const resizeObserver = new ResizeObserver(updateCanvasSize); resizeObserver.observe(container); const intersectionObserver = new IntersectionObserver( ([entry]) => setIsInView(entry.isIntersecting), { threshold: 0 } ); intersectionObserver.observe(canvas); return () => { if (typeof animationFrameId === 'number') cancelAnimationFrame(animationFrameId); resizeObserver.disconnect(); intersectionObserver.disconnect(); }; }, [ setupCanvas, updateSquares, getColorRgbaPrefix, width, height, isInView, squareSize, gridGap, maxOpacity, minOpacity, clipDataSignature, margin, ]); return (
); } ================================================ FILE: apps/dashboard/src/components/analytics/charts/interaction-trend-chart.tsx ================================================ import { Fragment, useCallback, useId, useMemo } from 'react'; import { Area, AreaChart, XAxis, YAxis } from 'recharts'; import { type InteractionTrendDataPoint } from '../../../api/activity'; import { ChartConfig, ChartContainer, ChartTooltip } from '../../primitives/chart'; import { ANALYTICS_TOOLTIPS } from '../constants/analytics-tooltips'; import { createDateBasedHasDataChecker } from '../utils/chart-validation'; import { generateDummyInteractionData } from './chart-dummy-data'; import { type InteractionChartData } from './chart-types'; import { ChartWrapper } from './chart-wrapper'; const chartConfig = { messageSeen: { label: 'Seen', color: '#818cf8' }, messageRead: { label: 'Read', color: '#34d399' }, messageSnoozed: { label: 'Snoozed', color: '#f472b6' }, messageArchived: { label: 'Archived', color: '#fb923c' }, } satisfies ChartConfig; const FUNNEL = (['messageSeen', 'messageRead', 'messageSnoozed', 'messageArchived'] as const).map((key) => ({ key, label: chartConfig[key].label, color: chartConfig[key].color, })); type InteractionTooltipProps = { active?: boolean; payload?: Array<{ dataKey?: string; name?: string; value?: number; color?: string; payload?: InteractionChartData; }>; label?: string; }; function InteractionTrendTooltip({ active, payload, label }: InteractionTooltipProps) { if (!active || !payload?.length) return null; const data = payload[0]?.payload as InteractionChartData | undefined; if (!data) return null; const seen = Number(data.messageSeen) || 0; const total = seen + (Number(data.messageRead) || 0) + (Number(data.messageSnoozed) || 0) + (Number(data.messageArchived) || 0); const rows = FUNNEL.map(({ key, label: rowLabel, color }) => { const value = Number(data[key as keyof InteractionChartData]) || 0; const pctOfSeen = key !== 'messageSeen' && seen > 0 ? Math.round((value / seen) * 100) : null; return { key, label: rowLabel, value, color, pctOfSeen }; }); return (

{label}

{rows.map((row) => (

{row.label}

{row.pctOfSeen !== null ? ( ({row.pctOfSeen}% of seen) ) : null}
{row.value.toLocaleString()}
))}

Total

{total.toLocaleString()}
); } type CustomTickProps = { x?: number; y?: number; payload?: { value: string }; index?: number; }; function CustomTick({ x, y, payload, index }: CustomTickProps) { const anchor = index === 0 ? 'start' : 'end'; return ( {payload?.value} ); } type InteractionTrendChartProps = { data?: InteractionTrendDataPoint[]; isLoading?: boolean; error?: Error | null; }; type InteractionTrendChartContentProps = { data: InteractionChartData[]; includeTooltip: boolean; }; function InteractionTrendChartContent({ data, includeTooltip }: InteractionTrendChartContentProps) { const baseId = useId(); const gradientSeenId = `${baseId}-gradientSeen`; const gradientReadId = `${baseId}-gradientRead`; const gradientSnoozedId = `${baseId}-gradientSnoozed`; const gradientArchivedId = `${baseId}-gradientArchived`; const colors = { seen: { stroke: chartConfig.messageSeen.color, fill: `url(#${gradientSeenId})` }, read: { stroke: chartConfig.messageRead.color, fill: `url(#${gradientReadId})` }, snoozed: { stroke: chartConfig.messageSnoozed.color, fill: `url(#${gradientSnoozedId})` }, archived: { stroke: chartConfig.messageArchived.color, fill: `url(#${gradientArchivedId})` }, }; // Use second point as first tick so the axis excludes the leading/padding point at data[0] const firstDate = data[1]?.date || ''; const lastDate = data[data.length - 1]?.date || ''; return (
} ticks={[firstDate, lastDate]} domain={['dataMin', 'dataMax']} padding={{ left: 4, right: 0 }} /> {includeTooltip && } />}
); } export function InteractionTrendChart({ data, isLoading, error }: InteractionTrendChartProps) { const chartData = useMemo(() => { return data?.map((dataPoint) => ({ date: new Date(dataPoint.timestamp).toLocaleDateString('en-US', { month: 'short', day: 'numeric', }), messageSeen: dataPoint.messageSeen, messageRead: dataPoint.messageRead, messageSnoozed: dataPoint.messageSnoozed, messageArchived: dataPoint.messageArchived, timestamp: dataPoint.timestamp, })); }, [data]); const hasDataChecker = useCallback( createDateBasedHasDataChecker((dataPoint: InteractionChartData) => { return ( (dataPoint.messageSeen || 0) > 0 || (dataPoint.messageRead || 0) > 0 || (dataPoint.messageSnoozed || 0) > 0 || (dataPoint.messageArchived || 0) > 0 ); }), [] ); return ( } infoTooltip={ANALYTICS_TOOLTIPS.INTERACTION_TREND} emptyStateTitle="Not enough data to show" emptyStateTooltip={ANALYTICS_TOOLTIPS.INSUFFICIENT_DATE_RANGE} > {(data) => } ); } ================================================ FILE: apps/dashboard/src/components/analytics/charts/providers-by-volume.tsx ================================================ import { useCallback, useMemo } from 'react'; import { Bar, BarChart, Cell, XAxis, YAxis } from 'recharts'; import { type ProviderVolumeDataPoint } from '../../../api/activity'; import { ProviderIcon } from '../../integrations/components/provider-icon'; import { ChartConfig, ChartContainer, ChartTooltip, NovuTooltip } from '../../primitives/chart'; import { ANALYTICS_TOOLTIPS } from '../constants/analytics-tooltips'; import { createVolumeBasedHasDataChecker } from '../utils/chart-validation'; import { generateDummyProviderData } from './chart-dummy-data'; import { type ProviderChartData } from './chart-types'; import { ChartWrapper } from './chart-wrapper'; const colorPalette = ['#818cf8', '#22d3ee', '#34d399', '#fbbf24', '#fb923c']; const chartConfig = { count: { label: 'Messages sent', color: '#818cf8', }, } satisfies ChartConfig; type ProviderVolumeTooltipProps = { active?: boolean; payload?: Array<{ dataKey?: string; name?: string; value?: number; color?: string; payload?: { providerId?: string; count?: number; displayName?: string; fill?: string; }; }>; label?: string; }; function ProviderVolumeTooltip(props: ProviderVolumeTooltipProps) { const data = props.payload?.[0]?.payload; if (!data) return null; const rows = [ { key: 'count', label: 'Messages sent', value: data.count || 0, color: data.fill || '#818cf8', }, ]; return ( ); } function CustomTick({ x, y, payload }: { x: number; y: number; payload: { value: string } }) { const maxLength = 20; const formatProviderName = (name: string) => { return name.replace(/-/g, ' ').replace(/\b\w/g, (char: string) => char.toUpperCase()); }; const formattedText = payload.value === 'novu' ? 'Novu Inbox' : formatProviderName(payload.value); const text = formattedText.length > maxLength ? `${formattedText.slice(0, maxLength)}...` : formattedText; return ( {text} ); } type ProvidersByVolumeProps = { data?: ProviderVolumeDataPoint[]; isLoading?: boolean; error?: Error | null; }; export function ProvidersByVolume({ data, isLoading }: ProvidersByVolumeProps) { const formatProviderName = useCallback((name: string) => { return name.replace(/-/g, ' ').replace(/\b\w/g, (char: string) => char.toUpperCase()); }, []); const chartData = useMemo(() => { return data?.map((dataPoint, index) => { const formattedName = dataPoint.providerId === 'novu' ? 'Novu Inbox' : formatProviderName(dataPoint.providerId); return { providerId: dataPoint.providerId, count: dataPoint.count, displayName: formattedName.length > 20 ? `${formattedName.substring(0, 20)}...` : formattedName, fill: colorPalette[index % colorPalette.length], }; }); }, [data, formatProviderName]); const hasDataChecker = useCallback( createVolumeBasedHasDataChecker((dataPoint: ProviderChartData) => { return (dataPoint.count || 0) > 0; }), [] ); const calculateChartHeight = useCallback((data: ProviderChartData[]) => { const itemCount = data.length; const barHeight = 16; const gap = 10; return Math.max(itemCount * (barHeight + gap) + 20, 80); }, []); const renderChart = useCallback( (data: ProviderChartData[], includeTooltip = true) => { const chartHeight = calculateChartHeight(data); return ( {includeTooltip && } />} {data.map((entry, index) => ( ))} ); }, [calculateChartHeight] ); const renderEmptyState = useCallback( (dummyData: ProviderChartData[]) => { return renderChart(dummyData, false); }, [renderChart] ); return ( {renderChart} ); } ================================================ FILE: apps/dashboard/src/components/analytics/charts/workflow-runs-trend-chart.tsx ================================================ import { FeatureFlagsKeysEnum } from '@novu/shared'; import { ArrowRight } from 'lucide-react'; import { useCallback, useId, useMemo } from 'react'; import { Link } from 'react-router-dom'; import { Area, ComposedChart, XAxis, YAxis } from 'recharts'; import { type WorkflowRunsTrendDataPoint } from '../../../api/activity'; import { useFeatureFlag } from '../../../hooks/use-feature-flag'; import { ROUTES } from '../../../utils/routes'; import { ChartConfig, ChartContainer, ChartTooltip, NovuTooltip } from '../../primitives/chart'; import { ANALYTICS_TOOLTIPS } from '../constants/analytics-tooltips'; import { createDateBasedHasDataChecker } from '../utils/chart-validation'; import { generateDummyWorkflowRunsData } from './chart-dummy-data'; import { type WorkflowRunsChartData } from './chart-types'; import { ChartWrapper } from './chart-wrapper'; import { FlickeringGrid } from './flickering-grid'; type WorkflowRunsChartDataWithTotal = WorkflowRunsChartData & { total: number }; const CHART_HEIGHT = 180; const WORKFLOW_RUNS_GRID_CLIP_MARGIN = { left: 2, right: 2, top: 4, bottom: 0 } as const; const FLICKERING_GRID_PROPS = { squareSize: 2, gridGap: 1, maxOpacity: 0.1, color: '#34d399', } as const; const LEGACY_CHART_CONFIG = { completed: { label: 'Success', color: '#34d399' }, processing: { label: 'Pending', color: '#fbbf24' }, error: { label: 'Error', color: '#fb923c' }, } satisfies ChartConfig; const FINAL_STATUS_CHART_CONFIG = { completed: { label: 'Success', color: '#34d399' }, error: { label: 'Error', color: '#fb923c' }, } satisfies ChartConfig; const LEGACY_SERIES_KEYS = ['completed', 'processing', 'error'] as const; const FINAL_STATUS_SERIES_KEYS = ['completed', 'error'] as const; const GRADIENT_STOPS: Record = { completed: [0.22, 0.04, 0], processing: [0.12, 0.04, 0], error: [0.12, 0.04, 0], }; type CustomTickProps = { x?: number; y?: number; payload?: { value: string }; index?: number; }; function CustomTick({ x, y, payload, index }: CustomTickProps) { const anchor = index === 0 ? 'start' : 'middle'; return ( {payload?.value} ); } function BillingNudge() { return ( Track usage against your plan limits View billing ); } type WorkflowRunsTrendChartProps = { data?: WorkflowRunsTrendDataPoint[]; count?: number; periodLabel?: string; isLoading?: boolean; error?: Error | null; }; type ChartContentParams = { data: WorkflowRunsChartDataWithTotal[]; includeTooltip: boolean; config: ChartConfig; seriesKeys: readonly string[]; baseId: string; }; function renderWorkflowRunsChartContent({ data, includeTooltip, config, seriesKeys, baseId }: ChartContentParams) { return (
{seriesKeys.map((key) => { const entry = config[key as keyof typeof config]; if (!entry || !('color' in entry)) return null; const [opacityTop, opacityMid, opacityBottom] = GRADIENT_STOPS[key] ?? [0.12, 0.04, 0]; const gradientId = `${baseId}-${key}`; const midOffset = key === 'completed' ? 20 : 40; return ( ); })} } interval={Math.max(0, Math.floor(data.length / 3) - 1)} padding={{ left: 8, right: 8 }} /> {includeTooltip && } />} {seriesKeys.map((key) => { const entry = config[key as keyof typeof config]; if (!entry || !('color' in entry)) return null; const gradientId = `${baseId}-${key}`; const label = typeof entry.label === 'string' ? entry.label : String(entry.label ?? key); return ( ); })}
); } type Variant = 'legacy' | 'finalStatus'; const VARIANT_CONFIG: Record< Variant, { config: ChartConfig; seriesKeys: readonly string[]; hasDataChecker: (dataPoint: WorkflowRunsChartData) => boolean; getTotal: (d: WorkflowRunsChartData) => number; mapDataPoint: (dataPoint: WorkflowRunsTrendDataPoint) => WorkflowRunsChartDataWithTotal; } > = { legacy: { config: LEGACY_CHART_CONFIG, seriesKeys: LEGACY_SERIES_KEYS, hasDataChecker: (p) => (p.completed || 0) > 0 || (p.processing || 0) > 0 || (p.error || 0) > 0, getTotal: (d) => (d.completed ?? 0) + (d.processing ?? 0) + (d.error ?? 0), mapDataPoint: (p) => ({ date: new Date(p.timestamp).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), completed: p.completed, processing: p.processing, error: p.error, timestamp: p.timestamp, total: (p.completed ?? 0) + (p.processing ?? 0) + (p.error ?? 0), }), }, finalStatus: { config: FINAL_STATUS_CHART_CONFIG, seriesKeys: FINAL_STATUS_SERIES_KEYS, hasDataChecker: (p) => (p.completed || 0) > 0 || (p.error || 0) > 0, getTotal: (d) => (d.completed ?? 0) + (d.error ?? 0), mapDataPoint: (p) => ({ date: new Date(p.timestamp).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), completed: p.completed, error: p.error, timestamp: p.timestamp, total: (p.completed ?? 0) + (p.error ?? 0), }), }, }; function WorkflowRunsTrendChartInner({ variant, data, count, periodLabel, isLoading, error, }: WorkflowRunsTrendChartProps & { variant: Variant }) { const baseId = useId(); const { config, seriesKeys, getTotal } = VARIANT_CONFIG[variant]; const chartData = useMemo( () => data?.map((p) => VARIANT_CONFIG[variant].mapDataPoint(p)) ?? undefined, [data, variant] ); // biome-ignore lint/correctness/useExhaustiveDependencies: hasDataChecker must be recreated when variant changes const hasDataChecker = useCallback( createDateBasedHasDataChecker(VARIANT_CONFIG[variant].hasDataChecker), [variant] ); const mapToWithTotal = useCallback( (d: WorkflowRunsChartData): WorkflowRunsChartDataWithTotal => ({ ...d, total: getTotal(d) }), [getTotal] ); const renderChart = useCallback( (chartDataToRender: WorkflowRunsChartDataWithTotal[], includeTooltip = true) => renderWorkflowRunsChartContent({ data: chartDataToRender, includeTooltip, config, seriesKeys, baseId, }), [baseId, config, seriesKeys] ); const renderEmptyState = useCallback( (dummyData: WorkflowRunsChartDataWithTotal[]) => renderChart(dummyData, false), [renderChart] ); const dummyDataGenerator = useCallback(() => generateDummyWorkflowRunsData().map(mapToWithTotal), [mapToWithTotal]); return ( title="Workflow runs" data={chartData} isLoading={isLoading} error={error} hasDataChecker={hasDataChecker} dummyDataGenerator={dummyDataGenerator} emptyStateRenderer={renderEmptyState} infoTooltip={ANALYTICS_TOOLTIPS.WORKFLOW_RUNS_TREND} emptyStateTitle="Not enough data to show" emptyStateTooltip={ANALYTICS_TOOLTIPS.INSUFFICIENT_DATE_RANGE} count={count} periodLabel={periodLabel} footer={} > {renderChart} ); } export function WorkflowRunsTrendChart(props: WorkflowRunsTrendChartProps) { const isFinalStatusOnly = useFeatureFlag(FeatureFlagsKeysEnum.IS_WORKFLOW_RUN_COUNT_ENABLED); return ; } ================================================ FILE: apps/dashboard/src/components/analytics/charts/workflows-by-volume.tsx ================================================ import { useCallback, useMemo } from 'react'; import { RiRouteFill } from 'react-icons/ri'; import { Bar, BarChart, Cell, XAxis, YAxis } from 'recharts'; import { type WorkflowVolumeDataPoint } from '../../../api/activity'; import { ChartConfig, ChartContainer, ChartTooltip, NovuTooltip } from '../../primitives/chart'; import { ANALYTICS_TOOLTIPS } from '../constants/analytics-tooltips'; import { createVolumeBasedHasDataChecker } from '../utils/chart-validation'; import { generateDummyWorkflowData } from './chart-dummy-data'; import { type WorkflowChartData } from './chart-types'; import { ChartWrapper } from './chart-wrapper'; // Color palette for workflow charts const colorPalette = ['#818cf8', '#22d3ee', '#34d399', '#fbbf24', '#fb923c']; const chartConfig = { count: { label: 'Workflow runs', color: '#8b5cf6', }, } satisfies ChartConfig; type WorkflowVolumeTooltipProps = { active?: boolean; payload?: Array<{ dataKey?: string; name?: string; value?: number; color?: string; payload?: { workflowName?: string; count?: number; displayName?: string; fill?: string; }; }>; label?: string; }; function WorkflowVolumeTooltip(props: WorkflowVolumeTooltipProps) { const data = props.payload?.[0]?.payload; if (!data) return null; const rows = [ { key: 'count', label: 'Workflow runs', value: data.count || 0, color: data.fill || '#8b5cf6', }, ]; return ( ); } function CustomTick({ x, y, payload }: { x: number; y: number; payload: { value: string } }) { const maxLength = 20; const text = payload.value.length > maxLength ? `${payload.value.slice(0, maxLength)}...` : payload.value; return ( {text} ); } type WorkflowsByVolumeProps = { data?: WorkflowVolumeDataPoint[]; isLoading?: boolean; error?: Error | null; }; export function WorkflowsByVolume({ data, isLoading }: WorkflowsByVolumeProps) { const chartData = useMemo(() => { return data?.map((dataPoint, index) => ({ workflowName: dataPoint.workflowName, count: dataPoint.count, displayName: dataPoint.workflowName.length > 20 ? `${dataPoint.workflowName.substring(0, 20)}...`.replace(/\b\w/g, (char: string) => char.toUpperCase()) : dataPoint.workflowName.replace(/\b\w/g, (char: string) => char.toUpperCase()), fill: colorPalette[index % colorPalette.length], })); }, [data]); const hasDataChecker = useCallback( createVolumeBasedHasDataChecker((dataPoint: WorkflowChartData) => { return (dataPoint.count || 0) > 0; }), [] ); const barSize = 12; const calculateChartHeight = useCallback( (data: WorkflowChartData[]) => { const itemCount = data.length; const gap = 10; return Math.max(itemCount * (barSize + gap) + 20, 80); }, [] ); const renderChart = useCallback( (data: WorkflowChartData[], includeTooltip = true) => { const chartHeight = calculateChartHeight(data); return ( {includeTooltip && } />} {data.map((entry, index) => ( ))} ); }, [calculateChartHeight] ); const renderEmptyState = useCallback( (dummyData: WorkflowChartData[]) => { return renderChart(dummyData, false); }, [renderChart] ); return ( {renderChart} ); } ================================================ FILE: apps/dashboard/src/components/analytics/components/analytics-page-skeleton.tsx ================================================ import { motion } from 'motion/react'; import { useEffect, useRef } from 'react'; import { RiGroup2Fill } from 'react-icons/ri'; import { cn } from '@/utils/ui'; import { InboxBellFilled } from '../../icons/inbox-bell-filled'; import { StackedDots } from '../../icons/stacked-dots'; import { TargetArrow } from '../../icons/target-arrow'; import { Card, CardContent, CardHeader, CardTitle } from '../../primitives/card'; import { FlickeringGridPlaceholder } from './flickering-grid-placeholder'; const ROW_STAGGER = 0.32; const CARD_STAGGER = 0.1; const REVEAL_EASE = [0.22, 1, 0.36, 1] as const; const containerVariants = { hidden: { opacity: 0 }, show: { opacity: 1, transition: { staggerChildren: ROW_STAGGER, delayChildren: 0.12, }, }, }; const rowVariants = { hidden: { opacity: 0 }, show: { opacity: 1, transition: { staggerChildren: CARD_STAGGER, delayChildren: 0.06, }, }, }; const cardRevealVariants = { hidden: { opacity: 0, y: 24, scale: 0.94, }, show: { opacity: 1, y: 0, scale: 1, transition: { duration: 0.72, ease: REVEAL_EASE, }, }, }; const METRIC_CARDS = [ { title: 'Messages delivered', icon: InboxBellFilled }, { title: 'Active subscribers', icon: RiGroup2Fill }, { title: ' Interactions', icon: TargetArrow }, { title: 'Avg. Messages per subscriber', icon: StackedDots }, ] as const; function MetricCardSkeleton({ title, icon: Icon, }: { title: string; icon: React.ComponentType<{ className?: string }>; }) { return (
{title}
); } const CHART_TITLES_ROW_2 = ['Delivery trend', 'Top workflows by volume', 'Interaction trend'] as const; const CHART_TITLE_ROW_3 = 'Workflow runs'; const CHART_TITLES_ROW_4 = ['Active subscribers', 'Top providers by volume'] as const; const X_AXIS_LABELS = ['Feb 26', 'Mar 26', 'Apr 26', 'May 26', 'Jun 26'] as const; const X_AXIS_MASK_GRADIENT = 'linear-gradient(90deg, transparent 0%, rgba(0,0,0,0.12) 24%, rgba(0,0,0,0.45) 32%, black 50%, rgba(0,0,0,0.45) 68%, rgba(0,0,0,0.12) 76%, transparent 100%)'; function ChartSkeletonCard({ className, title, showGrid = true, }: { className?: string; title: string; showGrid?: boolean; }) { return (
{title}
{showGrid ? ( ) : (
)}
{X_AXIS_LABELS.map((label) => ( {label} ))}
); } const SHIMMER_DURATION_MS = 2200; function useShimmerSyncToXAxisStrips( containerRef: React.RefObject, shimmerRef: React.RefObject ) { useEffect(() => { const container = containerRef.current; const shimmerEl = shimmerRef.current; if (!container || !shimmerEl) return; let rafId: number; function update() { const c = containerRef.current; const s = shimmerRef.current; if (!c || !s) return; const animations = s.getAnimations(); const sweep = animations.length > 0 ? animations[0] : undefined; const currentTime = typeof sweep?.currentTime === 'number' ? sweep.currentTime : 0; const progress = (currentTime % SHIMMER_DURATION_MS) / SHIMMER_DURATION_MS; const containerRect = c.getBoundingClientRect(); const shimmerCenterPx = containerRect.width * (-0.25 + progress); const brightBandWidthPx = containerRect.width * 0.18; const strips = c.querySelectorAll('[data-x-axis-strip]'); for (const strip of strips) { const stripRect = strip.getBoundingClientRect(); const stripLeftInContainer = stripRect.left - containerRect.left; const maskCenter = ((shimmerCenterPx - stripLeftInContainer) / stripRect.width) * 100; const maskSizePercent = (brightBandWidthPx / stripRect.width) * 100; strip.style.setProperty('--shimmer-mask-x', String(maskCenter)); strip.style.setProperty('--shimmer-mask-size', String(maskSizePercent)); } rafId = requestAnimationFrame(update); } rafId = requestAnimationFrame(update); return () => cancelAnimationFrame(rafId); }, [containerRef, shimmerRef]); } export function AnalyticsPageSkeleton() { const containerRef = useRef(null); const shimmerRef = useRef(null); useShimmerSyncToXAxisStrips(containerRef, shimmerRef); return ( Loading analytics {METRIC_CARDS.map(({ title, icon }) => ( ))} {CHART_TITLES_ROW_2.map((title) => ( ))}
); } ================================================ FILE: apps/dashboard/src/components/analytics/components/analytics-section.tsx ================================================ import { RiGroup2Fill } from 'react-icons/ri'; import type { MetricData } from '../../../hooks/use-metric-data'; import { InboxBellFilled } from '../../icons/inbox-bell-filled'; import { StackedDots } from '../../icons/stacked-dots'; import { TargetArrow } from '../../icons/target-arrow'; import { AnalyticsCard } from '../../primitives/analytics-card'; import { ANALYTICS_TOOLTIPS } from '../constants/analytics-tooltips'; type AnalyticsSectionProps = { messagesDeliveredData: MetricData; activeSubscribersData: MetricData; avgMessagesPerSubscriberData: MetricData; totalInteractionsData: MetricData; isLoading: boolean; }; export function AnalyticsSection({ messagesDeliveredData, activeSubscribersData, avgMessagesPerSubscriberData, totalInteractionsData, isLoading, }: AnalyticsSectionProps) { return (
); } ================================================ FILE: apps/dashboard/src/components/analytics/components/analytics-upgrade-cta-icon.tsx ================================================ import { Link } from 'react-router-dom'; import { ROUTES } from '../../../utils/routes'; import { Badge } from '../../primitives/badge'; import { Tooltip, TooltipContent, TooltipPortal, TooltipTrigger } from '../../primitives/tooltip'; export function AnalyticsUpgradeCtaIcon() { return ( Upgrade Upgrade your plan to unlock extended retention periods ); } ================================================ FILE: apps/dashboard/src/components/analytics/components/charts-section.tsx ================================================ import { type ChartDataPoint, type InteractionTrendDataPoint, ReportTypeEnum, type WorkflowVolumeDataPoint, } from '../../../api/activity'; import { DeliveryTrendsChart } from '../charts/delivery-trends-chart'; import { InteractionTrendChart } from '../charts/interaction-trend-chart'; import { WorkflowsByVolume } from '../charts/workflows-by-volume'; type ChartsSectionProps = { charts: Record | undefined; isTrendsLoading: boolean; isWorkflowLoading: boolean; trendsError: Error | null; workflowError: Error | null; }; export function ChartsSection({ charts, isTrendsLoading, isWorkflowLoading, trendsError, workflowError, }: ChartsSectionProps) { return (
); } ================================================ FILE: apps/dashboard/src/components/analytics/components/flickering-grid-placeholder.tsx ================================================ import { motion } from 'motion/react'; import { cn } from '@/utils/ui'; import { FlickeringGrid } from '../charts/flickering-grid'; type FlickeringGridPlaceholderProps = { className?: string; minHeight?: number; topFadeHeight?: number; bottomFadeHeight?: number; }; export function FlickeringGridPlaceholder({ className, minHeight = 100, topFadeHeight = 24, bottomFadeHeight = 32, }: FlickeringGridPlaceholderProps) { return (
); } ================================================ FILE: apps/dashboard/src/components/analytics/constants/analytics-page.consts.ts ================================================ const CONTENT_EASE = [0.16, 1, 0.3, 1] as const; const EXIT_EASE = [0.4, 0, 1, 1] as const; export const ANIMATION_VARIANTS = { page: { hidden: { opacity: 0 }, show: { opacity: 1, transition: { staggerChildren: 0.2, delayChildren: 0.1, }, }, }, section: { hidden: { opacity: 0, y: 20 }, show: { opacity: 1, y: 0, transition: { duration: 0.5, ease: CONTENT_EASE, }, }, }, }; export const SKELETON_TO_CONTENT_TRANSITION = { skeletonExit: { opacity: 0, scale: 0.98, y: -8, transition: { duration: 0.28, ease: EXIT_EASE, }, }, contentEnter: { hidden: { opacity: 0, y: 14, }, show: { opacity: 1, y: 0, transition: { staggerChildren: 0.07, delayChildren: 0.14, ease: CONTENT_EASE, }, }, }, contentSection: { hidden: { opacity: 0, y: 14 }, show: { opacity: 1, y: 0, transition: { duration: 0.42, ease: CONTENT_EASE, }, }, }, } as const; export const CHART_CONFIG = { reportTypes: [ 'DELIVERY_TREND', 'INTERACTION_TREND', 'WORKFLOW_BY_VOLUME', 'PROVIDER_BY_VOLUME', 'MESSAGES_DELIVERED', 'ACTIVE_SUBSCRIBERS', 'AVG_MESSAGES_PER_SUBSCRIBER', 'WORKFLOW_RUNS_METRIC', 'TOTAL_INTERACTIONS', 'WORKFLOW_RUNS_TREND', 'ACTIVE_SUBSCRIBERS_TREND', ] as const, refetchInterval: 5 * 60 * 1000, // 5 minutes staleTime: 2 * 60 * 1000, // 2 minutes }; ================================================ FILE: apps/dashboard/src/components/analytics/constants/analytics-tooltips.ts ================================================ export const ANALYTICS_TOOLTIPS = { MESSAGES_DELIVERED: 'Shows the total number of messages generated across all channels (Email, SMS, Push, Chat, In-App) during the selected time period.', ACTIVE_SUBSCRIBERS: 'Displays the count of unique subscribers who have received at least one message during the selected time period.', INTERACTIONS: 'Shows total user interactions with Inbox messages:\n\n• Message seen\n• Message read\n• Message snoozed\n• Message archived\n\nCurrently tracks engagement for Inbox channel only. More channels coming soon.', AVG_MESSAGES_PER_SUBSCRIBER: 'Calculates the average number of messages sent per subscriber during the selected time period.', DELIVERY_TREND: 'Visualizes daily delivery volume breakdown by channel:\n\n• Email\n• SMS\n• Push\n• Chat\n• In-App\n\nShows trends over the selected time period.', INTERACTION_TREND: 'Shows daily interaction patterns over time for Inbox messages:\n\n• Message sent\n• Message seen\n• Message read\n• Message snoozed\n\nVisualizes user engagement trends for Inbox channel only. More channels coming soon.', TOP_WORKFLOWS_BY_VOLUME: 'Displays the workflow runs with the highest volume, showing which workflows are most actively used.', WORKFLOW_RUNS_TREND: 'Tracks workflow runs patterns over time.', ACTIVE_SUBSCRIBERS_TREND: 'Visualizes the growth or decline of your active subscriber base over the selected time period.', PROVIDERS_BY_VOLUME: 'Shows message distribution across different delivery providers (SendGrid, Twilio, Firebase, etc.) by volume.', INSUFFICIENT_DATE_RANGE: 'At least 5 days of data is required to display this chart. Continue using Novu to generate more data points.', INSUFFICIENT_ENTRIES: 'At least 2 entries with data are required to display this chart. Continue using Novu to generate more data points.', } as const; ================================================ FILE: apps/dashboard/src/components/analytics/hooks/use-analytics-page-date-filter.ts ================================================ import { ApiServiceLevelEnum, FeatureFlagsKeysEnum, FeatureNameEnum, type GetSubscriptionDto, getFeatureForTierAsNumber, } from '@novu/shared'; import { useEffect, useMemo, useState } from 'react'; import { IS_SELF_HOSTED } from '../../../config'; import { useNumericFeatureFlag } from '../../../hooks/use-feature-flag'; type OrganizationLike = { createdAt: Date }; export type DateRangeOption = { value: string; label: string; ms: number; }; export type DateFilterOption = { disabled: boolean; label: string; value: string; icon?: React.ComponentType<{ className?: string }>; disabledDueToAnalyticsLimit?: boolean; }; const HOME_PAGE_DATE_RANGE_OPTIONS: DateRangeOption[] = [ { value: '24h', label: 'Last 24 hours', ms: 24 * 60 * 60 * 1000 }, { value: '7d', label: 'Last 7 days', ms: 7 * 24 * 60 * 60 * 1000 }, { value: '30d', label: 'Last 30 days', ms: 30 * 24 * 60 * 60 * 1000 }, { value: '90d', label: 'Last 90 days', ms: 90 * 24 * 60 * 60 * 1000 }, ]; function buildDateFilterOptions({ organization, apiServiceLevel, maxDateAnalyticsMs, }: { organization: OrganizationLike; apiServiceLevel?: ApiServiceLevelEnum; maxDateAnalyticsMs?: number; }): Omit[] { const maxActivityFeedRetentionMs = getFeatureForTierAsNumber( FeatureNameEnum.PLATFORM_ACTIVITY_FEED_RETENTION, IS_SELF_HOSTED ? ApiServiceLevelEnum.UNLIMITED : apiServiceLevel || ApiServiceLevelEnum.FREE, true ); return HOME_PAGE_DATE_RANGE_OPTIONS.map((option) => { const isLegacyFreeTier = apiServiceLevel === ApiServiceLevelEnum.FREE && organization && organization.createdAt < new Date('2025-02-28'); const legacyFreeMaxRetentionMs = 30 * 24 * 60 * 60 * 1000; const maxRetentionMs = isLegacyFreeTier ? legacyFreeMaxRetentionMs : maxActivityFeedRetentionMs; // Check if the option exceeds the analytics date limit const exceedsAnalyticsLimit = Boolean( maxDateAnalyticsMs && maxDateAnalyticsMs > 0 && option.ms > maxDateAnalyticsMs ); const exceedsRetentionLimit = option.ms > maxRetentionMs; return { disabled: exceedsRetentionLimit || exceedsAnalyticsLimit, label: exceedsAnalyticsLimit && !exceedsRetentionLimit ? `${option.label} (Coming soon)` : option.label, value: option.value, disabledDueToAnalyticsLimit: exceedsAnalyticsLimit && !exceedsRetentionLimit, }; }); } function getDefaultDateRange({ subscription, organization, maxDateAnalyticsMs, }: { subscription: GetSubscriptionDto | null | undefined; organization: OrganizationLike | null | undefined; maxDateAnalyticsMs?: number; }): string { if (!organization || !subscription) { return '30d'; } const availableFilters = buildDateFilterOptions({ organization, apiServiceLevel: subscription.apiServiceLevel, maxDateAnalyticsMs, }); // Find the maximum available date range up to 30 days, excluding "Coming soon" options // Priority order: 30d -> 7d -> 24h (largest available that's not "Coming soon") const preferredOrder = ['30d', '7d', '24h']; for (const preferredValue of preferredOrder) { const option = availableFilters.find((opt) => opt.value === preferredValue); if (option && !option.disabled && !option.disabledDueToAnalyticsLimit) { return preferredValue; } } // Fallback: find any available option that's not "Coming soon" const fallbackOption = availableFilters.find((option) => !option.disabled && !option.disabledDueToAnalyticsLimit); if (fallbackOption) { return fallbackOption.value; } // Last resort: find any available option (including subscription-limited ones) const lastResortOption = availableFilters.find((option) => !option.disabled); return lastResortOption?.value ?? '7d'; } function getChartsDateRange(selectedDateRange: string) { const rangeMs = HOME_PAGE_DATE_RANGE_OPTIONS.find((option) => option.value === selectedDateRange)?.ms ?? 30 * 24 * 60 * 60 * 1000; return { createdAtGte: new Date(Date.now() - rangeMs).toISOString(), }; } type UseHomepageDateFilterParams = { organization: OrganizationLike | null | undefined; subscription: GetSubscriptionDto | null | undefined; upgradeCtaIcon?: React.ComponentType<{ className?: string }>; }; export function useHomepageDateFilter({ organization, subscription, upgradeCtaIcon }: UseHomepageDateFilterParams) { // Get the max date analytics feature flag value (in days, convert to milliseconds) // This feature flag controls the maximum date range available for analytics // If set to 7, only options <= 7 days will be enabled, others will show "Coming soon" // Controlled via LaunchDarkly feature flag: MAX_DATE_ANALYTICS_ENABLED_NUMBER const maxDateAnalyticsDays = useNumericFeatureFlag(FeatureFlagsKeysEnum.MAX_DATE_ANALYTICS_ENABLED_NUMBER, 0); const maxDateAnalyticsMs = maxDateAnalyticsDays > 0 ? maxDateAnalyticsDays * 24 * 60 * 60 * 1000 : 0; const defaultDateRange = useMemo( () => getDefaultDateRange({ organization, subscription, maxDateAnalyticsMs }), [organization, subscription, maxDateAnalyticsMs] ); const [selectedDateRange, setSelectedDateRange] = useState(defaultDateRange); useEffect(() => { setSelectedDateRange(defaultDateRange); }, [defaultDateRange]); const dateFilterOptions = useMemo(() => { const missingSubscription = !subscription && !IS_SELF_HOSTED; if (!organization || missingSubscription) { return []; } return buildDateFilterOptions({ organization: organization, apiServiceLevel: subscription?.apiServiceLevel, maxDateAnalyticsMs, }).map((option) => ({ ...option, icon: option.disabled && !option.disabledDueToAnalyticsLimit ? upgradeCtaIcon : undefined, })); }, [organization, subscription, upgradeCtaIcon, maxDateAnalyticsMs]); const chartsDateRange = useMemo(() => getChartsDateRange(selectedDateRange), [selectedDateRange]); const selectedPeriodLabel = useMemo(() => { const option = dateFilterOptions.find((opt) => opt.value === selectedDateRange); return option?.label?.toLowerCase() || 'selected period'; }, [selectedDateRange, dateFilterOptions]); return { selectedDateRange, setSelectedDateRange, dateFilterOptions, chartsDateRange, selectedPeriodLabel, }; } ================================================ FILE: apps/dashboard/src/components/analytics/index.ts ================================================ // Components export type { MetricData } from '../../hooks/use-metric-data'; export { useMetricData } from '../../hooks/use-metric-data'; // Charts export { ChartWrapper } from './charts/chart-wrapper'; export { DeliveryTrendsChart } from './charts/delivery-trends-chart'; export { InteractionTrendChart } from './charts/interaction-trend-chart'; export { WorkflowsByVolume } from './charts/workflows-by-volume'; export { AnalyticsSection } from './components/analytics-section'; export { AnalyticsUpgradeCtaIcon } from './components/analytics-upgrade-cta-icon'; export { ChartsSection } from './components/charts-section'; // Constants export * from './constants/analytics-page.consts'; export * from './constants/analytics-tooltips'; // Hooks export { useHomepageDateFilter as useAnalyticsDateFilter } from './hooks/use-analytics-page-date-filter'; ================================================ FILE: apps/dashboard/src/components/analytics/utils/chart-validation.ts ================================================ type DateBasedChartData = { timestamp: string; }; function hasMinimumDateRange(data: T[], minimumDays: number = 5): boolean { if (!data || data.length === 0) { return false; } const uniqueDates = new Set( data.map((item) => { const date = new Date(item.timestamp); return date.toISOString().split('T')[0]; }) ); return uniqueDates.size >= minimumDays; } function hasMinimumDaysWithData( data: T[], hasDataForItem: (item: T) => boolean, minimumDays: number = 5 ): boolean { if (!data || data.length === 0) { return false; } // Group data by date and check if each date has meaningful data const dateGroups = new Map(); for (const item of data) { const date = new Date(item.timestamp).toISOString().split('T')[0]; if (!dateGroups.has(date)) { dateGroups.set(date, []); } const dayData = dateGroups.get(date); if (dayData) { dayData.push(item); } } // Count days that have at least one data point with meaningful values let daysWithData = 0; for (const [, dayData] of dateGroups) { if (dayData.some(hasDataForItem)) { daysWithData++; } } return daysWithData >= minimumDays; } export function createDateBasedHasDataChecker( hasDataForItem: (item: T) => boolean, minimumDays: number = 5 ) { return (data: T[]) => { return hasMinimumDaysWithData(data, hasDataForItem, minimumDays); }; } function hasMinimumEntries( data: T[], hasDataForItem: (item: T) => boolean, minimumEntries: number = 2 ): boolean { if (!data || data.length === 0) { return false; } const entriesWithData = data.filter(hasDataForItem); return entriesWithData.length >= minimumEntries; } export function createVolumeBasedHasDataChecker(hasDataForItem: (item: T) => boolean, minimumEntries: number = 2) { return (data: T[]) => { return hasMinimumEntries(data, hasDataForItem, minimumEntries); }; } ================================================ FILE: apps/dashboard/src/components/animated-outlet.tsx ================================================ import { AnimatePresence } from 'motion/react'; import React, { useRef } from 'react'; import { useLocation, useOutlet } from 'react-router-dom'; export const AnimatedOutlet = (): React.JSX.Element => { const { pathname, state } = useLocation(); const keyRef = useRef(pathname); const element = useOutlet(); if (!state?.skipAnimation) { keyRef.current = pathname; } return ( {element && React.cloneElement(element, { key: keyRef.current })} ); }; ================================================ FILE: apps/dashboard/src/components/auth/auth-card.tsx ================================================ import { cn } from '../../utils/ui'; import { Card } from '../primitives/card'; export function AuthCard({ children, className }: { children: React.ReactNode; className?: string }) { return ( {children} ); } ================================================ FILE: apps/dashboard/src/components/auth/auth-feature-row.tsx ================================================ import { ReactNode } from 'react'; interface AuthFeatureRowProps { icon: ReactNode; title: string; description: string; } export function AuthFeatureRow({ icon, title, description }: AuthFeatureRowProps) { return (
{icon}
{title}
{description}
); } ================================================ FILE: apps/dashboard/src/components/auth/auth-side-banner.tsx ================================================ import { Button } from '@/components/primitives/button'; import { openInNewTab } from '@/utils/url'; import { IS_ENTERPRISE, IS_SELF_HOSTED, SELF_HOSTED_UPGRADE_REDIRECT_URL } from '../../config'; import { CircleCheck } from '../icons/circle-check'; import { Plug } from '../icons/plug'; import { ShieldZap } from '../icons/shield-zap'; import { Sparkling } from '../icons/sparkling'; import { AuthFeatureRow } from './auth-feature-row'; import { TrustedCompanies } from './trusted-companies'; export function AuthSideBanner() { return (
logo
{IS_SELF_HOSTED ? (
{IS_ENTERPRISE ? 'Welcome to Novu Enterprise' : 'Welcome to Novu Self-Hosted!'}
{IS_ENTERPRISE ? 'Enterprise-grade notification infrastructure with premium support and advanced features.' : 'Full control over your notification infrastructure. Backed by a vibrant community.'}
) : (
Send your first notification in minutes.
No credit card required, 10k workflow runs for free every month.
)}
{IS_SELF_HOSTED ? (
} title={ IS_ENTERPRISE ? 'Enterprise Data Sovereignty & Compliance' : 'Full Data Control & Unlimited Customization' } description={ IS_ENTERPRISE ? 'Complete data residency control with enterprise-grade security, compliance certifications, and audit trails.' : 'Host Novu on your own infrastructure, tailor it to your exact needs, and own your data.' } /> } title={IS_ENTERPRISE ? 'Premium Support & Professional Services' : 'Community-Driven & Transparent'} description={ IS_ENTERPRISE ? 'Dedicated account management, priority support, and professional services for seamless deployment and optimization.' : 'Leverage the power of open-source. Contribute, inspect the code, and be part of our active community.' } /> } title={ IS_ENTERPRISE ? 'Enterprise-Grade Performance & Reliability' : 'Scalable, Secure, and Enterprise-Ready' } description={ IS_ENTERPRISE ? 'Mission-critical SLAs, advanced monitoring, and enterprise integrations built for large-scale operations.' : 'Built to handle any volume, ensuring reliable delivery for your mission-critical notifications.' } />
) : (
} title="Powerful notifications, easy integrations" description="Unlimited workflows, unlimited providers, unlimited subscribers with 99.9% uptime SLA" /> } title="As flexible as in-house built" description="Novu API-first approach, means that you can use just what you need, when you need it." /> } title="Observable and scalable with built-in security" description="Novu handles any volume, any channel, and any team for mission-critical notifications." />
)} {IS_SELF_HOSTED && !IS_ENTERPRISE && (

Looking for a Managed Solution?

Explore Novu Cloud for a fully managed experience with dedicated support, advanced features, and seamless scalability.

)}
); } ================================================ FILE: apps/dashboard/src/components/auth/create-organization.tsx ================================================ import { RegionSelector, useRegion } from '@/context/region'; import { OrganizationList as OrganizationListForm, useOrganization } from '@clerk/clerk-react'; import { useEffect, useRef, useState } from 'react'; import { useTelemetry } from '../../hooks/use-telemetry'; import { clerkSignupAppearance } from '../../utils/clerk-appearance'; import { ROUTES } from '../../utils/routes'; import { TelemetryEvent } from '../../utils/telemetry'; import { UsecasePlaygroundHeader } from '../usecase-playground-header'; import { AuthCard } from './auth-card'; // Constants const HEADER_CONFIG = { title: 'Create an organization', description: 'Create an organization to get started', showSkipButton: false, showBackButton: false, showStepper: true, currentStep: 1, totalSteps: 4, } as const; const ORGANIZATION_FORM_CONFIG = { hidePersonal: true, skipInvitationScreen: true, afterSelectOrganizationUrl: ROUTES.ENV, afterCreateOrganizationUrl: ROUTES.INBOX_USECASE, } as const; const FORM_APPEARANCE = { elements: { ...clerkSignupAppearance.elements, cardBox: { boxShadow: 'none' }, card: { paddingTop: 0, padding: 0 }, }, } as const; const ILLUSTRATION_CONFIG = { src: '/images/auth/ui-org.svg', alt: 'Novu dashboard overview', className: 'opacity-70', } as const; // Types interface FormContainerProps { children: React.ReactNode; } interface IllustrationProps { src: string; alt: string; className?: string; } // Small Components function FormContainer({ children }: FormContainerProps) { return (
{children}
); } function OrganizationForm() { const [showRegionSelector, setShowRegionSelector] = useState(false); useEffect(() => { // Watch for DOM changes to detect when we're on the form page (Page 2) const observer = new MutationObserver(() => { // Check if the organization creation form (with name input) is visible const nameInput = document.querySelector('input[name="name"]'); const isOnFormPage = !!nameInput; if (isOnFormPage !== showRegionSelector) { setShowRegionSelector(isOnFormPage); } }); // Start observing observer.observe(document.body, { childList: true, subtree: true, }); return () => observer.disconnect(); }, [showRegionSelector]); return (
{/* Region selector - only visible on Page 2 (form page), aligned with form content */} {showRegionSelector && (
)}
); } function OrganizationFormSection() { return (
); } function Illustration({ src, alt, className }: IllustrationProps) { return (
{alt}
); } function IllustrationSection() { return (
); } function MainContent() { return (
); } function PageHeader() { return ; } function PageContent() { return (
); } export default function OrganizationCreate() { const { organization } = useOrganization(); const { selectedRegion } = useRegion(); const track = useTelemetry(); const hasTrackedRef = useRef(false); const trackedOrgIdRef = useRef(null); useEffect(() => { if (organization?.id && !hasTrackedRef.current && trackedOrgIdRef.current !== organization.id) { hasTrackedRef.current = true; trackedOrgIdRef.current = organization.id; track(TelemetryEvent.CREATE_ORGANIZATION_FORM_SUBMITTED, { location: 'web', organizationId: organization.id, organizationName: organization.name, region: selectedRegion, }); } }, [organization?.id, organization?.name, selectedRegion, track]); return (
); } ================================================ FILE: apps/dashboard/src/components/auth/inbox-playground.tsx ================================================ import { useOrganization } from '@clerk/clerk-react'; import { useState } from 'react'; import { RiArrowRightSLine } from 'react-icons/ri'; import { useNavigate } from 'react-router-dom'; import { Notification5Fill } from '@/components/icons'; import { useEnvironment } from '@/context/environment/hooks'; import { useInitDemoWorkflow } from '@/hooks/use-init-demo-workflow'; import { useTriggerWorkflow } from '@/hooks/use-trigger-workflow'; import { ONBOARDING_DEMO_WORKFLOW_ID } from '../../config'; import { useTelemetry } from '../../hooks/use-telemetry'; import { ROUTES } from '../../utils/routes'; import { TelemetryEvent } from '../../utils/telemetry'; import { Button } from '../primitives/button'; import { ToastIcon } from '../primitives/sonner'; import { showToast } from '../primitives/sonner-helpers'; import { UsecasePlaygroundHeader } from '../usecase-playground-header'; import { InboxPreviewContent } from './inbox-preview-content'; const PLAYGROUND_CONFIG = { title: 'The your app deserves', description: 'See in-app notifications in action with a live preview of the inbox component', currentStep: 2, totalSteps: 4, } as const; function showCustomToast( message: string, variant: 'success' | 'error', position: 'bottom-center' | 'top-center' | 'bottom-right' = 'bottom-center' ) { showToast({ children: () => ( <> {message} ), options: { position, style: { left: '50%', transform: 'translateX(-50%)', }, }, }); } export function InboxPlayground({ appId, subscriberId }: { appId: string; subscriberId: string }) { const { organization } = useOrganization(); const { currentEnvironment: environment } = useEnvironment(); const { triggerWorkflow, isPending } = useTriggerWorkflow(); const [hasNotificationBeenSent, setHasNotificationBeenSent] = useState(false); const navigate = useNavigate(); const telemetry = useTelemetry(); useInitDemoWorkflow(environment); if (!environment) { return (

Loading environment...

); } const handleSendNotification = async () => { try { await triggerWorkflow({ name: ONBOARDING_DEMO_WORKFLOW_ID, to: subscriberId, payload: { __source: 'inbox-onboarding', }, }); telemetry(TelemetryEvent.INBOX_NOTIFICATION_SENT); setHasNotificationBeenSent(true); showCustomToast('Notification sent successfully!', 'success', 'bottom-right'); } catch (error) { console.error('Failed to send notification:', error); showCustomToast('Failed to send notification. Please try again later.', 'error'); } }; const handleNextStepClick = () => { if (!appId) { return; } telemetry(TelemetryEvent.INBOX_NEXT_STEP_CLICKED); const queryParams = new URLSearchParams(); if (environment?._id) { queryParams.set('environmentId', environment._id); } const qs = queryParams.toString(); navigate(qs ? `${ROUTES.INBOX_EMBED}?${qs}` : ROUTES.INBOX_EMBED); }; const handleSkipClick = () => { telemetry(TelemetryEvent.SKIP_ONBOARDING_CLICKED); const queryParams = new URLSearchParams(); if (environment?._id) { queryParams.set('environmentId', environment._id); } const qs = queryParams.toString(); navigate(qs ? `${ROUTES.INBOX_EMBED}?${qs}` : ROUTES.INBOX_EMBED); }; return (
{organization?.name ? `${organization.name} App` : 'ACME App'}
{/* Action Buttons - Show with optimized interaction states */}
{!hasNotificationBeenSent ? ( ) : ( <> )}
); } ================================================ FILE: apps/dashboard/src/components/auth/inbox-preview-content.tsx ================================================ import { apiHostnameManager } from '@/utils/api-hostname-manager'; import { useUser } from '@clerk/clerk-react'; import { Inbox, InboxContent, InboxProps } from '@novu/react'; import { useAuth } from '../../context/auth/hooks'; import { useFetchEnvironments } from '../../context/environment/hooks'; const defaultTabs = [ { label: 'All', filter: { tags: [] }, }, { label: 'Promotions', filter: { tags: ['promotions'] }, }, { label: 'Security Alerts', filter: { tags: ['security', 'alert'] }, }, ]; export function InboxPreviewContent() { const auth = useAuth(); const { user } = useUser(); const { environments } = useFetchEnvironments({ organizationId: auth?.currentOrganization?._id }); const currentEnvironment = environments?.find((env) => !env._parentId); if (!currentEnvironment || !user) { return null; } const configuration: InboxProps = { applicationIdentifier: currentEnvironment?.identifier, subscriberId: user?.externalId as string, backendUrl: apiHostnameManager.getHostname(), socketUrl: apiHostnameManager.getWebSocketHostname(), localization: { 'notifications.emptyNotice': 'Click Send Notification to see your first notification', }, appearance: { variables: { colorPrimary: '#DD2450', }, elements: { inboxHeader: { backgroundColor: 'white', }, preferencesHeader: { backgroundColor: 'white', }, tabsList: { backgroundColor: 'white', }, inboxContent: { maxHeight: '100%', }, notificationListContainer: { minHeight: '100%', }, notificationListEmptyNoticeContainer: { height: '100%', }, notificationListEmptyNotice: { marginTop: '-32px', }, }, }, tabs: defaultTabs, }; return (
); } ================================================ FILE: apps/dashboard/src/components/auth/mobile-message.tsx ================================================ import { Smartphone } from 'lucide-react'; import { useEffect } from 'react'; import { post } from '@/api/api.client'; import { showErrorToast } from '../primitives/sonner-helpers'; const MOBILE_WIDTH_THRESHOLD = 768; const ONE_HOUR_MS = 60 * 60 * 1000; const MOBILE_SETUP_STORAGE_KEY = 'mobileSetupEmailSentAt'; export function MobileMessage() { useEffect(() => { const notifyMobileSetup = async () => { try { const isMobile = window.innerWidth < MOBILE_WIDTH_THRESHOLD; const lastSentAt = localStorage.getItem(MOBILE_SETUP_STORAGE_KEY); const now = Date.now(); const shouldSendEmail = !lastSentAt || now - parseInt(lastSentAt) > ONE_HOUR_MS; if (isMobile && shouldSendEmail) { localStorage.setItem(MOBILE_SETUP_STORAGE_KEY, now.toString()); await post('/support/mobile-setup', {}); } } catch (e) { showErrorToast('Failed to send mobile setup email, please visit this page from Desktop.'); } }; notifyMobileSetup(); }, []); return (

Desktop Setup Required

👋 Hey, You're Almost There!

We see you signed up from your mobile—nice move! But to complete the Novu setup, you'll need to switch over to your laptop and fire up your favorite IDE.

Integrating Novu into your stack means writing some actual code, setting up workflows, configuring Inbox , and composing your first email.

Check your inbox! We've sent you the setup instructions to get started.

); } ================================================ FILE: apps/dashboard/src/components/auth/questionnaire-form.tsx ================================================ import { useOrganization, useUser } from '@clerk/clerk-react'; import { CompanySizeEnum, JobTitleEnum, jobTitleToLabelMapper, NewDashboardOptInStatusEnum, OrganizationTypeEnum, } from '@novu/shared'; import { useMutation } from '@tanstack/react-query'; import { AnimatePresence, motion } from 'motion/react'; import React from 'react'; import { Controller, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; import { updateClerkOrgMetadata } from '@/api/organization'; import { identifyUser } from '@/api/telemetry'; import { StepIndicator } from '@/components/auth/shared'; import { Button } from '@/components/primitives/button'; import { CardDescription, CardTitle } from '@/components/primitives/card'; import { Form, FormRoot } from '@/components/primitives/form/form'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/primitives/select'; import { useEnvironment, useFetchEnvironments } from '@/context/environment/hooks'; import { useSegment } from '@/context/segment/hooks'; import { useTelemetry } from '@/hooks/use-telemetry'; import { ROUTES } from '@/utils/routes'; import { TelemetryEvent } from '@/utils/telemetry'; interface QuestionnaireFormData { jobTitle: JobTitleEnum; organizationType: OrganizationTypeEnum; companySize?: CompanySizeEnum; } interface SubmitQuestionnaireData { jobTitle: JobTitleEnum; organizationType: OrganizationTypeEnum; companySize?: CompanySizeEnum | string; pageUri: string; pageName: string; } export function QuestionnaireForm() { const { organization } = useOrganization(); useFetchEnvironments({ organizationId: organization?.id }); const form = useForm(); const { control, watch, handleSubmit } = form; const submitQuestionnaireMutation = useSubmitQuestionnaire(); const { user } = useUser(); const selectedJobTitle = watch('jobTitle'); const selectedOrgType = watch('organizationType'); const companySize = watch('companySize'); const shouldShowCompanySize = (selectedOrgType === OrganizationTypeEnum.COMPANY || selectedOrgType === OrganizationTypeEnum.AGENCY) && !!selectedJobTitle; const isFormValid = React.useMemo(() => { if (!selectedJobTitle || !selectedOrgType) return false; if (shouldShowCompanySize && !companySize) return false; return true; }, [selectedJobTitle, selectedOrgType, shouldShowCompanySize, companySize]); const onSubmit = async (data: QuestionnaireFormData) => { submitQuestionnaireMutation.mutate({ ...data, companySize: data.companySize || '1', pageUri: window.location.href, pageName: 'Create Organization Form', }); // TODO: Make this more robust for all new sign-ups if (!user?.unsafeMetadata?.newDashboardOptInStatus) { await user?.update({ unsafeMetadata: { newDashboardOptInStatus: NewDashboardOptInStatusEnum.OPTED_IN, }, }); // TODO: Reload shouldn't be necessary as user.update already returns the updated user await user?.reload(); } }; return ( <>
Help us personalize your experience
This helps us set up Novu to match your goals and plan features and improvements.
( )} />
{selectedJobTitle && (
( <> {Object.values(OrganizationTypeEnum).map((type, index) => ( ))} )} />
)} {shouldShowCompanySize && (
( <> {Object.values(CompanySizeEnum).map((size, index) => ( ))} )} />
)}
{isFormValid && ( )}
create-org-illustration
); } function useSubmitQuestionnaire() { const segment = useSegment(); const track = useTelemetry(); const navigate = useNavigate(); const { currentEnvironment } = useEnvironment(); return useMutation({ mutationFn: async (data: SubmitQuestionnaireData) => { await updateClerkOrgMetadata({ environment: currentEnvironment!, data: { companySize: data.companySize, jobTitle: data.jobTitle, organizationType: data.organizationType, }, }); const anonymousId = await segment.getAnonymousId(); await identifyUser({ pageUri: data.pageUri, pageName: data.pageName, jobTitle: data.jobTitle, companySize: data.companySize, organizationType: data.organizationType, anonymousId, }); track(TelemetryEvent.CREATE_ORGANIZATION_FORM_SUBMITTED, { location: 'web', jobTitle: data.jobTitle, companySize: data.companySize, organizationType: data.organizationType, }); }, onSuccess: () => { navigate(ROUTES.INBOX_USECASE); }, }); } ================================================ FILE: apps/dashboard/src/components/auth/region-picker.tsx ================================================ import { useState } from 'react'; import { BsFillInfoCircleFill } from 'react-icons/bs'; import { EuFlag } from '../icons/flags/eu'; import { USFlag } from '../icons/flags/us'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../primitives/select'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../primitives/tooltip'; const REGION_MAP = { US: 'US', EU: 'EU', } as const; type RegionType = (typeof REGION_MAP)[keyof typeof REGION_MAP]; function getDefaultRegion(): RegionType { if (typeof window === 'undefined') return REGION_MAP.US; return window.location.hostname.includes('eu.') ? REGION_MAP.EU : REGION_MAP.US; } export function RegionPicker() { const [selectedRegion] = useState(getDefaultRegion()); function handleRegionChange(value: RegionType) { switch (value) { case REGION_MAP.US: window.location.href = 'https://dashboard.novu.co'; break; case REGION_MAP.EU: window.location.href = 'https://eu.dashboard.novu.co'; break; } } return (
Data Residency Novu offers data residency in Europe (Germany) and the United States. Data residency cannot be modified after sign-up.
); } ================================================ FILE: apps/dashboard/src/components/auth/shared.tsx ================================================ import { RiArrowLeftSLine } from 'react-icons/ri'; import { useNavigate } from 'react-router-dom'; import { cn } from '../../utils/ui'; interface StepIndicatorProps { step: number; className?: string; hideBackButton?: boolean; } export function StepIndicator({ step, className, hideBackButton }: StepIndicatorProps) { const navigate = useNavigate(); function handleGoBack() { navigate(-1); } return (
{!hideBackButton && ( )} {step}/3
); } ================================================ FILE: apps/dashboard/src/components/auth/trusted-companies.tsx ================================================ export function TrustedCompanies() { return (
TRUSTED BY
); } interface CompanyLogoProps { name: string; } function CompanyLogo({ name }: CompanyLogoProps) { return (
{name}
); } ================================================ FILE: apps/dashboard/src/components/auth/usecase-selector.tsx ================================================ import { ChannelTypeEnum } from '@novu/shared'; import { Card, CardContent } from '../primitives/card'; import { StepIndicator } from './shared'; import { Usecase } from './usecases-list.utils'; interface UsecaseSelectOnboardingProps { onHover: (id: ChannelTypeEnum | null) => void; onClick: (id: ChannelTypeEnum) => void; selectedUseCases: ChannelTypeEnum[]; channelOptions: Usecase[]; } export function UsecaseSelectOnboarding({ onHover, onClick, selectedUseCases, channelOptions, }: UsecaseSelectOnboardingProps) { return (

How do you plan to use Novu?

You can route notifications across channels intelligently with Novu's powerful workflows, among the channels below.

{channelOptions.map((option, index) => { const isSelected = selectedUseCases.includes(option.id); return (
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick(option.id); } }} onFocus={() => onHover(option.id)} onBlur={() => onHover(null)} > onHover(option.id)} onMouseLeave={() => onHover(null)} onClick={() => onClick(option.id)} >

{option.title}

{option.description}

); })}
); } ================================================ FILE: apps/dashboard/src/components/auth/usecases-list.utils.tsx ================================================ import { ChannelTypeEnum, StepTypeEnum } from '@novu/shared'; import { IconType } from 'react-icons/lib'; import { STEP_TYPE_TO_COLOR } from '../../utils/color'; import { STEP_TYPE_TO_ICON } from '../icons/utils'; export interface Usecase { icon: IconType; title: string; color: string; id: ChannelTypeEnum; description: string; image: string; } export const getChannelOptions = () => [ { icon: STEP_TYPE_TO_ICON[StepTypeEnum.IN_APP], title: 'Inbox', color: STEP_TYPE_TO_COLOR[StepTypeEnum.IN_APP], id: ChannelTypeEnum.IN_APP, description: 'Embed real-time in your product', image: 'in_app-preview-v3.webp', }, { icon: STEP_TYPE_TO_ICON[StepTypeEnum.EMAIL], title: 'E-Mail', color: STEP_TYPE_TO_COLOR[StepTypeEnum.EMAIL], id: ChannelTypeEnum.EMAIL, description: 'Sends Emails to your users', image: 'email-preview.webp', }, { icon: STEP_TYPE_TO_ICON[StepTypeEnum.SMS], title: 'SMS', color: STEP_TYPE_TO_COLOR[StepTypeEnum.SMS], id: ChannelTypeEnum.SMS, description: 'Sends SMS messages to your users', image: 'sms-preview.webp', }, { icon: STEP_TYPE_TO_ICON[StepTypeEnum.PUSH], title: 'Push', color: STEP_TYPE_TO_COLOR[StepTypeEnum.PUSH], id: ChannelTypeEnum.PUSH, description: 'Send push notifications to your users', image: 'push-preview.webp', }, { icon: STEP_TYPE_TO_ICON[StepTypeEnum.CHAT], title: 'Chat', color: STEP_TYPE_TO_COLOR[StepTypeEnum.CHAT], id: ChannelTypeEnum.CHAT, description: 'Send Slack and other chat notifications', image: 'chat-preview.webp', }, ]; ================================================ FILE: apps/dashboard/src/components/auth-layout.tsx ================================================ import { ReactNode } from 'react'; import { Toaster } from './primitives/sonner'; export const AuthLayout = ({ children }: { children: ReactNode }) => { return (
{children}
); }; ================================================ FILE: apps/dashboard/src/components/billing/active-plan-banner.tsx ================================================ import { getCalApi } from '@calcom/embed-react'; import { useOrganization } from '@clerk/clerk-react'; import { ApiServiceLevelEnum, FeatureNameEnum, getFeatureForTierAsNumber, getFeatureForTierAsText, UNLIMITED_VALUE, } from '@novu/shared'; import { Check, Minus } from 'lucide-react'; import { useEffect } from 'react'; import { RiCalendarEventLine, RiRouteFill, RiTeamLine } from 'react-icons/ri'; import { Badge } from '@/components/primitives/badge'; import { LinkButton } from '@/components/primitives/button-link'; import { Card } from '@/components/primitives/card'; import { Progress } from '@/components/primitives/progress'; import { Skeleton } from '@/components/primitives/skeleton'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/primitives/tooltip'; import { useFetchSubscription } from '../../hooks/use-fetch-subscription'; import { useFetchWorkflows } from '../../hooks/use-fetch-workflows'; import { getPlanFeatures, type PlanFeature } from './features-config'; import { PlanActionButton } from './plan-action-button'; interface ActivePlanBannerProps { selectedBillingInterval: 'month' | 'year'; } interface UsageMetric { type: 'events' | 'workflows' | 'teammates'; icon: React.ComponentType<{ className?: string }>; label: string; } const USAGE_METRICS: UsageMetric[] = [ { type: 'events', icon: RiCalendarEventLine, label: 'Workflow Runs' }, { type: 'workflows', icon: RiRouteFill, label: 'Workflows' }, { type: 'teammates', icon: RiTeamLine, label: 'Teammates' }, ]; function formatDate(date: string | number): string { return new Date(date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', }); } function formatLimit(limit: number): string { return limit === UNLIMITED_VALUE ? '∞' : limit.toLocaleString(); } function getEventsTooltipContent( usageData: { included: number }, subscription: ReturnType['subscription'] ): string { const currentPlan = subscription?.apiServiceLevel || ApiServiceLevelEnum.FREE; const isFreePlan = currentPlan === ApiServiceLevelEnum.FREE; const limitMessage = isFreePlan ? "Further workflow runs won't be allowed after the free limit is exceeded." : 'Pay as you grow. No hard limit.'; return `Includes ${formatLimit(usageData.included)} workflow runs — ${limitMessage}`; } function formatDateRange( subscription: NonNullable['subscription']>, daysLeft: number ) { if (subscription.trial.isActive) { const endDate = subscription.trial.end ? formatDate(subscription.trial.end) : 'soon'; return `Trial ends ${endDate} (${daysLeft} days left)`; } const start = formatDate(subscription.currentPeriodStart ?? Date.now()); const end = formatDate(subscription.currentPeriodEnd ?? Date.now()); return `${start} - ${end}`; } function getPlanBadgeText(subscription: ReturnType['subscription']): string { const currentPlan = subscription?.apiServiceLevel || ApiServiceLevelEnum.FREE; const planLabel = getFeatureForTierAsText(FeatureNameEnum.PLATFORM_PLAN_LABEL, currentPlan); const isTrialActive = subscription?.trial?.isActive; const baseText = currentPlan === ApiServiceLevelEnum.FREE ? `${planLabel.toUpperCase()} FOREVER` : planLabel.toUpperCase(); return isTrialActive ? `${baseText} (TRIAL)` : baseText; } function getUsageData( type: UsageMetric['type'], subscription: ReturnType['subscription'], workflowsData: ReturnType['data'], organization: ReturnType['organization'] ) { const currentPlan = subscription?.apiServiceLevel || ApiServiceLevelEnum.FREE; switch (type) { case 'events': return { current: subscription?.events.current ?? 0, included: subscription?.events.included ?? getFeatureForTierAsNumber(FeatureNameEnum.PLATFORM_MONTHLY_EVENTS_INCLUDED, currentPlan, false), label: 'included', }; case 'workflows': return { current: workflowsData?.totalCount ?? 0, included: getFeatureForTierAsNumber(FeatureNameEnum.PLATFORM_MAX_WORKFLOWS, currentPlan, false), label: 'workflows', }; case 'teammates': return { current: organization?.membersCount ?? 0, included: getFeatureForTierAsNumber(FeatureNameEnum.ACCOUNT_MAX_TEAM_MEMBERS, currentPlan, false), label: 'teammates', }; } } interface CardHeaderProps { title: string; children?: React.ReactNode; rightContent?: React.ReactNode; titleInline?: boolean; } function CardHeader({ title, children, rightContent, titleInline = false }: CardHeaderProps) { const containerClasses = titleInline ? 'items-center' : 'items-start'; const contentClasses = titleInline ? 'flex items-center gap-3' : 'flex flex-col items-start gap-1'; return (

{title}

{children}
{rightContent}
); } interface UsageMetricRowProps { metric: UsageMetric; subscription: ReturnType['subscription']; workflowsData: ReturnType['data']; organization: ReturnType['organization']; } function UsageMetricRow({ metric, subscription, workflowsData, organization }: UsageMetricRowProps) { const usageData = getUsageData(metric.type, subscription, workflowsData, organization); const Icon = metric.icon; if (!subscription) { return (
{metric.label}
); } return (
{metric.label}
{usageData.current.toLocaleString()} /{' '} {formatLimit(usageData.included)}{' '} {metric.type === 'events' ? ( {usageData.label}

{getEventsTooltipContent(usageData, subscription)}

) : ( usageData.label )}
); } interface FeatureListProps { title: string; features: PlanFeature[]; isIncluded: boolean; } function FeatureList({ title, features, isIncluded }: FeatureListProps) { const titleColor = isIncluded ? 'text-text-sub' : 'text-text-soft'; const Icon = isIncluded ? Check : Minus; const iconColor = isIncluded ? 'text-text-sub' : 'text-text-soft'; return (

{title}

    {features.map((feature, index) => (
  • {!feature.isMore && } {feature.text}
  • ))}
); } interface ActionButtonProps { selectedBillingInterval: 'month' | 'year'; subscription: ReturnType['subscription']; } function ActionButton({ selectedBillingInterval, subscription }: ActionButtonProps) { const currentPlan = subscription?.apiServiceLevel || ApiServiceLevelEnum.FREE; const isFreePlan = currentPlan === ApiServiceLevelEnum.FREE; const isTrialActive = subscription?.trial?.isActive; const isPaidActive = subscription?.isActive && !isTrialActive && !isFreePlan; const requestedServiceLevel = isPaidActive ? currentPlan : ApiServiceLevelEnum.PRO; return ( ); } function UsageCard({ subscription, daysLeft, workflowsData, organization, }: { subscription: ReturnType['subscription']; daysLeft: number; workflowsData: ReturnType['data']; organization: ReturnType['organization']; }) { return ( Updates hourly}>
{!subscription ? ( ) : ( {formatDateRange(subscription, daysLeft)} )}
{USAGE_METRICS.map((metric) => ( ))}
); } function PlanCard({ selectedBillingInterval, subscription, }: { selectedBillingInterval: 'month' | 'year'; subscription: ReturnType['subscription']; }) { const currentPlan = subscription?.apiServiceLevel || ApiServiceLevelEnum.FREE; const { included, excluded } = getPlanFeatures(currentPlan); return ( } > {getPlanBadgeText(subscription)}
{excluded.length > 0 && ( )}
); } export function ActivePlanBanner({ selectedBillingInterval }: ActivePlanBannerProps) { const { subscription, daysLeft } = useFetchSubscription(); const { organization } = useOrganization(); const { data: workflowsData } = useFetchWorkflows({ limit: 1 }); useEffect(() => { (async () => { const cal = await getCalApi({ namespace: 'novu-meeting' }); cal('ui', { hideEventTypeDetails: false, layout: 'month_view' }); })(); }, []); return (
Have questions or need a custom plan?{' '}
); } ================================================ FILE: apps/dashboard/src/components/billing/contact-sales-button.tsx ================================================ import { getCalApi } from '@calcom/embed-react'; import { ApiServiceLevelEnum } from '@novu/shared'; import { useEffect } from 'react'; import { Button } from '@/components/primitives/button'; import { useTelemetry } from '../../hooks/use-telemetry'; import { TelemetryEvent } from '../../utils/telemetry'; interface ContactSalesButtonProps { className?: string; } export function ContactSalesButton({ className }: ContactSalesButtonProps) { const track = useTelemetry(); useEffect(() => { (async () => { const cal = await getCalApi({ namespace: 'novu-meeting' }); cal('ui', { hideEventTypeDetails: false, layout: 'month_view' }); })(); }, []); const handleContactSales = () => { track(TelemetryEvent.BILLING_CONTACT_SALES_CLICKED, { intendedPlan: ApiServiceLevelEnum.ENTERPRISE, source: 'billing_page', }); }; return ( ); } ================================================ FILE: apps/dashboard/src/components/billing/features-config.ts ================================================ import { ApiServiceLevelEnum, FeatureNameEnum, getFeatureForTierAsText } from '@novu/shared'; export interface PlanFeature { text: string; included: boolean; isMore?: boolean; // For "& more..." items } export interface FeatureSectionConfig { title: string; features: FeatureNameEnum[]; } // Feature can be either an enum (uses constants) or a direct string type FeatureConfig = FeatureNameEnum | string; export const FEATURE_SECTIONS: FeatureSectionConfig[] = [ { title: 'Workflow Runs', features: [ FeatureNameEnum.PLATFORM_MONTHLY_EVENTS_INCLUDED, FeatureNameEnum.PLATFORM_COST_PER_ADDITIONAL_1K_EVENTS, FeatureNameEnum.PLATFORM_CHANNELS_SUPPORTED_BOOLEAN, ], }, { title: 'Platform', features: [ FeatureNameEnum.PLATFORM_SUBSCRIBERS, FeatureNameEnum.PLATFORM_MAX_WORKFLOWS, FeatureNameEnum.PLATFORM_MAX_LAYOUTS, FeatureNameEnum.PLATFORM_MAX_STEP_RESOLVERS, FeatureNameEnum.CUSTOM_ENVIRONMENTS_BOOLEAN, FeatureNameEnum.AUTO_TRANSLATIONS, FeatureNameEnum.WEBHOOKS, FeatureNameEnum.ENVIRONMENT_VARIABLES, ], }, { title: 'Retention', features: [ FeatureNameEnum.PLATFORM_ACTIVITY_FEED_RETENTION, FeatureNameEnum.PLATFORM_MAX_DELAY_DURATION, FeatureNameEnum.PLATFORM_MAX_DIGEST_WINDOW_TIME, ], }, { title: 'Inbox', features: [ FeatureNameEnum.INBOX_COMPONENT_BOOLEAN, FeatureNameEnum.INBOX_USER_PREFERENCES_COMPONENT_BOOLEAN, FeatureNameEnum.PLATFORM_REMOVE_NOVU_BRANDING_BOOLEAN, FeatureNameEnum.PLATFORM_MAX_SNOOZE_DURATION, ], }, { title: 'Account administration and security', features: [ FeatureNameEnum.ACCOUNT_MAX_TEAM_MEMBERS, FeatureNameEnum.ACCOUNT_ROLE_BASED_ACCESS_CONTROL_BOOLEAN, FeatureNameEnum.COMPLIANCE_GDPR_BOOLEAN, FeatureNameEnum.COMPLIANCE_HIPAA_BAA_BOOLEAN, FeatureNameEnum.ACCOUNT_CUSTOM_SAML_SSO_OIDC_BOOLEAN, ], }, { title: 'Support and account management', features: [FeatureNameEnum.PLATFORM_SUPPORT_SLA, FeatureNameEnum.PLATFORM_SUPPORT_CHANNELS], }, { title: 'Legal & Vendor management', features: [ FeatureNameEnum.PAYMENT_METHOD, FeatureNameEnum.COMPLIANCE_CUSTOM_SECURITY_REVIEWS, FeatureNameEnum.PLATFORM_TERMS_OF_SERVICE, FeatureNameEnum.COMPLIANCE_DATA_PROCESSING_AGREEMENTS, ], }, ]; const PLAN_FEATURES_CONFIG: Record = { [ApiServiceLevelEnum.FREE]: { included: [ FeatureNameEnum.PLATFORM_MONTHLY_EVENTS_INCLUDED, FeatureNameEnum.PLATFORM_MAX_WORKFLOWS, FeatureNameEnum.PLATFORM_SUBSCRIBERS, FeatureNameEnum.PLATFORM_ACTIVITY_FEED_RETENTION, ], excluded: [ '30,000 workflow runs & more', 'Unlimited workflows', FeatureNameEnum.CUSTOM_ENVIRONMENTS_BOOLEAN, 'Dedicated support', FeatureNameEnum.PLATFORM_REMOVE_NOVU_BRANDING_BOOLEAN, ], }, [ApiServiceLevelEnum.PRO]: { included: [ FeatureNameEnum.PLATFORM_MONTHLY_EVENTS_INCLUDED, FeatureNameEnum.PLATFORM_MAX_WORKFLOWS, FeatureNameEnum.PLATFORM_REMOVE_NOVU_BRANDING_BOOLEAN, FeatureNameEnum.PLATFORM_ACTIVITY_FEED_RETENTION, ], excluded: [ '250,000 workflow runs & more', 'Unlimited workflows', FeatureNameEnum.CUSTOM_ENVIRONMENTS_BOOLEAN, FeatureNameEnum.WEBHOOKS, FeatureNameEnum.AUTO_TRANSLATIONS, ], }, [ApiServiceLevelEnum.BUSINESS]: { included: [ FeatureNameEnum.PLATFORM_MONTHLY_EVENTS_INCLUDED, FeatureNameEnum.PLATFORM_MAX_WORKFLOWS, FeatureNameEnum.ACCOUNT_MAX_TEAM_MEMBERS, FeatureNameEnum.PLATFORM_ACTIVITY_FEED_RETENTION, ], excluded: [ 'Custom workflow run amount', FeatureNameEnum.ACCOUNT_CUSTOM_SAML_SSO_OIDC_BOOLEAN, '24 hours support SLA', 'Custom delay & snooze durations', 'Custom retention periods', ], }, [ApiServiceLevelEnum.ENTERPRISE]: { included: [ 'Volume discounts', FeatureNameEnum.PLATFORM_MAX_WORKFLOWS, FeatureNameEnum.ACCOUNT_MAX_TEAM_MEMBERS, FeatureNameEnum.PLATFORM_ACTIVITY_FEED_RETENTION, ], excluded: ['Being told "you need to upgrade"'], }, [ApiServiceLevelEnum.UNLIMITED]: { included: [ 'Custom workflow runs', FeatureNameEnum.PLATFORM_MAX_WORKFLOWS, FeatureNameEnum.ACCOUNT_MAX_TEAM_MEMBERS, FeatureNameEnum.PLATFORM_ACTIVITY_FEED_RETENTION, ], excluded: [], }, }; function getFeatureDisplayText(feature: FeatureConfig, plan: ApiServiceLevelEnum): string { if (Object.values(FeatureNameEnum).includes(feature as FeatureNameEnum)) { return getFeatureForTierAsText(feature as FeatureNameEnum, plan); } // It's a direct string, use as-is return feature as string; } // Get features for a specific plan (for active plan banner) export function getPlanFeatures(plan: ApiServiceLevelEnum): { included: PlanFeature[]; excluded: PlanFeature[] } { const config = PLAN_FEATURES_CONFIG[plan]; const included: PlanFeature[] = config.included.map((feature: FeatureConfig) => ({ text: getFeatureDisplayText(feature, plan), included: true, })); // Add "& more..." as the last item included.push({ text: '& more...', included: true, isMore: true, }); const excluded: PlanFeature[] = config.excluded.map((feature: FeatureConfig) => ({ text: getFeatureDisplayText(feature, plan), included: false, })); return { included, excluded }; } // Get just the included features for plan highlights (for plan cards) export function getPlanHighlightFeatures(plan: ApiServiceLevelEnum): string[] { const config = PLAN_FEATURES_CONFIG[plan]; return config.included.map((feature: FeatureConfig) => getFeatureDisplayText(feature, plan)); } ================================================ FILE: apps/dashboard/src/components/billing/features.tsx ================================================ import { ApiServiceLevelEnum, FeatureNameEnum, getFeatureForTier, getFeatureForTierAsText, isDetailedPriceListItem, } from '@novu/shared'; import { Check, Minus } from 'lucide-react'; import { FEATURE_SECTIONS } from './features-config'; const PLAN_ORDER: ApiServiceLevelEnum[] = [ ApiServiceLevelEnum.FREE, ApiServiceLevelEnum.PRO, ApiServiceLevelEnum.BUSINESS, ApiServiceLevelEnum.ENTERPRISE, ]; // Get feature display info for a specific plan function getFeatureDisplay(featureName: FeatureNameEnum, plan: ApiServiceLevelEnum) { const rawFeature = getFeatureForTier(featureName, plan); const textValue = getFeatureForTierAsText(featureName, plan); // Disabled if value is null or empty text const isDisabled = (isDetailedPriceListItem(rawFeature) && !rawFeature.value) || textValue === '-' || textValue === ''; return { content: textValue, disabled: isDisabled, }; } // Individual feature row component function FeatureItem({ featureName, plan }: { featureName: FeatureNameEnum; plan: ApiServiceLevelEnum }) { const { content, disabled } = getFeatureDisplay(featureName, plan); return (
{typeof content === 'string' ? ( <> {disabled ? : } {content !== '-' && content !== '' && {content}} ) : ( content )}
); } // Main features component export function Features() { return (
{FEATURE_SECTIONS.map((section) => (

{section.title}

{PLAN_ORDER.map((plan) => (
{section.features.map((featureName) => ( ))}
))}
))}
); } ================================================ FILE: apps/dashboard/src/components/billing/plan-action-button.tsx ================================================ import { ApiServiceLevelEnum, FeatureNameEnum, getFeatureForTierAsNumber, PermissionsEnum } from '@novu/shared'; import { RiArrowRightSLine } from 'react-icons/ri'; import { useBillingPortal } from '../../hooks/use-billing-portal'; import { useCheckoutSession } from '../../hooks/use-checkout-session'; import { useFetchSubscription } from '../../hooks/use-fetch-subscription'; import { cn } from '../../utils/ui'; import { PermissionButton } from '../primitives/permission-button'; import { ContactSalesButton } from './contact-sales-button'; interface PlanActionButtonProps { billingInterval: 'month' | 'year'; requestedServiceLevel: ApiServiceLevelEnum; className?: string; size?: 'sm' | 'md' | 'xs' | '2xs'; } export function PlanActionButton({ billingInterval, requestedServiceLevel, className, size = 'md', }: PlanActionButtonProps) { const { subscription, isLoading: isLoadingSubscription } = useFetchSubscription(); const { navigateToCheckout, isLoading: isCheckingOut } = useCheckoutSession(); const { navigateToPortal, isLoading: isLoadingPortal } = useBillingPortal(billingInterval); // Enterprise plans show contact sales if (requestedServiceLevel === ApiServiceLevelEnum.ENTERPRISE) { return ; } // Free tier has no button if (requestedServiceLevel === ApiServiceLevelEnum.FREE) { return null; } const isOnTrial = subscription?.trial?.isActive; const currentServiceLevel = subscription?.apiServiceLevel || ApiServiceLevelEnum.FREE; // During trial, treat Pro as current level const effectiveCurrentLevel = isOnTrial ? ApiServiceLevelEnum.PRO : currentServiceLevel; const isCurrentPlan = requestedServiceLevel === effectiveCurrentLevel; // Current plan - show manage button if (isCurrentPlan && !isOnTrial) { return ( navigateToPortal()} disabled={isLoadingPortal} isLoading={isLoadingSubscription} > Manage ); } // Special case: Pro plan during trial should show "Upgrade plan" if (isOnTrial && requestedServiceLevel === ApiServiceLevelEnum.PRO) { return ( navigateToCheckout({ billingInterval, requestedServiceLevel })} isLoading={isCheckingOut || isLoadingSubscription} > Upgrade plan ); } // Get tier indices for comparison const requestedIndex = getFeatureForTierAsNumber(FeatureNameEnum.TIERS_ORDER_INDEX, requestedServiceLevel); const currentIndex = getFeatureForTierAsNumber(FeatureNameEnum.TIERS_ORDER_INDEX, effectiveCurrentLevel); const isUpgrade = requestedIndex > currentIndex; // Don't show downgrade during trial if (isOnTrial && !isUpgrade) { return null; } const buttonLabel = isUpgrade ? 'Upgrade plan' : 'Downgrade plan'; return ( navigateToCheckout({ billingInterval, requestedServiceLevel })} isLoading={isCheckingOut || isLoadingSubscription} > {buttonLabel} ); } ================================================ FILE: apps/dashboard/src/components/billing/plan-switcher.tsx ================================================ import { StripeBillingIntervalEnum } from '@novu/shared'; import { Tabs, TabsList, TabsTrigger } from '@/components/primitives/tabs'; import { Badge } from '../primitives/badge'; interface PlanSwitcherProps { selectedBillingInterval: 'month' | 'year'; setSelectedBillingInterval: (value: StripeBillingIntervalEnum) => void; } export function PlanSwitcher({ selectedBillingInterval, setSelectedBillingInterval }: PlanSwitcherProps) { return (

Compare Plans

setSelectedBillingInterval(value as StripeBillingIntervalEnum)} > Monthly Annually{' '} 10% off
); } ================================================ FILE: apps/dashboard/src/components/billing/plan.tsx ================================================ import { ApiServiceLevelEnum, FeatureNameEnum, getFeatureForTierAsText, StripeBillingIntervalEnum } from '@novu/shared'; import { useEffect, useState } from 'react'; import { ActionType } from '@/components/billing/utils/action.button.constants.ts'; import { useAuth } from '@/context/auth/hooks'; import { useFetchSubscription } from '@/hooks/use-fetch-subscription'; import { useTelemetry } from '../../hooks/use-telemetry'; import { TelemetryEvent } from '../../utils/telemetry'; import { sendGTMEvent } from '../../utils/tracking'; import { cn } from '../../utils/ui'; import { showErrorToast, showSuccessToast } from '../primitives/sonner-helpers'; import { ActivePlanBanner } from './active-plan-banner'; import { Features } from './features'; import { PlanSwitcher } from './plan-switcher'; import { type PlanConfig, PlansRow } from './plans-row'; function createPlanConfig(plan: ApiServiceLevelEnum, interval: StripeBillingIntervalEnum): PlanConfig { const price = getFeatureForTierAsText( interval === StripeBillingIntervalEnum.YEAR ? FeatureNameEnum.PLATFORM_ANNUAL_COST : FeatureNameEnum.PLATFORM_MONTHLY_COST, plan ); const actionTypeMap = { [ApiServiceLevelEnum.FREE]: undefined, [ApiServiceLevelEnum.PRO]: ActionType.BUTTON, [ApiServiceLevelEnum.BUSINESS]: ActionType.BUTTON, [ApiServiceLevelEnum.ENTERPRISE]: ActionType.CONTACT, [ApiServiceLevelEnum.UNLIMITED]: ActionType.CONTACT, }; return { name: getFeatureForTierAsText(FeatureNameEnum.PLATFORM_PLAN_LABEL, plan), price, subtitle: price === '0$' ? 'Free forever' : `billed ${interval === 'year' ? 'annually' : 'monthly'}`, actionType: actionTypeMap[plan], }; } type DisplayedPlan = | ApiServiceLevelEnum.FREE | ApiServiceLevelEnum.PRO | ApiServiceLevelEnum.BUSINESS | ApiServiceLevelEnum.ENTERPRISE; function getPlansConfig(interval: StripeBillingIntervalEnum): Record { return { [ApiServiceLevelEnum.FREE]: createPlanConfig(ApiServiceLevelEnum.FREE, interval), [ApiServiceLevelEnum.PRO]: createPlanConfig(ApiServiceLevelEnum.PRO, interval), [ApiServiceLevelEnum.BUSINESS]: createPlanConfig(ApiServiceLevelEnum.BUSINESS, interval), [ApiServiceLevelEnum.ENTERPRISE]: createPlanConfig(ApiServiceLevelEnum.ENTERPRISE, interval), }; } export function Plan() { const track = useTelemetry(); const { currentOrganization } = useAuth(); const { subscription: data } = useFetchSubscription(); const [selectedBillingInterval, setSelectedBillingInterval] = useState<'month' | 'year'>( data?.billingInterval || 'month' ); const plans = getPlansConfig(selectedBillingInterval as StripeBillingIntervalEnum); useEffect(() => { const checkoutResult = new URLSearchParams(window.location.search).get('result'); if (checkoutResult === 'success') { showSuccessToast('Payment was successful.'); track(TelemetryEvent.BILLING_PAYMENT_SUCCESS, { billingInterval: selectedBillingInterval, plan: data?.apiServiceLevel, }); if (data?.apiServiceLevel && data.apiServiceLevel !== ApiServiceLevelEnum.FREE) { const tierPrice = getFeatureForTierAsText( selectedBillingInterval === 'year' ? FeatureNameEnum.PLATFORM_ANNUAL_COST : FeatureNameEnum.PLATFORM_MONTHLY_COST, data.apiServiceLevel ); sendGTMEvent('account_upgrade', { value: tierPrice, org_id: currentOrganization?._id, }); } } if (checkoutResult === 'canceled') { showErrorToast('Payment was canceled.'); track(TelemetryEvent.BILLING_PAYMENT_CANCELED, { billingInterval: selectedBillingInterval, plan: data?.apiServiceLevel, }); } }, [data?.apiServiceLevel, currentOrganization?._id, selectedBillingInterval, track]); useEffect(() => { track(TelemetryEvent.BILLING_PAGE_VIEWED, { currentPlan: data?.apiServiceLevel, billingInterval: selectedBillingInterval, isTrialActive: data?.trial?.isActive, }); }, [data?.apiServiceLevel, data?.trial?.isActive, selectedBillingInterval, track]); const handleBillingIntervalChange = (interval: StripeBillingIntervalEnum) => { track(TelemetryEvent.BILLING_INTERVAL_CHANGED, { from: selectedBillingInterval, to: interval, currentPlan: data?.apiServiceLevel, }); setSelectedBillingInterval(interval); }; return (
); } ================================================ FILE: apps/dashboard/src/components/billing/plans-row.tsx ================================================ import { ApiServiceLevelEnum, StripeBillingIntervalEnum } from '@novu/shared'; import { Check } from 'lucide-react'; import { AnimatePresence, motion, useInView } from 'motion/react'; import { useRef } from 'react'; import { ActionType } from '@/components/billing/utils/action.button.constants.ts'; import { Badge } from '@/components/primitives/badge'; import { ContactSalesButton } from './contact-sales-button'; import { getPlanHighlightFeatures } from './features-config'; import { PlanActionButton } from './plan-action-button'; type DisplayedPlan = | ApiServiceLevelEnum.FREE | ApiServiceLevelEnum.PRO | ApiServiceLevelEnum.BUSINESS | ApiServiceLevelEnum.ENTERPRISE; export interface PlanConfig { name: string; price: string; subtitle: string; actionType?: ActionType; } interface PlansRowProps { selectedBillingInterval: StripeBillingIntervalEnum; currentPlan?: ApiServiceLevelEnum; plans: Record; isOnTrial?: boolean; } function PlanFeature({ text }: { text: string }) { return (
  • {text}
  • ); } interface PlanHeaderProps { planKey: string; planConfig: PlanConfig; currentPlan?: ApiServiceLevelEnum; isOnTrial?: boolean; } function PlanHeader({ planKey, planConfig, currentPlan, isOnTrial }: PlanHeaderProps) { const isCurrentPlan = currentPlan === planKey; const isProPlan = planKey === ApiServiceLevelEnum.PRO; const isFreeOrTrial = currentPlan === ApiServiceLevelEnum.FREE || isOnTrial; const planName = planKey === ApiServiceLevelEnum.FREE ? 'Free forever' : planConfig.name; const showRecommended = isProPlan && isFreeOrTrial; const currentBadgeText = isOnTrial && isProPlan ? 'Current (Trial)' : 'Current'; return (

    {planName}

    {showRecommended && ( RECOMMENDED )}
    {isCurrentPlan && ( {currentBadgeText} )}
    ); } interface PlanPricingProps { planKey: string; planConfig: PlanConfig; } function PlanPricing({ planKey, planConfig }: PlanPricingProps) { const subtitle = planKey === ApiServiceLevelEnum.FREE ? 'Actually free - no strings attached.' : planConfig.subtitle; return ( <>
    {planConfig.price}
    {subtitle} ); } interface PlanCardProps { planKey: string; planConfig: PlanConfig; selectedBillingInterval: StripeBillingIntervalEnum; currentPlan?: ApiServiceLevelEnum; isOnTrial?: boolean; isSticky: boolean; } function getCardStyles(planKey: string, currentPlan?: ApiServiceLevelEnum, isOnTrial?: boolean) { const isCurrentPlan = currentPlan === planKey; const isProPlan = planKey === ApiServiceLevelEnum.PRO; const isFreeOrTrial = currentPlan === ApiServiceLevelEnum.FREE || isOnTrial; const isPaidCurrent = isCurrentPlan && !isOnTrial && currentPlan !== ApiServiceLevelEnum.FREE; const shouldHighlight = (isProPlan && isFreeOrTrial) || isPaidCurrent; const useGradient = isProPlan && isFreeOrTrial; if (shouldHighlight) { if (useGradient) { return { className: 'border border-primary/10', style: { background: 'radial-gradient(64% 62% at 16.2% 22.6%, rgba(251, 55, 72, 0.05) 0%, rgba(255, 255, 255, 0.00) 100%), #FFF', boxShadow: '0 0.602px 0.602px -1.25px rgba(251, 55, 72, 0.05), 0 2.289px 2.289px -2.5px rgba(251, 55, 72, 0.10), 0 10px 10px -3.75px rgba(251, 55, 72, 0.04), 0 20px 50px 0 rgba(251, 55, 72, 0.05)', }, }; } return { className: 'border border-primary/10', style: { background: '#FFF', boxShadow: '0 0.602px 0.602px -1.25px rgba(251, 55, 72, 0.05), 0 2.289px 2.289px -2.5px rgba(251, 55, 72, 0.10), 0 10px 10px -3.75px rgba(251, 55, 72, 0.04), 0 20px 50px 0 rgba(251, 55, 72, 0.05)', }, }; } return { className: 'border border-black/2 bg-white', style: { boxShadow: '0 0.602px 0.602px -1.25px rgba(0, 0, 0, 0.11), 0 2.289px 2.289px -2.5px rgba(0, 0, 0, 0.09), 0 10px 10px -3.75px rgba(0, 0, 0, 0.04)', }, }; } function PlanCard({ planKey, planConfig, selectedBillingInterval, currentPlan, isOnTrial, isSticky }: PlanCardProps) { const cardStyles = getCardStyles(planKey, currentPlan, isOnTrial); const features = getPlanHighlightFeatures(planKey as ApiServiceLevelEnum); return (
    {!isSticky && ( {features.map((feature, index) => ( ))} )}
    {planConfig.actionType === ActionType.BUTTON && ( )} {planConfig.actionType === ActionType.CONTACT && }
    ); } export function PlansRow({ selectedBillingInterval, currentPlan, plans, isOnTrial }: PlansRowProps) { const triggerRef = useRef(null); // Use Motion's useInView to detect when the trigger element is out of view // When it's out of view (above the viewport), we collapse the cards const isInView = useInView(triggerRef, { margin: '-100px 0px 0px 0px', // Trigger when element is 100px above viewport }); return ( <> {/* Invisible trigger element positioned before the sticky container */}
    {Object.entries(plans).map(([planKey, planConfig]) => ( ))}
    ); } ================================================ FILE: apps/dashboard/src/components/billing/utils/action.button.constants.ts ================================================ export enum ActionType { BUTTON = 'button', CONTACT = 'contact', } ================================================ FILE: apps/dashboard/src/components/command-palette/command-menu.tsx ================================================ 'use client'; import { type DialogProps } from '@radix-ui/react-dialog'; import { Command } from 'cmdk'; import * as React from 'react'; import { Dialog, DialogContent, DialogTitle } from '@/components/primitives/dialog'; import { VisuallyHidden } from '@/components/primitives/visually-hidden'; import { cn } from '@/utils/ui'; const CommandDialog = ({ children, className, overlayClassName, ...rest }: DialogProps & { className?: string; overlayClassName?: string; }) => { return ( button]:hidden', className )} > Command Palette [cmdk-label]+*]:border-t-0!' )} filter={(value, search, keywords) => { const extendValue = value + ' ' + (keywords?.join(' ') || ''); if (extendValue.includes(search)) return 1; return 0; }} > {children} ); }; const CommandInput = React.forwardRef< React.ComponentRef, React.ComponentPropsWithoutRef >(({ className, ...rest }, forwardedRef) => { return ( ); }); CommandInput.displayName = 'CommandInput'; const CommandList = React.forwardRef< React.ComponentRef, React.ComponentPropsWithoutRef >(({ className, ...rest }, forwardedRef) => { return ( ); }); CommandList.displayName = 'CommandList'; const CommandGroup = React.forwardRef< React.ComponentRef, React.ComponentPropsWithoutRef >(({ className, ...rest }, forwardedRef) => { return ( ); }); CommandGroup.displayName = 'CommandGroup'; const CommandItem = React.forwardRef< React.ComponentRef, React.ComponentPropsWithoutRef & { size?: 'small' | 'medium' } >(({ className, size = 'small', children, ...rest }, forwardedRef) => { const sizeClasses = { small: 'px-3 py-2', medium: 'px-3 py-3', }; return ( {children} ); }); CommandItem.displayName = 'CommandItem'; const CommandItemIcon = React.forwardRef>( ({ className, ...rest }, forwardedRef) => { return
    ; } ); CommandItemIcon.displayName = 'CommandItemIcon'; const CommandEmpty = React.forwardRef< React.ComponentRef, React.ComponentPropsWithoutRef >(({ className, ...rest }, forwardedRef) => { return ( ); }); CommandEmpty.displayName = 'CommandEmpty'; const CommandFooter = React.forwardRef>( ({ className, ...rest }, forwardedRef) => { return (
    ); } ); CommandFooter.displayName = 'CommandFooter'; const CommandFooterKeyBox = React.forwardRef>( ({ className, ...rest }, forwardedRef) => { return (
    ); } ); CommandFooterKeyBox.displayName = 'CommandFooterKeyBox'; export { CommandDialog as Dialog, CommandInput as Input, CommandList as List, CommandGroup as Group, CommandItem as Item, CommandItemIcon as ItemIcon, CommandEmpty as Empty, CommandFooter as Footer, CommandFooterKeyBox as FooterKeyBox, }; ================================================ FILE: apps/dashboard/src/components/command-palette/command-palette-provider.tsx ================================================ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { useEscapeKeyManager } from '@/context/escape-key-manager/hooks'; import { EscapeKeyManagerPriority } from '@/context/escape-key-manager/priority'; import { useTelemetry } from '@/hooks/use-telemetry'; import { TelemetryEvent } from '@/utils/telemetry'; type CommandPaletteContextType = { isOpen: boolean; openCommandPalette: () => void; closeCommandPalette: () => void; toggleCommandPalette: () => void; }; const CommandPaletteContext = createContext(null); export function CommandPaletteProvider({ children }: { children: React.ReactNode }) { const [isOpen, setIsOpen] = useState(false); const track = useTelemetry(); const openCommandPalette = useCallback(() => { setIsOpen(true); track(TelemetryEvent.COMMAND_PALETTE_OPENED); }, [track]); const closeCommandPalette = useCallback(() => { setIsOpen(false); }, []); const toggleCommandPalette = useCallback(() => { setIsOpen((prev) => { const newState = !prev; if (newState) { track(TelemetryEvent.COMMAND_PALETTE_OPENED); } return newState; }); }, [track]); // Register escape key handler with high priority useEscapeKeyManager('command-palette', closeCommandPalette, EscapeKeyManagerPriority.POPOVER, isOpen); // Global keyboard listener for ⌘K/Ctrl+K useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'k' && (event.metaKey || event.ctrlKey)) { event.preventDefault(); event.stopPropagation(); toggleCommandPalette(); } }; document.addEventListener('keydown', handleKeyDown, true); return () => { document.removeEventListener('keydown', handleKeyDown, true); }; }, [toggleCommandPalette]); const value = useMemo( () => ({ isOpen, openCommandPalette, closeCommandPalette, toggleCommandPalette, }), [isOpen, openCommandPalette, closeCommandPalette, toggleCommandPalette] ); return {children}; } export function useCommandPaletteContext() { const context = useContext(CommandPaletteContext); if (!context) { throw new Error('useCommandPaletteContext must be used within a CommandPaletteProvider'); } return context; } ================================================ FILE: apps/dashboard/src/components/command-palette/command-palette.tsx ================================================ import { useCommandState } from 'cmdk'; import { useCallback, useEffect, useState } from 'react'; import { RiArrowDownLine, RiArrowUpLine, RiCloseLine, RiCornerDownLeftLine, RiFileLine, RiFlashlightLine, RiPlayFill, RiQuestionLine, RiRouteFill, RiSearch2Line, RiSearchLine, RiSettings4Line, RiSparklingLine, RiUserLine, } from 'react-icons/ri'; import { useAiDrawer } from '@/components/ai-drawer'; import { useTelemetry } from '@/hooks/use-telemetry'; import { TelemetryEvent } from '@/utils/telemetry'; import { cn } from '@/utils/ui'; import { Button } from '../primitives/button'; import { Kbd } from '../primitives/kbd'; import * as CommandMenu from './command-menu'; import { CommandCategory, Command as CommandType } from './command-types'; import { useCommandPalette } from './hooks/use-command-palette'; import { useCommandRegistry } from './hooks/use-command-registry'; const CategoryIconWrapper = ({ children }: { children: React.ReactNode }) => { return (
    {children}
    ); }; const getDefaultIcon = (category: CommandCategory): React.ReactNode => { const defaultIcons: Record = { 'current-workflow': , workflow: , navigation: , data: , action: , search: , settings: , help: , }; return defaultIcons[category]; }; const getCategoryActionLabel = (category: CommandCategory | undefined, value: string): string => { const actionLabels: Record = { 'current-workflow': 'Execute action', workflow: 'Go to workflow', navigation: 'Navigate to', data: 'Open command', action: 'Execute action', search: 'Search for', settings: 'Open settings', help: 'Get help', }; if (value.includes('Ask AI')) { return 'Ask AI'; } else if (!category) { return 'Open Command'; } return actionLabels[category]; }; // Footer component that has access to command state function CommandFooter({ commands }: { commands: CommandType[] }) { const selectedValue = useCommandState((state) => state.value); const selectedCommand = commands.find((cmd) => `${cmd.label} ${cmd.keywords?.join(' ') || ''}` === selectedValue); return (
    Navigate
    ); } export function CommandPalette() { const { isOpen, closeCommandPalette } = useCommandPalette(); const { openAiDrawer } = useAiDrawer(); const track = useTelemetry(); const [search, setSearch] = useState(''); const commandGroups = useCommandRegistry(search); // Create a flat list of all commands for easy lookup const allCommands = commandGroups.flatMap((group) => group.commands); const hasInkeep = !!import.meta.env.VITE_INKEEP_API_KEY; // Reset search when dialog closes useEffect(() => { if (!isOpen) { setSearch(''); } }, [isOpen]); const openAiDrawerWithQuery = useCallback(() => { track(TelemetryEvent.COMMAND_PALETTE_COMMAND_SELECTED, { commandId: 'help-ai-search', commandLabel: `Ask AI "${search}"`, commandCategory: 'help', }); openAiDrawer(search); closeCommandPalette(); }, [search, openAiDrawer, closeCommandPalette, track]); const executeCommand = useCallback( async (command: CommandType) => { track(TelemetryEvent.COMMAND_PALETTE_COMMAND_SELECTED, { commandId: command.id, commandLabel: command.label, commandCategory: command.category, }); closeCommandPalette(); // Small delay to allow dialog to close smoothly setTimeout(async () => { try { await command.execute(); } catch (error) { console.error('Error executing command:', error); } }, 100); }, [closeCommandPalette, track] ); return (
    {commandGroups.map((group) => ( {group.commands.map((command) => { const isEnabled = command.isEnabled ? command.isEnabled() : true; return ( isEnabled && executeCommand(command)} disabled={!isEnabled} className="px-1.5 rounded-8" >
    {command.icon || getDefaultIcon(command.category)} {command.label}
    {command.metadata?.workflowId && ( {command.metadata.workflowId} )}
    ); })}
    ))} {hasInkeep && search.trim() && (
    Ask AI "{search}"
    )}
    ); } ================================================ FILE: apps/dashboard/src/components/command-palette/command-types.ts ================================================ import type { StepResponseDto, WorkflowResponseDto } from '@novu/shared'; import { ReactNode } from 'react'; export type CommandCategory = | 'navigation' | 'workflow' | 'current-workflow' | 'data' | 'action' | 'search' | 'settings' | 'help'; export type CommandPriority = 'high' | 'medium' | 'low'; export interface Command { id: string; label: string; description?: string; category: CommandCategory; keywords?: string[]; icon?: ReactNode; priority?: CommandPriority; metadata?: { slug?: string; workflowId?: string; [key: string]: unknown; }; execute: () => void | Promise; isVisible?: () => boolean; isEnabled?: () => boolean; } export interface CommandGroup { category: CommandCategory; label: string; commands: Command[]; } export interface CommandPaletteState { isOpen: boolean; search: string; selectedIndex: number; } export type CommandExecutionContext = { currentPath: string; environmentSlug?: string; organizationId?: string; searchQuery?: string; workflowContext?: { workflow?: WorkflowResponseDto; step?: StepResponseDto; isInWorkflowEditor?: boolean; isPending?: boolean; }; }; ================================================ FILE: apps/dashboard/src/components/command-palette/commands/action-commands.tsx ================================================ import { LuBookUp2 } from 'react-icons/lu'; import { useAuth } from '@/context/auth/hooks'; import { useEnvironment, useFetchEnvironments } from '@/context/environment/hooks'; import { Command, CommandExecutionContext } from '../command-types'; const DEVELOPMENT_ENVIRONMENT = 'Development'; export function useActionCommands(_context: CommandExecutionContext): Command[] { const { currentOrganization } = useAuth(); const { currentEnvironment } = useEnvironment(); const { environments = [] } = useFetchEnvironments({ organizationId: currentOrganization?._id }); const commands: Command[] = []; // Only show publish command in development environment const isDevelopmentEnvironment = currentEnvironment?.name === DEVELOPMENT_ENVIRONMENT; const targetEnvironment = environments.find((env) => env._id !== currentEnvironment?._id); if (isDevelopmentEnvironment && targetEnvironment) { commands.push({ id: 'action-open-publish-modal', label: 'Open publish changes modal', description: 'Open the modal to publish changes to production', category: 'action', icon: , priority: 'high', keywords: ['publish', 'changes', 'modal', 'production', 'deploy'], execute: () => { // Trigger a custom event that the publish button can listen to window.dispatchEvent( new CustomEvent('open-publish-modal', { detail: { targetEnvironment }, }) ); }, isVisible: () => isDevelopmentEnvironment && !!targetEnvironment, }); } return commands; } ================================================ FILE: apps/dashboard/src/components/command-palette/commands/environment-commands.tsx ================================================ import { RiDatabase2Line, RiGlobalLine } from 'react-icons/ri'; import { useNavigate } from 'react-router-dom'; import { useEnvironment } from '@/context/environment/hooks'; import { buildRoute, ROUTES } from '@/utils/routes'; import { Command, CommandExecutionContext } from '../command-types'; export function useEnvironmentCommands(_context: CommandExecutionContext): Command[] { const { currentEnvironment, environments, switchEnvironment } = useEnvironment(); const navigate = useNavigate(); const commands: Command[] = []; // Only show environment switching if there are multiple environments if (environments && environments.length > 1) { for (const environment of environments) { if (environment.slug === currentEnvironment?.slug) { continue; } commands.push({ id: `env-switch-${environment.slug}`, label: `Switch to ${environment.name}`, description: `Switch to the ${environment.name} environment`, category: 'action', icon: environment.name === 'Production' ? : , priority: 'high', keywords: ['environment', 'switch', environment.name.toLowerCase(), 'env'], execute: () => { switchEnvironment(environment.slug); if (environment.slug) { navigate(buildRoute(ROUTES.WORKFLOWS, { environmentSlug: environment.slug })); } }, isVisible: () => environment.slug !== currentEnvironment?.slug, }); } } return commands; } ================================================ FILE: apps/dashboard/src/components/command-palette/commands/help-commands.tsx ================================================ import { RiBookOpenLine, RiChat1Line, RiQuestionLine, RiSparklingLine } from 'react-icons/ri'; import { useAiDrawer } from '@/components/ai-drawer'; import { useTelemetry } from '@/hooks/use-telemetry'; import { TelemetryEvent } from '@/utils/telemetry'; import { Command, CommandExecutionContext } from '../command-types'; export function useHelpCommands(_context: CommandExecutionContext): Command[] { const track = useTelemetry(); const { openAiDrawer } = useAiDrawer(); const commands: Command[] = [ { id: 'help-docs', label: 'Open Documentation', description: 'View the Novu documentation', category: 'help', icon: , priority: 'medium', keywords: ['docs', 'documentation', 'help', 'guide'], execute: () => { window.open('https://docs.novu.co', '_blank'); }, }, { id: 'help-feedback', label: 'Share Feedback', description: 'Send feedback or get help from our team', category: 'help', icon: , priority: 'medium', keywords: ['feedback', 'support', 'help', 'chat'], execute: () => { track(TelemetryEvent.SHARE_FEEDBACK_LINK_CLICKED); try { window?.Plain?.open(); } catch (error) { console.error('Error opening Plain chat:', error); } }, }, ]; if (import.meta.env.VITE_INKEEP_API_KEY) { commands.push({ id: 'help-ai-search', label: 'Ask Novu AI', description: 'Get instant answers powered by AI', category: 'help', icon: , priority: 'high', keywords: ['ai', 'ask', 'search', 'help', 'question', 'assistant', 'inkeep'], execute: () => { openAiDrawer(); }, }); } return commands; } ================================================ FILE: apps/dashboard/src/components/command-palette/commands/navigation-commands.tsx ================================================ import { PermissionsEnum } from '@novu/shared'; import { useCallback } from 'react'; import { RiBarChartBoxLine, RiDatabase2Line, RiDiscussLine, RiGroup2Line, RiKey2Line, RiLayout5Line, RiRouteFill, RiSettings4Line, RiSignalTowerLine, RiTranslate2, } from 'react-icons/ri'; import { useNavigate } from 'react-router-dom'; import { IS_ENTERPRISE, IS_SELF_HOSTED } from '@/config'; import { useHasPermission } from '@/hooks/use-has-permission'; import { buildRoute, ROUTES } from '@/utils/routes'; import { Command, CommandExecutionContext } from '../command-types'; export function useNavigationCommands(context: CommandExecutionContext): Command[] { const navigate = useNavigate(); const hasPermission = useHasPermission(); const hasWorkflowPermission = hasPermission({ permission: PermissionsEnum.WORKFLOW_READ }); const hasSubscriberPermission = hasPermission({ permission: PermissionsEnum.SUBSCRIBER_READ }); const isEnterprise = !IS_SELF_HOSTED || IS_ENTERPRISE; const createNavigationCommand = useCallback( (id: string, label: string, route: string, icon: React.ReactNode, permission?: () => boolean) => ({ id, label: `Go to ${label}`, description: `Navigate to the ${label.toLowerCase()} page`, category: 'navigation' as const, icon, priority: 'high' as const, keywords: [label.toLowerCase(), 'go', 'navigate'], execute: () => { const finalRoute = route.includes(':environmentSlug') ? buildRoute(route, { environmentSlug: context.environmentSlug || '' }) : route; navigate(finalRoute); }, isVisible: permission || (() => true), }), [navigate, context.environmentSlug] ); const commands: Command[] = []; // Core navigation commands if (hasWorkflowPermission) { commands.push( createNavigationCommand( 'nav-workflows', 'Workflows', ROUTES.WORKFLOWS, , () => hasWorkflowPermission ) ); } if (hasSubscriberPermission) { commands.push( createNavigationCommand( 'nav-subscribers', 'Subscribers', ROUTES.SUBSCRIBERS, , () => hasSubscriberPermission ) ); } // Activity navigation commands.push( createNavigationCommand('nav-activity', 'Activity', ROUTES.ACTIVITY_WORKFLOW_RUNS, ) ); // Integrations commands.push( createNavigationCommand('nav-integrations', 'Integrations', ROUTES.INTEGRATIONS, ) ); // API Keys commands.push(createNavigationCommand('nav-api-keys', 'API Keys', ROUTES.API_KEYS, )); // Settings commands.push(createNavigationCommand('nav-settings', 'Settings', ROUTES.SETTINGS, )); // Topics commands.push(createNavigationCommand('nav-topics', 'Topics', ROUTES.TOPICS, )); // Environments commands.push(createNavigationCommand('nav-environments', 'Environments', ROUTES.ENVIRONMENTS, )); // Layouts commands.push(createNavigationCommand('nav-layouts', 'Email Layouts', ROUTES.LAYOUTS, )); if (isEnterprise) { commands.push( createNavigationCommand( 'nav-translations', 'Translations', ROUTES.TRANSLATIONS, , () => isEnterprise ) ); } return commands; } ================================================ FILE: apps/dashboard/src/components/command-palette/commands/settings-commands.tsx ================================================ import { PermissionsEnum } from '@novu/shared'; import { RiDatabase2Line, RiMoneyDollarCircleLine, RiSettings4Line, RiUserAddLine } from 'react-icons/ri'; import { useNavigate } from 'react-router-dom'; import { IS_SELF_HOSTED } from '@/config'; import { useHasPermission } from '@/hooks/use-has-permission'; import { ROUTES } from '@/utils/routes'; import { Command, CommandExecutionContext } from '../command-types'; export function useSettingsCommands(_context: CommandExecutionContext): Command[] { const navigate = useNavigate(); const hasPermission = useHasPermission(); const hasBillingPermission = hasPermission({ permission: PermissionsEnum.BILLING_WRITE }); const canShowBilling = !IS_SELF_HOSTED && hasBillingPermission; const commands: Command[] = [ { id: 'settings-account', label: 'Account Settings', description: 'Manage your account preferences', category: 'settings', icon: , priority: 'medium', keywords: ['account', 'profile', 'settings'], execute: () => navigate(ROUTES.SETTINGS_ACCOUNT), }, { id: 'settings-organization', label: 'Organization Settings', description: 'Manage organization settings and preferences', category: 'settings', icon: , priority: 'medium', keywords: ['organization', 'org', 'settings'], execute: () => navigate(ROUTES.SETTINGS_ORGANIZATION), }, { id: 'settings-team', label: 'Team Settings', description: 'Manage team members and permissions', category: 'settings', icon: , priority: 'medium', keywords: ['team', 'members', 'invite', 'settings'], execute: () => navigate(ROUTES.SETTINGS_TEAM), }, ]; if (canShowBilling) { commands.push({ id: 'settings-billing', label: 'Billing Settings', description: 'Manage billing and subscription settings', category: 'settings', icon: , priority: 'medium', keywords: ['billing', 'subscription', 'payment', 'invoice', 'settings'], execute: () => navigate(ROUTES.SETTINGS_BILLING), }); } return commands; } ================================================ FILE: apps/dashboard/src/components/command-palette/commands/subscriber-commands.tsx ================================================ import { PermissionsEnum } from '@novu/shared'; import { RiDiscussLine, RiUserAddLine } from 'react-icons/ri'; import { useNavigate } from 'react-router-dom'; import { useHasPermission } from '@/hooks/use-has-permission'; import { buildRoute, ROUTES } from '@/utils/routes'; import { Command, CommandExecutionContext } from '../command-types'; export function useSubscriberCommands(context: CommandExecutionContext): Command[] { const navigate = useNavigate(); const hasPermission = useHasPermission(); const hasSubscriberWrite = hasPermission({ permission: PermissionsEnum.SUBSCRIBER_WRITE }); const commands: Command[] = []; if (hasSubscriberWrite && context.environmentSlug) { // Create new subscriber commands.push({ id: 'subscriber-create', label: 'Create New Subscriber', description: 'Add a new subscriber to your environment', category: 'data', icon: , priority: 'high', keywords: ['create', 'new', 'subscriber', 'add', 'user'], execute: () => { if (context.environmentSlug) { navigate(buildRoute(ROUTES.CREATE_SUBSCRIBER, { environmentSlug: context.environmentSlug })); } }, isVisible: () => hasSubscriberWrite && !!context.environmentSlug, }); // Create new topic commands.push({ id: 'topic-create', label: 'Create New Topic', description: 'Create a new topic for subscriber management', category: 'data', icon: , priority: 'medium', keywords: ['create', 'new', 'topic', 'add'], execute: () => { if (context.environmentSlug) { navigate(buildRoute(ROUTES.TOPICS_CREATE, { environmentSlug: context.environmentSlug })); } }, isVisible: () => hasSubscriberWrite && !!context.environmentSlug, }); } return commands; } ================================================ FILE: apps/dashboard/src/components/command-palette/commands/workflow-commands.tsx ================================================ import { PermissionsEnum, EnvironmentTypeEnum } from '@novu/shared'; import { RiFileAddLine, RiRouteFill } from 'react-icons/ri'; import { useNavigate } from 'react-router-dom'; import { useFetchWorkflows } from '@/hooks/use-fetch-workflows'; import { useHasPermission } from '@/hooks/use-has-permission'; import { useEnvironment } from '@/context/environment/hooks'; import { buildRoute, ROUTES } from '@/utils/routes'; import { Command, CommandExecutionContext } from '../command-types'; export function useWorkflowCommands(context: CommandExecutionContext): Command[] { const navigate = useNavigate(); const hasWorkflowWrite = useHasPermission(); const { currentEnvironment } = useEnvironment(); const { data: workflowsData } = useFetchWorkflows({ limit: 50, offset: 0, }); const commands: Command[] = []; // Create new workflow - only show in development environment if ( hasWorkflowWrite({ permission: PermissionsEnum.WORKFLOW_WRITE }) && context.environmentSlug && currentEnvironment?.type === EnvironmentTypeEnum.DEV ) { commands.push({ id: 'workflow-create', label: 'Create New Workflow', description: 'Create a new workflow from scratch', category: 'workflow', icon: , priority: 'high', keywords: ['create', 'new', 'workflow', 'add'], execute: () => { if (context.environmentSlug) { navigate(buildRoute(ROUTES.WORKFLOWS_CREATE, { environmentSlug: context.environmentSlug })); } }, isVisible: () => hasWorkflowWrite({ permission: PermissionsEnum.WORKFLOW_WRITE }) && !!context.environmentSlug && currentEnvironment?.type === EnvironmentTypeEnum.DEV, }); } // Add individual workflow commands (will only show when searching) if (context.environmentSlug && workflowsData?.workflows) { for (const workflow of workflowsData.workflows) { commands.push({ id: `workflow-edit-${workflow.workflowId}`, label: workflow.name, description: `Open ${workflow.name} workflow for editing`, category: 'workflow', icon: , priority: 'low', // Lower priority so main workflow commands appear first keywords: ['edit', 'workflow', workflow.name, workflow.workflowId, 'open'], metadata: { slug: workflow.slug, workflowId: workflow.workflowId, }, execute: () => { if (context.environmentSlug && workflow.slug) { navigate( buildRoute(ROUTES.EDIT_WORKFLOW, { environmentSlug: context.environmentSlug, workflowSlug: workflow.slug, }) ); } }, }); } } return commands; } ================================================ FILE: apps/dashboard/src/components/command-palette/commands/workflow-editor-commands.tsx ================================================ import type { StepResponseDto } from '@novu/shared'; import { RiEditLine, RiPlayFill, RiSettings4Line } from 'react-icons/ri'; import { useNavigate } from 'react-router-dom'; import { StepTypeEnum } from '@/utils/enums'; import { buildRoute, ROUTES } from '@/utils/routes'; import { Command, CommandExecutionContext } from '../command-types'; const DELIVERY_CHANNEL_STEPS = [ StepTypeEnum.EMAIL, StepTypeEnum.SMS, StepTypeEnum.PUSH, StepTypeEnum.IN_APP, StepTypeEnum.CHAT, ]; function isDeliveryChannelStep(stepType: string): boolean { return DELIVERY_CHANNEL_STEPS.includes(stepType as StepTypeEnum); } export function useWorkflowEditorCommands(context: CommandExecutionContext): Command[] { const navigate = useNavigate(); const commands: Command[] = []; const { workflowContext } = context; const { workflow, isInWorkflowEditor } = workflowContext || {}; // Early return if not in workflow editor context if (!isInWorkflowEditor || !context.environmentSlug || !workflow) { return commands; } commands.push({ id: 'trigger-current-workflow', label: `Trigger current workflow`, description: `Test and trigger the ${workflow.name} workflow`, category: 'current-workflow', icon: , priority: 'high', keywords: ['trigger', 'test', 'run', workflow.name, 'workflow'], execute: () => { if (context.environmentSlug) { navigate( buildRoute(ROUTES.TRIGGER_WORKFLOW, { environmentSlug: context.environmentSlug, workflowSlug: workflow.slug, }) ); } }, }); // Workflow preferences command commands.push({ id: 'edit-workflow-preferences', label: `Edit workflow preferences`, description: `Configure preferences for the workflow`, category: 'current-workflow', icon: , priority: 'medium', keywords: ['preferences', 'settings', 'configure', workflow.name], execute: () => { if (context.environmentSlug) { navigate( buildRoute(ROUTES.EDIT_WORKFLOW, { environmentSlug: context.environmentSlug, workflowSlug: workflow.slug, }) + '/preferences' ); } }, }); // Edit step commands for each step if (workflow.steps && Array.isArray(workflow.steps) && workflow.steps.length > 0) { for (const workflowStep of workflow.steps as StepResponseDto[]) { // Skip if step doesn't have required properties if (!workflowStep.stepId || !workflowStep.slug) { continue; } const stepName = workflowStep.name || `${workflowStep.type} step`; commands.push({ id: `edit-step-${workflowStep.stepId}`, label: `Edit ${stepName}`, description: `Edit the ${stepName} configuration`, category: 'current-workflow', icon: , priority: 'medium', keywords: ['edit', 'step', stepName, workflowStep.type], metadata: { stepId: workflowStep.stepId, stepSlug: workflowStep.slug, }, execute: () => { if (context.environmentSlug) { const basePath = buildRoute(ROUTES.EDIT_WORKFLOW, { environmentSlug: context.environmentSlug, workflowSlug: workflow.slug, }) + `/steps/${workflowStep.slug}`; const finalPath = isDeliveryChannelStep(workflowStep.type) ? `${basePath}/editor` : basePath; navigate(finalPath); } }, }); } } return commands; } ================================================ FILE: apps/dashboard/src/components/command-palette/hooks/use-command-palette.ts ================================================ import { useCommandPaletteContext } from '../command-palette-provider'; export function useCommandPalette() { return useCommandPaletteContext(); } ================================================ FILE: apps/dashboard/src/components/command-palette/hooks/use-command-registry.ts ================================================ import { useMemo } from 'react'; import { useLocation } from 'react-router-dom'; import { useEnvironment } from '@/context/environment/hooks'; import { Command, CommandCategory, CommandExecutionContext, CommandGroup } from '../command-types'; import { useActionCommands } from '../commands/action-commands'; import { useEnvironmentCommands } from '../commands/environment-commands'; import { useHelpCommands } from '../commands/help-commands'; import { useNavigationCommands } from '../commands/navigation-commands'; import { useSettingsCommands } from '../commands/settings-commands'; import { useSubscriberCommands } from '../commands/subscriber-commands'; import { useWorkflowCommands } from '../commands/workflow-commands'; import { useWorkflowEditorCommands } from '../commands/workflow-editor-commands'; import { useWorkflowEditorContext } from './use-workflow-editor-context'; export function useCommandRegistry(searchQuery = ''): CommandGroup[] { const location = useLocation(); const { currentEnvironment } = useEnvironment(); const workflowEditorContext = useWorkflowEditorContext(); const context: CommandExecutionContext = { currentPath: location.pathname, environmentSlug: currentEnvironment?.slug, organizationId: currentEnvironment?._organizationId, searchQuery, workflowContext: workflowEditorContext, }; const actionCommands = useActionCommands(context); const navigationCommands = useNavigationCommands(context); const workflowCommands = useWorkflowCommands(context); const workflowEditorCommands = useWorkflowEditorCommands(context); const subscriberCommands = useSubscriberCommands(context); const environmentCommands = useEnvironmentCommands(context); const settingsCommands = useSettingsCommands(context); const helpCommands = useHelpCommands(context); const commandGroups = useMemo(() => { const allCommands: Command[] = [ ...actionCommands, ...workflowCommands, ...workflowEditorCommands, ...navigationCommands, ...subscriberCommands, ...environmentCommands, ...settingsCommands, ...helpCommands, ]; const visibleCommands = allCommands.filter((command) => (command.isVisible ? command.isVisible() : true)); const isSearching = searchQuery.trim().length > 0; const maxItemsPerCategory = isSearching ? Infinity : 5; const groups: CommandGroup[] = []; const categoryOrder: CommandCategory[] = [ 'current-workflow', 'workflow', 'action', 'navigation', 'data', 'settings', 'search', 'help', ]; const availableCategories = Array.from(new Set(visibleCommands.map((cmd) => cmd.category))); // Sort categories by predefined order, with any unlisted categories at the end const sortedCategories = categoryOrder .filter((cat) => availableCategories.includes(cat)) .concat(availableCategories.filter((cat) => !categoryOrder.includes(cat))); for (const category of sortedCategories) { const commands = visibleCommands.filter((cmd) => cmd.category === category); if (commands.length > 0) { const sortedCommands = commands.sort((a, b) => { // Sort by priority first, then alphabetically const priorityOrder = { high: 0, medium: 1, low: 2 }; const aPriority = priorityOrder[a.priority || 'medium']; const bPriority = priorityOrder[b.priority || 'medium']; if (aPriority !== bPriority) { return aPriority - bPriority; } return a.label.localeCompare(b.label); }); // Limit commands per category when not searching const limitedCommands = sortedCommands.slice(0, maxItemsPerCategory); groups.push({ category, label: getCategoryLabel(category), commands: limitedCommands, }); } } return groups; }, [ actionCommands, navigationCommands, workflowCommands, workflowEditorCommands, subscriberCommands, environmentCommands, settingsCommands, helpCommands, searchQuery, ]); return commandGroups; } function getCategoryLabel(category: string): string { const labels: Record = { 'current-workflow': 'Current Workflow Actions', navigation: 'Navigation', workflow: 'Workflows', data: 'Data', action: 'Actions', search: 'Search', settings: 'Settings', help: 'Help & Support', }; return labels[category] || category; } ================================================ FILE: apps/dashboard/src/components/command-palette/hooks/use-workflow-editor-context.ts ================================================ import { useLocation, useParams } from 'react-router-dom'; import { useFetchWorkflow } from '@/hooks/use-fetch-workflow'; export function useWorkflowEditorContext() { const location = useLocation(); const params = useParams<{ workflowSlug?: string; stepSlug?: string }>(); const isOnWorkflowEditorPath = location.pathname.includes('/workflows/') && !location.pathname.includes('/workflows/create') && !location.pathname.includes('/workflows/templates'); const workflowSlug = params.workflowSlug; const isNewWorkflowSlug = workflowSlug === 'new'; const { workflow: fetchedWorkflow, isPending: fetchIsPending } = useFetchWorkflow({ workflowSlug: isOnWorkflowEditorPath && !isNewWorkflowSlug ? workflowSlug : undefined, }); const workflow = fetchedWorkflow; const isInWorkflowEditor = isOnWorkflowEditorPath; return { isInWorkflowEditor, workflow: isInWorkflowEditor ? workflow : undefined, isPending: fetchIsPending, }; } ================================================ FILE: apps/dashboard/src/components/command-palette/index.ts ================================================ export * as CommandMenu from './command-menu'; export { CommandPalette } from './command-palette'; export type { Command, CommandCategory, CommandGroup } from './command-types'; export { useEnvironmentCommands } from './commands/environment-commands'; export { useCommandPalette } from './hooks/use-command-palette'; ================================================ FILE: apps/dashboard/src/components/conditions-editor/add-condition-action.tsx ================================================ import { RiAddFill } from 'react-icons/ri'; import { ActionWithRulesAndAddersProps } from 'react-querybuilder'; import { Button } from '@/components/primitives/button'; export const AddConditionAction = ({ label, title, rules, handleOnClick, context }: ActionWithRulesAndAddersProps) => { if (rules && rules.length >= 10) { return null; } return ( ); }; ================================================ FILE: apps/dashboard/src/components/conditions-editor/add-group-action.tsx ================================================ import { ActionWithRulesAndAddersProps } from 'react-querybuilder'; import { StackedPlusLine } from '@/components/icons/stacked-plus-line'; import { Button } from '@/components/primitives/button'; export const AddGroupAction = ({ label, title, level, rules, handleOnClick, context, }: ActionWithRulesAndAddersProps) => { if (level === 1 || (rules && rules.length >= 10)) { return null; } return ( ); }; ================================================ FILE: apps/dashboard/src/components/conditions-editor/combinator-selector.tsx ================================================ import { type CombinatorSelectorProps } from 'react-querybuilder'; import { fromSafeValue, toSafeValue, toSelectOptions } from '@/components/conditions-editor/select-option-utils'; import { Select, SelectContent, SelectTrigger, SelectValue } from '@/components/primitives/select'; import { cn } from '@/utils/ui'; export const CombinatorSelector = ({ disabled, value, options, handleOnChange, context }: CombinatorSelectorProps) => { return ( ); }; ================================================ FILE: apps/dashboard/src/components/conditions-editor/conditions-editor-context.tsx ================================================ import { createContext, useCallback, useContext, useMemo } from 'react'; import { add, isRuleGroup, Path, RuleGroupType, RuleGroupTypeAny, RuleType, remove } from 'react-querybuilder'; import { useDataRef } from '@/hooks/use-data-ref'; import { generateUUID } from '@/utils/uuid'; import { ConditionsEditorContextType } from './types'; export const ConditionsEditorContext = createContext({ removeRuleOrGroup: () => {}, cloneRuleOrGroup: () => {}, getParentGroup: () => null, }); export function ConditionsEditorProvider({ children, query, onQueryChange, }: { children: React.ReactNode; query: RuleGroupType; onQueryChange: (query: RuleGroupType) => void; }) { const queryRef = useDataRef(query); const queryChangeRef = useDataRef(onQueryChange); const removeRuleOrGroup = useCallback( (path: Path) => { queryChangeRef.current(remove(queryRef.current, path)); }, [queryChangeRef, queryRef] ); const cloneRuleOrGroup = useCallback( (ruleOrGroup: RuleGroupTypeAny | RuleType, path: Path = []) => { queryChangeRef.current(add(queryRef.current, { ...ruleOrGroup, id: generateUUID() } as RuleType, path)); }, [queryChangeRef, queryRef] ); const getParentGroup = useCallback( (id?: string) => { if (!id) return queryRef.current; const findParent = (group: RuleGroupTypeAny): RuleGroupTypeAny | null => { for (const rule of group.rules) { if (typeof rule !== 'string' && rule.id === id) { return group; } if (isRuleGroup(rule)) { const parent = findParent(rule); if (parent) { return parent; } } } return null; }; return findParent(queryRef.current); }, [queryRef] ); const contextValue = useMemo( () => ({ removeRuleOrGroup, cloneRuleOrGroup, getParentGroup }), [removeRuleOrGroup, cloneRuleOrGroup, getParentGroup] ); return {children}; } export const useConditionsEditorContext = () => useContext(ConditionsEditorContext); ================================================ FILE: apps/dashboard/src/components/conditions-editor/conditions-editor.tsx ================================================ import { useCallback, useMemo } from 'react'; import { type Field, QueryBuilder, RuleGroupType, Translations } from 'react-querybuilder'; import 'react-querybuilder/dist/query-builder.css'; import { AddConditionAction } from '@/components/conditions-editor/add-condition-action'; import { AddGroupAction } from '@/components/conditions-editor/add-group-action'; import { CombinatorSelector } from '@/components/conditions-editor/combinator-selector'; import { ConditionsEditorProvider } from '@/components/conditions-editor/conditions-editor-context'; import { FieldSelector } from '@/components/conditions-editor/field-selector'; import { getHelpTextForField, getPlaceholderForField, getValueEditorTypeForField, } from '@/components/conditions-editor/field-type-editors'; import { getOperatorsForFieldType } from '@/components/conditions-editor/field-type-operators'; import { OperatorSelector } from '@/components/conditions-editor/operator-selector'; import { RuleActions } from '@/components/conditions-editor/rule-actions'; import { ValueEditor } from '@/components/conditions-editor/value-editor'; import { EnhancedLiquidVariable, type FieldDataType, IsAllowedVariable, LiquidVariable, } from '@/utils/parseStepVariables'; export interface EnhancedField extends Field { dataType: FieldDataType; inputType?: string; format?: string; } const ruleActionsClassName = `*:data-[actions="true"]:opacity-0! [&:hover>[data-actions="true"]]:opacity-100! [&>[data-actions="true"]:has(~[data-radix-popper-content-wrapper])]:opacity-100!`; const groupActionsClassName = `[&_.ruleGroup-header>[data-actions="true"]]:opacity-0! [&_.ruleGroup-header:hover>[data-actions="true"]]:opacity-100! [&_.ruleGroup-header>[data-actions="true"]:has(~[data-radix-popper-content-wrapper])]:opacity-100!`; const nestedGroupClassName = `[&.ruleGroup_.ruleGroup]:p-3! [&.ruleGroup_.ruleGroup]:bg-neutral-50! [&.ruleGroup_.ruleGroup]:rounded-md! [&.ruleGroup_.ruleGroup]:border! [&.ruleGroup_.ruleGroup]:border-solid! [&.ruleGroup_.ruleGroup]:border-neutral-100!`; const ruleGroupClassName = `[&.ruleGroup]:bg-transparent! [&.ruleGroup]:border-none! [&.ruleGroup]:p-0! ${nestedGroupClassName} [&_.ruleGroup-body_.rule]:items-start! ${groupActionsClassName}`; const ruleClassName = `${ruleActionsClassName}`; const controlClassnames = { ruleGroup: ruleGroupClassName, rule: ruleClassName, queryBuilder: 'queryBuilder-branches [&_.rule]:before:border-stroke-soft! [&_.rule]:after:border-stroke-soft! [&_.ruleGroup_.ruleGroup]:before:border-stroke-soft! [&_.ruleGroup_.ruleGroup]:after:border-stroke-soft!', }; const translations: Partial = { addRule: { label: 'Add condition', title: 'Add condition', }, addGroup: { label: 'Add group', title: 'Add group', }, }; const controlElements = { operatorSelector: OperatorSelector, combinatorSelector: CombinatorSelector, fieldSelector: FieldSelector, valueEditor: ValueEditor, addRuleAction: AddConditionAction, addGroupAction: AddGroupAction, removeGroupAction: RuleActions, removeRuleAction: RuleActions, cloneGroupAction: null, cloneRuleAction: null, }; const accessibleDescriptionGenerator = () => ''; function InternalConditionsEditor({ fields, variables, isAllowedVariable, query, onQueryChange, saveForm, enhancedVariables, }: { fields: EnhancedField[]; variables: LiquidVariable[]; isAllowedVariable: IsAllowedVariable; query: RuleGroupType; onQueryChange: (query: RuleGroupType) => void; saveForm: () => void; enhancedVariables?: EnhancedLiquidVariable[]; }) { const fieldDataMap = useMemo(() => { if (!enhancedVariables) return new Map(); return new Map( enhancedVariables.map((variable) => [ variable.name, { name: variable.name, label: variable.displayLabel || variable.name, value: variable.name, dataType: variable.dataType, inputType: variable.inputType, format: variable.format, }, ]) ); }, [enhancedVariables]); const getOperators = useCallback( (fieldName: string) => { if (!enhancedVariables) { // Fallback to default string operators for variables not found in schema return getOperatorsForFieldType('string'); } const fieldData = fieldDataMap.get(fieldName); if (!fieldData) { // Fallback to default string operators for variables not found in schema return getOperatorsForFieldType('string'); } return getOperatorsForFieldType(fieldData.dataType); }, [fieldDataMap, enhancedVariables] ); const getValueEditorType = useCallback((fieldName: string, operator: string) => { return getValueEditorTypeForField(fieldName, operator); }, []); // Add new functions for placeholder and help text const getPlaceholder = useCallback( (fieldName: string, operator: string) => { if (!enhancedVariables) { // Fallback to default placeholder for variables not found in schema return getPlaceholderForField(fieldName, operator, { fieldData: { name: fieldName, label: fieldName, value: fieldName, dataType: 'string', } as EnhancedField, }); } const fieldData = fieldDataMap.get(fieldName); if (!fieldData) { // Fallback to default placeholder for variables not found in schema return getPlaceholderForField(fieldName, operator, { fieldData: { name: fieldName, label: fieldName, value: fieldName, dataType: 'string', } as EnhancedField, }); } return getPlaceholderForField(fieldName, operator, { fieldData }); }, [fieldDataMap, enhancedVariables] ); const getHelpText = useCallback( (fieldName: string, operator: string) => { if (!enhancedVariables) { // Fallback to default help text for variables not found in schema return getHelpTextForField(operator, { fieldData: { name: fieldName, label: fieldName, value: fieldName, dataType: 'string', }, }); } const fieldData = fieldDataMap.get(fieldName); if (!fieldData) { // Fallback to default help text for variables not found in schema return getHelpTextForField(operator, { fieldData: { name: fieldName, label: fieldName, value: fieldName, dataType: 'string', }, }); } return getHelpTextForField(operator, { fieldData }); }, [fieldDataMap, enhancedVariables] ); const context = useMemo( () => ({ variables, isAllowedVariable, saveForm, getPlaceholder, getHelpText, }), [variables, isAllowedVariable, saveForm, getPlaceholder, getHelpText] ); return ( ); } export type ConditionsEditorContext = { variables: LiquidVariable[]; isAllowedVariable: IsAllowedVariable; saveForm: () => void; getPlaceholder?: (fieldName: string, operator: string) => string; getHelpText?: ( fieldName: string, operator: string ) => { title: string; description: string; examples: string[]; notes?: string[] }; }; export function ConditionsEditor({ query, onQueryChange, fields, saveForm, variables, isAllowedVariable, enhancedVariables, }: { query: RuleGroupType; onQueryChange: (query: RuleGroupType) => void; fields: EnhancedField[]; saveForm: () => void; variables: LiquidVariable[]; isAllowedVariable: IsAllowedVariable; enhancedVariables?: EnhancedLiquidVariable[]; }) { return ( ); } ================================================ FILE: apps/dashboard/src/components/conditions-editor/field-selector.tsx ================================================ import React, { useMemo } from 'react'; import { useFormContext } from 'react-hook-form'; import { FieldSelectorProps } from 'react-querybuilder'; import { VariableSelect } from '@/components/conditions-editor/variable-select'; import { Code2 } from '@/components/icons/code-2'; export const FieldSelector = React.memo( ({ handleOnChange, options, path, value, disabled, context }: FieldSelectorProps) => { const form = useFormContext(); const queryPath = 'query.rules.' + path.join('.rules.') + '.field'; const { error } = form.getFieldState(queryPath, form.formState); const optionsArray = useMemo( () => options.map((option) => ({ label: option.label, value: 'value' in option ? option.value : '', })), [options] ); return ( } onChange={(e) => { handleOnChange(e); context?.saveForm(); }} options={optionsArray} title="Fields" value={value} disabled={disabled} error={error?.message} /> ); }, (prevProps, nextProps) => { return ( prevProps.value === nextProps.value && prevProps.path === nextProps.path && prevProps.disabled === nextProps.disabled && prevProps.options === nextProps.options && prevProps.handleOnChange === nextProps.handleOnChange ); } ); ================================================ FILE: apps/dashboard/src/components/conditions-editor/field-type-editors.ts ================================================ import type { ValueEditorType } from 'react-querybuilder'; import type { EnhancedField } from '@/components/conditions-editor/conditions-editor'; import { isRelativeDateOperator } from './field-type-operators'; export function getValueEditorTypeForField(fieldName: string, operator: string): ValueEditorType { if (operator === 'null' || operator === 'notNull') { return null; } // Always return text for all field types this allows both values and variables return 'text'; } export function shouldUseRelativeDateEditor(operator: string): boolean { return isRelativeDateOperator(operator); } export function getPlaceholderForField( fieldName: string, operator: string, { fieldData }: { fieldData: EnhancedField } ): string { const { dataType } = fieldData; // Handle between operators with two values if (operator === 'between' || operator === 'notBetween') { switch (dataType) { case 'number': return '0, 100'; case 'date': case 'datetime': return '2024-01-01T00:00:00Z, 2024-12-31T23:59:59Z'; default: return 'value1, value2'; } } if (operator === 'in' || operator === 'notIn') { switch (dataType) { case 'number': return '1, 2, 3'; case 'boolean': return 'true, false'; case 'date': case 'datetime': return '2024-01-01T00:00:00Z, 2024-06-01T12:00:00Z'; default: return 'value1, value2, value3'; } } // Single value placeholders switch (dataType) { case 'string': return operator === 'contains' || operator === 'doesNotContain' ? 'search text' : 'text value'; case 'number': return '42'; case 'boolean': return 'true'; case 'date': case 'datetime': return '2024-01-01T00:00:00Z'; case 'array': if (operator === 'contains') return 'item'; if (operator === 'containsAny' || operator === 'doesNotContainAny') return 'item1, item2, item3'; return 'item1, item2'; case 'object': return '{"key": "value"}'; default: return 'value'; } } export type HelpTextInfo = { title: string; description: string; examples: string[]; }; export function getHelpTextForField(operator: string, { fieldData }: { fieldData: EnhancedField }): HelpTextInfo { const { dataType } = fieldData; // Handle between operators if (operator === 'between' || operator === 'notBetween') { const action = operator === 'between' ? 'between' : 'not between'; switch (dataType) { case 'number': return { title: `Number ${action}`, description: `Check if the number is ${action} two values (inclusive). Uses two separate input fields. You can also use dynamic values from the payload.`, examples: ['First: 10, Second: 50', 'Dynamic: {{payload.minPrice}}'], }; case 'date': case 'datetime': return { title: `Date ${action}`, description: `Check if the date is ${action} two dates. Use ISO 8601 format (YYYY-MM-DDTHH:mm:ssZ). Uses two separate input fields. You can also use dynamic values from the payload.`, examples: ['First: 2024-01-01T00:00:00Z', 'Dynamic: {{payload.startDate}}'], }; default: return { title: `Value ${action}`, description: `Check if the value is ${action} two values. Uses two separate input fields. You can also use dynamic values from the payload.`, examples: ['First: value1, Second: value2', 'Dynamic: {{payload.minValue}}'], }; } } // Handle relative date operators if (operator === 'moreThanXAgo') { return { title: 'More than X time ago', description: 'Check if the date occurred more than the specified amount of time ago. Uses current time as reference.', examples: ['5 days ago', '2 hours ago', '1 week ago'], }; } if (operator === 'lessThanXAgo') { return { title: 'Less than X time ago', description: 'Check if the date occurred less than the specified amount of time ago. Uses current time as reference.', examples: ['3 days ago', '30 minutes ago', '6 months ago'], }; } if (operator === 'withinLast') { return { title: 'Within last X time', description: 'Check if the date occurred within the last specified amount of time. Excludes future dates.', examples: ['within last 7 days', 'within last 24 hours', 'within last 1 year'], }; } if (operator === 'notWithinLast') { return { title: 'Not within last X time', description: 'Check if the date did NOT occur within the last specified amount of time.', examples: ['not within last 30 days', 'not within last 2 weeks'], }; } if (operator === 'exactlyXAgo') { return { title: 'Exactly X time ago', description: 'Check if the date occurred exactly the specified amount of time ago (with tolerance based on time unit).', examples: ['exactly 1 day ago', 'exactly 2 hours ago'], }; } // Handle in/notIn operators if (operator === 'in' || operator === 'notIn') { const action = operator === 'in' ? 'matches any of' : 'does not match any of'; switch (dataType) { case 'number': return { title: `Number ${action}`, description: `Check if the number ${action} the provided values. Separate multiple values with commas. You can also use dynamic values from the payload.`, examples: ['1, 2, 3, 4', '{{payload.allowedIds}}'], }; case 'boolean': return { title: `Boolean ${action}`, description: `Check if the boolean ${action} the provided values. Separate multiple values with commas. You can also use dynamic values from the payload.`, examples: ['true, false', '{{payload.isActive}}'], }; case 'date': case 'datetime': return { title: `Date ${action}`, description: `Check if the date ${action} the provided dates. Use ISO 8601 format (YYYY-MM-DDTHH:mm:ssZ). Separate multiple dates with commas. You can also use dynamic values from the payload.`, examples: ['2024-01-01T00:00:00Z, 2024-06-01T12:00:00Z', '{{payload.eventDate}}'], }; default: return { title: `Value ${action}`, description: `Check if the value ${action} the provided values. Separate multiple values with commas. You can also use dynamic values from the payload.`, examples: ['value1, value2, value3', '{{payload.category}}'], }; } } // Single value operators switch (dataType) { case 'string': switch (operator) { case 'contains': return { title: 'String contains', description: 'Check if the string contains the specified text (case-sensitive). You can also use dynamic values from the payload.', examples: ['hello', '{{payload.searchTerm}}'], }; case 'doesNotContain': return { title: 'String does not contain', description: 'Check if the string does not contain the specified text (case-sensitive). You can also use dynamic values from the payload.', examples: ['spam', '{{payload.blockedWord}}'], }; case 'beginsWith': return { title: 'String begins with', description: 'Check if the string starts with the specified text (case-sensitive). You can also use dynamic values from the payload.', examples: ['Hello', '{{payload.prefix}}'], }; case 'endsWith': return { title: 'String ends with', description: 'Check if the string ends with the specified text (case-sensitive). You can also use dynamic values from the payload.', examples: ['.com', '{{payload.domain}}'], }; default: return { title: 'String comparison', description: 'Compare the string value with the provided text. You can also use dynamic values from the payload.', examples: ['Hello World', '{{payload.message}}'], }; } case 'number': return { title: 'Number comparison', description: 'Compare the number with the provided value. You can also use dynamic values from the payload.', examples: ['42', '{{payload.age}}'], }; case 'boolean': return { title: 'Boolean comparison', description: 'Compare the boolean value. Use "true" or "false". You can also use dynamic values from the payload.', examples: ['true', '{{payload.isActive}}'], }; case 'date': case 'datetime': return { title: 'Date comparison', description: 'Compare dates using ISO 8601 format (YYYY-MM-DDTHH:mm:ssZ). Time zone is UTC. You can also use dynamic values from the payload.', examples: ['2024-01-01T00:00:00Z', '{{payload.eventDate}}'], }; case 'array': if (operator === 'contains') { return { title: 'Array contains', description: 'Check if the array contains the specified item. You can also use dynamic values from the payload.', examples: ['item1', '{{payload.requiredTag}}'], }; } if (operator === 'containsAny') { return { title: 'Array contains any of', description: 'Check if the array contains at least one of the specified items. Separate multiple values with commas. You can also use dynamic values from the payload.', examples: ['tag1, tag2, tag3', '{{subscriber.data.tags}}'], }; } if (operator === 'doesNotContainAny') { return { title: 'Array does not contain any of', description: 'Check if the array does not contain any of the specified items. Separate multiple values with commas. You can also use dynamic values from the payload.', examples: ['tag1, tag2, tag3', '{{subscriber.data.tags}}'], }; } return { title: 'Array comparison', description: 'Compare array values. For multiple items, separate with commas. You can also use dynamic values from the payload.', examples: ['item1, item2', '{{payload.tags}}'], }; case 'object': return { title: 'Object comparison', description: 'Compare object values using JSON format. You can also use dynamic values from the payload.', examples: ['{"key": "value"}', '{{payload.metadata}}'], }; default: return { title: 'Value comparison', description: 'Compare the field value with the provided input. You can also use dynamic values from the payload.', examples: ['example value', '{{payload.customField}}'], }; } } ================================================ FILE: apps/dashboard/src/components/conditions-editor/field-type-operators.ts ================================================ import type { Operator } from 'react-querybuilder'; import type { FieldDataType } from '@/utils/parseStepVariables'; export const FIELD_TYPE_OPERATORS: Record = { string: [ { name: '=', label: 'equals' }, { name: '!=', label: 'does not equal' }, { name: 'contains', label: 'contains' }, { name: 'beginsWith', label: 'begins with' }, { name: 'endsWith', label: 'ends with' }, { name: 'doesNotContain', label: 'does not contain' }, { name: 'doesNotBeginWith', label: 'does not begin with' }, { name: 'doesNotEndWith', label: 'does not end with' }, { name: 'null', label: 'is null' }, { name: 'notNull', label: 'is not null' }, { name: 'in', label: 'in' }, { name: 'notIn', label: 'not in' }, ], number: [ { name: '=', label: 'equals' }, { name: '!=', label: 'does not equal' }, { name: '<', label: 'less than' }, { name: '<=', label: 'less than or equal to' }, { name: '>', label: 'greater than' }, { name: '>=', label: 'greater than or equal to' }, { name: 'between', label: 'between' }, { name: 'notBetween', label: 'not between' }, { name: 'null', label: 'is null' }, { name: 'notNull', label: 'is not null' }, ], boolean: [ { name: '=', label: 'is' }, { name: '!=', label: 'is not' }, { name: 'null', label: 'is null' }, { name: 'notNull', label: 'is not null' }, ], date: [ { name: '=', label: 'on' }, { name: '!=', label: 'not on' }, { name: '<', label: 'before' }, { name: '<=', label: 'on or before' }, { name: '>', label: 'after' }, { name: '>=', label: 'on or after' }, { name: 'between', label: 'between' }, { name: 'notBetween', label: 'not between' }, { name: 'moreThanXAgo', label: 'more than X ago' }, { name: 'lessThanXAgo', label: 'less than X ago' }, { name: 'withinLast', label: 'within last' }, { name: 'notWithinLast', label: 'not within last' }, { name: 'exactlyXAgo', label: 'exactly X ago' }, { name: 'null', label: 'is null' }, { name: 'notNull', label: 'is not null' }, ], datetime: [ { name: '=', label: 'at' }, { name: '!=', label: 'not at' }, { name: '<', label: 'before' }, { name: '<=', label: 'at or before' }, { name: '>', label: 'after' }, { name: '>=', label: 'at or after' }, { name: 'between', label: 'between' }, { name: 'notBetween', label: 'not between' }, { name: 'moreThanXAgo', label: 'more than X ago' }, { name: 'lessThanXAgo', label: 'less than X ago' }, { name: 'withinLast', label: 'within last' }, { name: 'notWithinLast', label: 'not within last' }, { name: 'exactlyXAgo', label: 'exactly X ago' }, { name: 'null', label: 'is null' }, { name: 'notNull', label: 'is not null' }, ], array: [ { name: 'contains', label: 'contains' }, { name: 'doesNotContain', label: 'does not contain' }, { name: 'containsAny', label: 'contains any of' }, { name: 'doesNotContainAny', label: 'does not contain any of' }, { name: 'null', label: 'is null' }, { name: 'notNull', label: 'is not null' }, ], object: [ { name: 'null', label: 'is null' }, { name: 'notNull', label: 'is not null' }, ], }; export function getOperatorsForFieldType(dataType: FieldDataType): Operator[] { return FIELD_TYPE_OPERATORS[dataType] || FIELD_TYPE_OPERATORS.string; } export const RELATIVE_DATE_OPERATORS = [ 'moreThanXAgo', 'lessThanXAgo', 'withinLast', 'notWithinLast', 'exactlyXAgo', ] as const; export function isRelativeDateOperator(operator: string): boolean { return RELATIVE_DATE_OPERATORS.includes(operator as any); } ================================================ FILE: apps/dashboard/src/components/conditions-editor/help-icon.tsx ================================================ import { RiErrorWarningLine, RiInformation2Line } from 'react-icons/ri'; import type { HelpTextInfo } from '@/components/conditions-editor/field-type-editors'; import { Badge } from '@/components/primitives/badge'; import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/primitives/hover-card'; type HelpIconProps = { hasError: boolean; errorMessage?: string; helpText?: HelpTextInfo | null; contentWidth?: string; }; export function HelpIcon({ hasError, errorMessage, helpText, contentWidth = 'w-[240px]' }: HelpIconProps) { if (!helpText && !hasError) return null; const IconComponent = hasError ? RiErrorWarningLine : RiInformation2Line; const iconColor = hasError ? 'text-destructive' : 'text-foreground-400 hover:text-foreground-600'; return (
    {/* Error content (shown above info when present) */} {hasError && errorMessage && ( <>
    {errorMessage}
    {helpText &&
    } )} {helpText && ( <>
    💡 TIP
    {helpText.description}
    {helpText.examples.map((example, idx) => (
    {example}
    ))}
    )}
    ); } ================================================ FILE: apps/dashboard/src/components/conditions-editor/operator-selector.tsx ================================================ import React from 'react'; import { OperatorSelectorProps } from 'react-querybuilder'; import { fromSafeValue, toSafeValue, toSelectOptions } from '@/components/conditions-editor/select-option-utils'; import { Select, SelectContent, SelectTrigger, SelectValue } from '@/components/primitives/select'; import { cn } from '@/utils/ui'; export const OperatorSelector = React.memo( ({ disabled, value, options, handleOnChange, context }: OperatorSelectorProps) => { return ( ); }, (prevProps, nextProps) => { return ( prevProps.value === nextProps.value && prevProps.disabled === nextProps.disabled && prevProps.options === nextProps.options && prevProps.handleOnChange === nextProps.handleOnChange ); } ); ================================================ FILE: apps/dashboard/src/components/conditions-editor/rule-actions.tsx ================================================ import React, { useMemo } from 'react'; import { RiMore2Fill } from 'react-icons/ri'; import { ActionWithRulesProps, getParentPath, isRuleGroup } from 'react-querybuilder'; import { Delete } from '@/components/icons/delete'; import { SquareTwoStack } from '@/components/icons/square-two-stack'; import { CompactButton } from '@/components/primitives/button-compact'; import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/primitives/dropdown-menu'; import { Tooltip, TooltipContent, TooltipPortal, TooltipTrigger } from '@/components/primitives/tooltip'; import { useConditionsEditorContext } from './conditions-editor-context'; export const RuleActions = React.memo( ({ path, ruleOrGroup, context }: ActionWithRulesProps) => { const { removeRuleOrGroup, cloneRuleOrGroup, getParentGroup } = useConditionsEditorContext(); const parentGroup = useMemo(() => getParentGroup(ruleOrGroup.id), [ruleOrGroup, getParentGroup]); const isGroup = isRuleGroup(ruleOrGroup); const isDuplicateDisabled = !!(parentGroup && parentGroup.rules && parentGroup.rules.length >= 10); return ( { cloneRuleOrGroup(ruleOrGroup, getParentPath(path)); context?.saveForm(); }} className="text-foreground-600 text-label-xs h-7" disabled={isDuplicateDisabled} > Duplicate {isGroup ? `group` : `condition`} {isDuplicateDisabled && ( You cannot duplicate more than 10 groups or conditions )} { removeRuleOrGroup(path); context?.saveForm(); }} className="text-error-base text-label-xs h-7" > Delete {isGroup ? `group` : `condition`} ); }, (prevProps, nextProps) => { return prevProps.path === nextProps.path && prevProps.ruleOrGroup === nextProps.ruleOrGroup; } ); ================================================ FILE: apps/dashboard/src/components/conditions-editor/select-option-utils.tsx ================================================ import { BaseOption, isOptionGroupArray, OptionList } from 'react-querybuilder'; import { SelectGroup, SelectItem, SelectLabel } from '@/components/primitives/select'; import { capitalize } from '@/utils/string'; export const EMPTY_SELECT_VALUE = '__empty__'; export function toSafeValue(value: string | null | undefined): string { if (!value) return EMPTY_SELECT_VALUE; return value; } export function fromSafeValue(value: string): string { if (value === EMPTY_SELECT_VALUE) return ''; return value; } export const toSelectOptions = (arr: OptionList, capitalizeLabel: boolean = true) => { if (isOptionGroupArray(arr)) { return arr.map((group) => ( {group.label} {group.options.map((option) => ( {capitalizeLabel ? capitalize(option.label.toLocaleLowerCase()) : option.label} ))} )); } return (arr as BaseOption[]).map((option) => ( {capitalizeLabel ? capitalize(option.label.toLocaleLowerCase()) : option.label} )); }; ================================================ FILE: apps/dashboard/src/components/conditions-editor/types.ts ================================================ import { BaseOption, Path, RuleGroupTypeAny, RuleType } from 'react-querybuilder'; export interface ConditionsEditorContextType { removeRuleOrGroup: (path: Path) => void; cloneRuleOrGroup: (ruleOrGroup: RuleGroupTypeAny | RuleType, path?: Path) => void; getParentGroup: (id?: string) => RuleGroupTypeAny | null; } export interface VariablesListProps { options: Array>; onSelect: (value: string) => void; value?: string; } ================================================ FILE: apps/dashboard/src/components/conditions-editor/value-editor.tsx ================================================ import { useFormContext } from 'react-hook-form'; import { useValueEditor, ValueEditorProps } from 'react-querybuilder'; import type { HelpTextInfo } from '@/components/conditions-editor/field-type-editors'; import { shouldUseRelativeDateEditor } from '@/components/conditions-editor/field-type-editors'; import { HelpIcon } from '@/components/conditions-editor/help-icon'; import { InputRoot, InputWrapper } from '@/components/primitives/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/primitives/select'; import { ControlInput } from '@/components/workflow-editor/control-input'; import { IsAllowedVariable, LiquidVariable } from '@/utils/parseStepVariables'; type RelativeDateValue = { amount: number | string; unit: 'minutes' | 'hours' | 'days' | 'weeks' | 'months' | 'years'; }; type ExtendedContext = { variables: LiquidVariable[]; isAllowedVariable: IsAllowedVariable; getPlaceholder?: (fieldName: string, operator: string) => string; getHelpText?: (fieldName: string, operator: string) => HelpTextInfo; }; const TIME_UNITS = [ { value: 'minutes', label: 'minutes' }, { value: 'hours', label: 'hours' }, { value: 'days', label: 'days' }, { value: 'weeks', label: 'weeks' }, { value: 'months', label: 'months' }, { value: 'years', label: 'years' }, ] as const; type BaseEditorProps = { value: string; onChange: (newValue: string) => void; placeholder: string; variables: LiquidVariable[]; isAllowedVariable: IsAllowedVariable; hasError: boolean; helpText: HelpTextInfo | null; errorMessage?: string; }; export const ValueEditor = (props: ValueEditorProps) => { const form = useFormContext(); const queryPath = 'query.rules.' + props.path.join('.rules.') + '.value'; const { error } = form.getFieldState(queryPath, form.formState); const { variables = [], isAllowedVariable, getPlaceholder, getHelpText } = (props.context as ExtendedContext) ?? {}; const { value, handleOnChange, operator, field } = props; const { valueAsArray, multiValueHandler } = useValueEditor(props); const stringValue = typeof value === 'string' ? value : `${value}`; const stringValueAsArray = valueAsArray.map((v) => (typeof v === 'string' ? v : `${v}`)); if (operator === 'null' || operator === 'notNull') { return null; } const placeholder = getPlaceholder ? getPlaceholder(field, operator) : 'value'; const helpText = getHelpText ? getHelpText(field, operator) : null; const hasError = !!error; if (shouldUseRelativeDateEditor(operator)) { return ( true)} hasError={hasError} helpText={helpText} errorMessage={error?.message} /> ); } if (operator === 'between' || operator === 'notBetween') { return ( ); } return ( ); }; function SingleValueEditor({ value, onChange, placeholder, variables, isAllowedVariable, hasError, helpText, errorMessage, }: BaseEditorProps) { return ( ); } function BetweenValueEditor({ valueAsArray, multiValueHandler, placeholder, variables, isAllowedVariable, hasError, helpText, errorMessage, }: { valueAsArray: string[]; multiValueHandler: (value: string, index: number) => void; placeholder: string; variables: LiquidVariable[]; isAllowedVariable: IsAllowedVariable; hasError: boolean; helpText: HelpTextInfo | null; errorMessage?: string; }) { const [fromPlaceholder, toPlaceholder] = placeholder.split(',').map((p) => p.trim()); const editors = ['from', 'to'].map((key, i) => { const hasInputError = hasError && !valueAsArray[i]; const isLastInput = i === 1; return ( multiValueHandler(newValue, i)} variables={variables} isAllowedVariable={isAllowedVariable} size="3xs" /> {isLastInput && } ); }); return (
    {editors[0]} and {editors[1]}
    ); } function RelativeDateEditor({ value, onChange, variables, isAllowedVariable, hasError, helpText, errorMessage, }: { value: string; onChange: (newValue: string) => void; variables: LiquidVariable[]; isAllowedVariable: IsAllowedVariable; hasError: boolean; helpText: HelpTextInfo | null; errorMessage?: string; }) { const parseRelativeDateValue = (val: string): RelativeDateValue => { let parsedValue: RelativeDateValue = { amount: '', unit: 'days' }; if (!val) { return parsedValue; } try { if (typeof val === 'string') { // Try to parse as JSON first const parsed = JSON.parse(val); if (parsed && typeof parsed === 'object' && parsed.unit) { // Valid JSON object with unit property parsedValue = { amount: parsed.amount, unit: parsed.unit || 'days', }; } else { // If parsed value is not a valid relative date object, treat as raw amount parsedValue = { amount: parsed, unit: 'days' }; } } else if (typeof val === 'object' && val) { parsedValue = val as RelativeDateValue; } } catch { // JSON parsing failed - treat the entire value as the amount // This handles cases where the value is just a liquid variable like "{{payload.amount}}" parsedValue = { amount: val, unit: 'days' }; } return parsedValue; }; const parsedValue = parseRelativeDateValue(value); const handleAmountChange = (newAmount: string) => { // If it's a variable or dynamic value, store it directly without validation if (newAmount.includes('{{') || newAmount.includes('${')) { const newValue = { ...parsedValue, amount: newAmount }; const jsonValue = JSON.stringify(newValue); onChange(jsonValue); return; } // For static values, try to parse as number but allow any string const amount = parseInt(newAmount, 10); const finalAmount = !isNaN(amount) && amount > 0 ? amount : newAmount; const newValue = { ...parsedValue, amount: finalAmount }; const jsonValue = JSON.stringify(newValue); onChange(jsonValue); }; const handleUnitChange = (newUnit: string) => { const newValue = { ...parsedValue, unit: newUnit as RelativeDateValue['unit'] }; const jsonValue = JSON.stringify(newValue); onChange(jsonValue); }; return (
    true)} size="3xs" />
    ); } ================================================ FILE: apps/dashboard/src/components/conditions-editor/variable-select.tsx ================================================ import React, { HTMLAttributes, useMemo, useRef, useState } from 'react'; import { InputPure, InputRoot, InputWrapper } from '@/components/primitives/input'; import { Popover, PopoverAnchor, PopoverContent } from '@/components/primitives/popover'; import { VariableList, VariableListRef } from '@/components/variable/variable-list'; import { AUTOCOMPLETE_PASSWORD_MANAGERS_OFF } from '@/utils/constants'; import { cn } from '@/utils/ui'; type VariableSelectProps = Omit, 'onChange' | 'defaultValue'> & { disabled?: boolean; value?: string; defaultValue?: string; options: Array<{ label: string; value: string }>; onChange: (value: string) => void; onInputChange?: (value: string) => void; leftIcon?: React.ReactNode; title?: string; placeholder?: string; error?: string; emptyState?: React.ReactNode; isClearable?: boolean; }; /** * A searchable dropdown component for selecting variables with keyboard navigation support. * * Features: * - Filterable options list * - Keyboard navigation (↑/↓ arrows) * - Auto-creation of new options when typing custom values * - Visual feedback for selected items * - Support for custom left icon * - Empty state when no variables are available */ export const VariableSelect = (props: VariableSelectProps) => { const { className, disabled, value, options, onChange, onInputChange, leftIcon, title = 'Variables', error, placeholder, emptyState, isClearable = false, defaultValue, ...rest } = props; const [inputValue, setInputValue] = useState(value ?? defaultValue ?? ''); const [filterValue, setFilterValue] = useState(''); const [isOpen, setIsOpen] = useState(false); const variablesListRef = useRef(null); const filteredOptions = useMemo(() => { if (!filterValue) { return options; } return options.filter((option) => option.value?.toLocaleLowerCase().includes(filterValue.toLocaleLowerCase())); }, [options, filterValue]); const inputRef = useRef(null); const onInputChangeHandler = (e: React.ChangeEvent) => { const newValue = e.target.value.trim(); if (newValue !== inputValue) { setInputValue(newValue); setFilterValue(newValue); onInputChange?.(newValue); } }; const onInputKeyDown = (e: React.KeyboardEvent) => { setIsOpen(true); if (e.key === 'ArrowDown') { variablesListRef.current?.next(); e.preventDefault(); } else if (e.key === 'ArrowUp') { variablesListRef.current?.prev(); e.preventDefault(); } else if (e.key === 'Enter') { variablesListRef.current?.select(); } }; const onSelect = (newValue: string) => { setIsOpen(false); setFilterValue(''); setInputValue(newValue); onChange(newValue); }; const onOpen = () => { setIsOpen(true); inputRef.current?.focus(); }; const onClose = () => { setIsOpen(false); setFilterValue(''); let newInputValue = ''; if (inputValue !== '' || (inputValue === '' && isClearable)) { newInputValue = inputValue; } else { newInputValue = value ?? ''; } setInputValue(newInputValue); onChange(newInputValue); }; const onFocusCapture = () => { variablesListRef.current?.focusFirst(); }; return (
    { if (!open) { onClose(); } }} >
    {leftIcon}
    {filteredOptions.length > 0 && ( { // prevent the input from being blurred when the popover opens e.preventDefault(); }} onFocusOutside={onClose} > )} {filteredOptions.length === 0 && !inputValue && emptyState && ( { // prevent the input from being blurred when the popover opens e.preventDefault(); }} onFocusOutside={onClose} > {emptyState} )}
    {error && {error}}
    ); }; ================================================ FILE: apps/dashboard/src/components/confirmation-modal.tsx ================================================ import { Cross2Icon } from '@radix-ui/react-icons'; import { ReactNode } from 'react'; import { IconType } from 'react-icons'; import { RiAlertFill } from 'react-icons/ri'; import { Button } from '@/components/primitives/button'; import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogOverlay, DialogPortal, DialogTitle, } from '@/components/primitives/dialog'; type ConfirmationModalProps = { open: boolean; onOpenChange: (open: boolean) => void; onConfirm: () => void; title: string; description: ReactNode; confirmButtonText: string; confirmTrailingIcon?: IconType; isLoading?: boolean; isConfirmDisabled?: boolean; confirmButtonVariant?: 'primary' | 'error'; }; export const ConfirmationModal = ({ open, onOpenChange, onConfirm, title, description, confirmButtonText, confirmTrailingIcon, isLoading, isConfirmDisabled, confirmButtonVariant = 'primary', }: ConfirmationModalProps) => { return (
    Close
    {title} {description}
    {/*
    */}
    ); }; ================================================ FILE: apps/dashboard/src/components/context-search-editor.tsx ================================================ import { GetContextResponseDto } from '@novu/api/models/components'; import { ContextPayload } from '@novu/shared'; import { JSONSchema7 } from 'json-schema'; import { useCallback, useState } from 'react'; import { useFetchContexts } from '@/hooks/use-fetch-contexts'; import { Autocomplete } from './primitives/autocomplete'; import { ACCORDION_STYLES } from './workflow-editor/steps/constants/preview-context.constants'; import { EditableJsonViewer } from './workflow-editor/steps/shared/editable-json-viewer/editable-json-viewer'; type ContextSearchEditorProps = { value: unknown; onUpdate: (updatedData: ContextPayload) => void; schema?: JSONSchema7; error?: string; }; export function ContextSearchEditor({ value, onUpdate, schema, error }: ContextSearchEditorProps) { const [searchQuery, setSearchQuery] = useState(''); const { data: contextsData, isLoading } = useFetchContexts({ limit: 20, search: searchQuery.length >= 2 ? searchQuery : undefined, }); const contexts = contextsData?.data || []; const displayValue = value || {}; const handleSelectContext = useCallback( (selectedContext: GetContextResponseDto) => { // Add the selected context to the existing context structure by its type const currentContext = value || {}; const updatedContext = { ...currentContext, [selectedContext.type]: { id: selectedContext.id, data: selectedContext.data || {}, }, }; onUpdate(updatedContext); setSearchQuery(''); }, [onUpdate, value] ); const handleContextChange = useCallback( (updatedData: unknown) => { onUpdate(updatedData || {}); }, [onUpdate] ); return (
    ({ ...context, id: `${context.type}:${context.id}` }))} isLoading={isLoading} hasSearched={searchQuery.length >= 2} onSelectItem={(item) => { const originalContext = contexts.find((c) => `${c.type}:${c.id}` === item.id); if (originalContext) { handleSelectContext(originalContext); } }} size="xs" placeholder="Search contexts by type or ID..." sectionTitle="Contexts" emptyStateTitle="No contexts found" emptyStateDescription="Try a different search term" renderItem={(item) => { const originalContext = contexts.find((c) => `${c.type}:${c.id}` === item.id); if (!originalContext) return null; return (
    {originalContext.id} ({originalContext.type})
    {originalContext.data && Object.keys(originalContext.data).length > 0 && ( {Object.keys(originalContext.data).join(', ')} )}
    ); }} />
    {error &&

    {error}

    }
    ); } ================================================ FILE: apps/dashboard/src/components/contexts/context-activity.tsx ================================================ import { ContextId, ContextType, createContextKey, FeatureFlagsKeysEnum } from '@novu/shared'; import { AnimatePresence } from 'motion/react'; import { useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; import { ActivityFilters } from '@/components/activity/activity-filters'; import { defaultActivityFilters } from '@/components/activity/constants'; import { ActivityDetailsDrawer } from '@/components/subscribers/subscriber-activity-drawer'; import { SubscriberActivityList } from '@/components/subscribers/subscriber-activity-list'; import { useEnvironment } from '@/context/environment/hooks'; import { useFeatureFlag } from '@/hooks/use-feature-flag'; import { useFetchActivities } from '@/hooks/use-fetch-activities'; import { ActivityFiltersData } from '@/types/activity'; import { buildRoute, ROUTES } from '@/utils/routes'; const getInitialFilters = (contextKey: string, dateRange?: string): ActivityFiltersData => ({ ...defaultActivityFilters, dateRange: dateRange || '24h', contextKeys: [contextKey], }); export const ContextActivity = ({ type, id }: { type: ContextType; id: ContextId }) => { const { currentEnvironment } = useEnvironment(); const isHttpLogsPageEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_HTTP_LOGS_PAGE_ENABLED, false); const contextKey = createContextKey(type, id); const [filters, setFilters] = useState(() => getInitialFilters(contextKey)); const [activityItemId, setActivityItemId] = useState(''); const { activities, isLoading } = useFetchActivities( { filters, page: 0, limit: 50, }, { refetchOnWindowFocus: false, } ); const handleClearFilters = () => { setFilters(getInitialFilters(contextKey)); }; const hasChangesInFilters = useMemo(() => { return ( filters.channels.length > 0 || filters.workflows.length > 0 || filters.transactionId !== defaultActivityFilters.transactionId || filters.subscriberId !== defaultActivityFilters.subscriberId || filters.topicKey !== defaultActivityFilters.topicKey || filters.severity.length > 0 ); }, [filters]); const searchParams = useMemo(() => { const params = new URLSearchParams(); if (filters.workflows.length > 0) { params.set('workflows', filters.workflows.join(',')); } if (filters.channels.length > 0) { params.set('channels', filters.channels.join(',')); } if (filters.transactionId) { params.set('transactionId', filters.transactionId); } if (filters.subscriberId) { params.set('subscriberId', filters.subscriberId); } if (filters.topicKey) { params.set('topicKey', filters.topicKey); } if (filters.severity.length > 0) { params.set('severity', filters.severity.join(',')); } if (filters.contextKeys.length > 0) { for (const contextKey of filters.contextKeys) { params.append('contextKeys', contextKey); } } return params; }, [filters]); const handleActivitySelect = (activityId: string) => { setActivityItemId(activityId); }; return (
    To view more detailed activity, View{' '} Activity Feed {' '} page.
    ); }; ================================================ FILE: apps/dashboard/src/components/contexts/context-drawer.tsx ================================================ import { ContextId, ContextType, createContextKey } from '@novu/shared'; import React, { forwardRef, useState } from 'react'; import { RiBuildingLine } from 'react-icons/ri'; import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/primitives/sheet'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/primitives/tabs'; import { TooltipProvider } from '@/components/primitives/tooltip'; import { VisuallyHidden } from '@/components/primitives/visually-hidden'; import TruncatedText from '@/components/truncated-text'; import { useFormProtection } from '@/hooks/use-form-protection'; import { cn } from '@/utils/ui'; import { ContextActivity } from './context-activity'; import { ContextOverview } from './context-overview'; const tabTriggerClasses = 'hover:data-[state=inactive]:text-foreground-950 h-11 py-3 rounded-none [&>span]:h-5 px-0 relative'; type ContextTabsProps = { type: ContextType; id: ContextId; readOnly?: boolean; }; function ContextTabs(props: ContextTabsProps) { const { type, id, readOnly = false } = props; const contextKey = createContextKey(type, id); const [tab, setTab] = useState('overview'); const { protectedOnValueChange, ProtectionAlert, ref: protectionRef, } = useFormProtection({ onValueChange: setTab, }); return (
    Context - {contextKey}
    Overview Activity Feed {ProtectionAlert}
    ); } type ContextDrawerProps = { open: boolean; onOpenChange: (open: boolean) => void; type: ContextType; id: ContextId; readOnly?: boolean; }; export const ContextDrawer = forwardRef((props, forwardedRef) => { const { open, onOpenChange, type, id, readOnly = false } = props; return ( {/* Custom overlay since SheetOverlay does not work with modal={false} */}
    ); }); type ContextDrawerButtonProps = React.ButtonHTMLAttributes & { contextKey: string; readOnly?: boolean; }; export const ContextDrawerButton = (props: ContextDrawerButtonProps) => { const { contextKey, onClick, readOnly = false, ...rest } = props; const [open, setOpen] = useState(false); // Parse context key to extract type and id const [type, id] = contextKey.split(':') as [ContextType, ContextId]; return ( <>
    )} Are you sure you want to delete context {context.id}? This action cannot be undone. } confirmButtonText="Delete context" isLoading={isDeleting} />
    ); }; export const ContextOverview = (props: ContextOverviewProps) => { const { type, id, readOnly = false } = props; const { data, isPending, error } = useFetchContext({ type, id }); if (isPending) { return ; } if (error) { return ; } if (!data) { return ; } return ; }; ================================================ FILE: apps/dashboard/src/components/contexts/context-row.tsx ================================================ import { GetContextResponseDto } from '@novu/api/models/components'; import { PermissionsEnum } from '@novu/shared'; import { ComponentProps, useState } from 'react'; import { RiDeleteBin2Line, RiMore2Fill, RiPulseFill } from 'react-icons/ri'; import { Link } from 'react-router-dom'; import { ConfirmationModal } from '@/components/confirmation-modal'; import { CompactButton } from '@/components/primitives/button-compact'; import { CopyButton } from '@/components/primitives/copy-button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/primitives/dropdown-menu'; import { Skeleton } from '@/components/primitives/skeleton'; import { TableCell, TableRow } from '@/components/primitives/table'; import { TimeDisplayHoverCard } from '@/components/time-display-hover-card'; import { useEnvironment } from '@/context/environment/hooks'; import { useDeleteContext } from '@/hooks/use-delete-context'; import { formatDateSimple } from '@/utils/format-date'; import { Protect } from '@/utils/protect'; import { buildRoute, ROUTES } from '@/utils/routes'; import { cn } from '@/utils/ui'; type ContextRowProps = { context: GetContextResponseDto; }; type ContextTableCellProps = ComponentProps & { to?: string; }; const ContextTableCell = (props: ContextTableCellProps) => { const { children, className, to, ...rest } = props; return ( {to && ( Edit context )} {children} ); }; export const ContextRow = ({ context }: ContextRowProps) => { const { currentEnvironment } = useEnvironment(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const { deleteContext, isPending: isDeleting } = useDeleteContext(); const contextLink = buildRoute(ROUTES.CONTEXTS_EDIT, { environmentSlug: currentEnvironment?.slug ?? '', type: context.type, id: context.id, }); const stopPropagation = (e: React.MouseEvent) => { e.stopPropagation(); }; const handleDeletion = async () => { try { await deleteContext({ type: context.type, id: context.id, }); setIsDeleteModalOpen(false); } catch { // Error is already handled by the useDeleteContext hook } }; return ( <> {context.type}
    {context.id}
    {context.createdAt && ( {formatDateSimple(context.createdAt)} )} {context.updatedAt && ( {formatDateSimple(context.updatedAt)} )} View activity { setTimeout(() => setIsDeleteModalOpen(true), 0); }} > Delete context
    Are you sure you want to delete context {context.id}? This action cannot be undone. } confirmButtonText="Delete context" isLoading={isDeleting} /> ); }; export const ContextRowSkeleton = () => { return ( ); }; ================================================ FILE: apps/dashboard/src/components/contexts/contexts-filters.tsx ================================================ import { useQueryClient } from '@tanstack/react-query'; import { HTMLAttributes, useCallback, useEffect, useMemo, useRef } from 'react'; import { useForm } from 'react-hook-form'; import { RiLoader4Line } from 'react-icons/ri'; import { Button } from '@/components/primitives/button'; import { FacetedFormFilter } from '@/components/primitives/form/faceted-filter/facated-form-filter'; import { Form, FormField, FormItem, FormRoot } from '@/components/primitives/form/form'; import { QueryKeys } from '@/utils/query-keys'; import { cn } from '@/utils/ui'; import { ContextsFilter } from './hooks/use-contexts-url-state'; type FilterFormValues = { search: string; }; export type ContextsFiltersProps = HTMLAttributes & { onFiltersChange: (filter: Partial) => void; filterValues: ContextsFilter; onReset?: () => void; isLoading?: boolean; isFetching?: boolean; }; export const ContextsFilters = (props: ContextsFiltersProps) => { const { className, onFiltersChange, filterValues, onReset, isLoading, isFetching, ...rest } = props; const queryClient = useQueryClient(); const debounceTimeoutRef = useRef(null); // Combine parent loading state with local loading state const isFiltersLoading = isLoading; const defaultValues = useMemo( () => ({ search: filterValues.search || '', }), [filterValues.search] ); const form = useForm({ defaultValues, }); // Update form values when filter values change (like after a reset) useEffect(() => { form.reset(defaultValues); }, [form, defaultValues]); const clearDebounceTimeout = useCallback(() => { if (debounceTimeoutRef.current) { clearTimeout(debounceTimeoutRef.current); debounceTimeoutRef.current = null; } }, []); const debouncedFilterChange = useCallback( (fieldName: keyof FilterFormValues, value: string) => { clearDebounceTimeout(); debounceTimeoutRef.current = setTimeout(() => { // Cancel any in-flight requests queryClient.cancelQueries({ queryKey: [QueryKeys.fetchContexts] }); // If empty, explicitly pass undefined to remove the filter // Otherwise, pass the value to update the filter onFiltersChange({ [fieldName]: value.trim() ? value : undefined, }); // Note: We don't immediately clear loading state here // The parent component should handle this when data is loaded debounceTimeoutRef.current = null; }, 400); }, [clearDebounceTimeout, onFiltersChange, queryClient] ); const handleFieldChange = useCallback( (fieldName: keyof FilterFormValues, value: string) => { form.setValue(fieldName, value); debouncedFilterChange(fieldName, value); }, [form, debouncedFilterChange] ); const handleReset = useCallback(() => { clearDebounceTimeout(); // Reset form state form.reset({ search: '' }); // Cancel any pending requests queryClient.cancelQueries({ queryKey: [QueryKeys.fetchContexts] }); // Call the parent reset handler if (onReset) { onReset(); } }, [clearDebounceTimeout, form, onReset, queryClient]); // Clean up timeout on unmount useEffect(() => { return clearDebounceTimeout; }, [clearDebounceTimeout]); const filterHasValue = !!filterValues.search; const searchValue = form.watch('search'); return (
    ( handleFieldChange('search', value)} placeholder="Search contexts (type:id for combination)" /> )} /> {filterHasValue && (
    {isFetching && !isFiltersLoading && }
    )}
    ); }; ================================================ FILE: apps/dashboard/src/components/contexts/create-context-drawer.tsx ================================================ import { forwardRef, useId, useState } from 'react'; import { RiArrowRightSLine } from 'react-icons/ri'; import { Button } from '@/components/primitives/button'; import { Separator } from '@/components/primitives/separator'; import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetMain, SheetTitle, } from '@/components/primitives/sheet'; import { useCombinedRefs } from '@/hooks/use-combined-refs'; import { useFormProtection } from '@/hooks/use-form-protection'; import { useOnElementUnmount } from '@/hooks/use-on-element-unmount'; import { cn } from '@/utils/ui'; import { ExternalLink } from '../shared/external-link'; import { CreateContextForm } from './create-context-form'; type CreateContextDrawerProps = { isOpen: boolean; onOpenChange: (open: boolean) => void; onSuccess?: () => void; onCancel?: () => void; }; export const CreateContextDrawer = forwardRef((props, forwardedRef) => { const { isOpen, onOpenChange, onSuccess, onCancel } = props; const [isSubmitting, setIsSubmitting] = useState(false); const descriptionId = useId(); const formId = useId(); const { protectedOnValueChange, ProtectionAlert, ref: protectionRef, } = useFormProtection({ onValueChange: onOpenChange, }); const { ref: unmountRef } = useOnElementUnmount({ callback: () => { if (onCancel) { onCancel(); } }, condition: !isOpen, }); const combinedRef = useCombinedRefs(forwardedRef, unmountRef, protectionRef); const handleSuccess = () => { onOpenChange(false); onSuccess?.(); }; return ( <>
    Create context Contexts are flexible, user-defined data objects that help you organize and personalize your notifications.{' '} Learn more setIsSubmitting(false)} onSubmitStart={() => setIsSubmitting(true)} formId={formId} /> {ProtectionAlert} ); }); ================================================ FILE: apps/dashboard/src/components/contexts/create-context-form.tsx ================================================ import { standardSchemaResolver } from '@hookform/resolvers/standard-schema'; import { loadLanguage } from '@uiw/codemirror-extensions-langs'; import { useEffect, useId, useRef } from 'react'; import { useForm } from 'react-hook-form'; import { Link } from 'react-router-dom'; import { ExternalToast } from 'sonner'; import { z } from 'zod'; import { NovuApiError } from '@/api/api.client'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, FormRoot, } from '@/components/primitives/form/form'; import { InlineToast } from '@/components/primitives/inline-toast'; import { Input, InputRoot } from '@/components/primitives/input'; import { showErrorToast, showSuccessToast } from '@/components/primitives/sonner-helpers'; import { useCreateContext } from '@/hooks/use-create-context'; import { useTelemetry } from '@/hooks/use-telemetry'; import { TelemetryEvent } from '@/utils/telemetry'; import { Editor } from '../primitives/editor'; import { CreateContextFormSchema } from './schema'; const toastOptions: ExternalToast = { position: 'bottom-right', classNames: { toast: 'mb-4 right-0 pointer-events-none', }, }; const extensions = [loadLanguage('json')?.extension ?? []]; const basicSetup = { lineNumbers: true, defaultKeymap: true }; type CreateContextFormProps = { onSuccess?: () => void; onError?: (error: Error) => void; onSubmitStart?: () => void; formId?: string; }; export const CreateContextForm = (props: CreateContextFormProps) => { const { onSuccess, onError, onSubmitStart, formId: providedFormId } = props; const track = useTelemetry(); const idInputRef = useRef(null); const generatedFormId = useId(); const formId = providedFormId ?? generatedFormId; const { createContext } = useCreateContext({ onSuccess: () => { showSuccessToast(`Context created successfully`, undefined, toastOptions); track(TelemetryEvent.CONTEXTS_PAGE_VISIT); onSuccess?.(); }, onError: (error) => { if (error instanceof NovuApiError && error.status === 409) { form.setError('id', { type: 'manual', message: 'A context with this ID and type already exists', }); } else { const errorMessage = error instanceof Error ? error.message : 'Failed to create context'; showErrorToast(errorMessage, undefined, toastOptions); } onError?.(error instanceof Error ? error : new Error('Unknown error')); }, }); const form = useForm({ defaultValues: { id: '', type: '', data: '', }, resolver: standardSchemaResolver(CreateContextFormSchema), shouldFocusError: false, mode: 'onSubmit', reValidateMode: 'onChange', }); useEffect(() => { if (idInputRef.current) { idInputRef.current.focus(); } }, []); const onSubmit = async (formData: z.infer) => { onSubmitStart?.(); const parsedData = formData.data ? JSON.parse(formData.data) : {}; await createContext({ type: formData.type.trim(), id: formData.id.trim(), ...(parsedData && Object.keys(parsedData).length > 0 ? { data: parsedData } : {}), }); }; return ( <>
    ( Identifier * { field.onChange(e); }} hasError={!!fieldState.error} size="xs" ref={idInputRef} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); form.handleSubmit(onSubmit)(); } }} /> Specific instance identifier (e.g., 123, acme) )} /> (
    Context type *
    { field.onChange(e); }} hasError={!!fieldState.error} size="xs" onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); form.handleSubmit(onSubmit)(); } }} />
    Context type for targeting (e.g., user, tenant, organization)
    )} /> ( Custom data (JSON) { field.onChange(val); form.trigger(field.name); }} /> )} />
    Tip: Learn how to effectively use contexts to organize and personalize your notifications.{' '} Learn more } variant="success" className="mt-6 border-neutral-100 bg-neutral-50" /> ); }; ================================================ FILE: apps/dashboard/src/components/contexts/empty-contexts-illustration.tsx ================================================ import { useId } from 'react'; export const EmptyContextsIllustration = () => { const clipPathId = useId(); return ( ); }; ================================================ FILE: apps/dashboard/src/components/contexts/hooks/use-contexts-navigate.ts ================================================ import { ContextId, ContextType } from '@novu/shared'; import { useCallback } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { buildRoute, ROUTES } from '@/utils/routes'; import { useEnvironment } from '../../../context/environment/hooks'; export const useContextsNavigate = () => { const { currentEnvironment } = useEnvironment(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const environmentSlug = currentEnvironment?.slug ?? ''; const navigateToCreateContextPage = useCallback(() => { navigate(buildRoute(ROUTES.CONTEXTS_CREATE, { environmentSlug })); }, [navigate, environmentSlug]); const navigateToEditContextPage = useCallback( (type: ContextType, id: ContextId) => { navigate(buildRoute(ROUTES.CONTEXTS_EDIT, { environmentSlug, type, id })); }, [navigate, environmentSlug] ); const navigateToContextsPage = useCallback(() => { const currentSearchParams = searchParams.toString(); navigate(buildRoute(ROUTES.CONTEXTS, { environmentSlug }) + '?' + currentSearchParams); }, [navigate, searchParams, environmentSlug]); return { navigateToCreateContextPage, navigateToEditContextPage, navigateToContextsPage, }; }; ================================================ FILE: apps/dashboard/src/components/contexts/hooks/use-contexts-url-state.ts ================================================ import { DirectionEnum } from '@novu/shared'; import { useCallback, useMemo, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { getPersistedPageSize, usePersistedPageSize } from '@/hooks/use-persisted-page-size'; const CONTEXTS_TABLE_ID = 'contexts-list'; export type ContextsSortableColumn = 'createdAt' | 'updatedAt'; export type ContextsFilter = { search?: string; orderBy?: ContextsSortableColumn; orderDirection?: DirectionEnum; limit?: number; after?: string; before?: string; nextCursor?: string; previousCursor?: string; }; export interface ContextsUrlState { filterValues: ContextsFilter; handleFiltersChange: (filter: Partial) => void; resetFilters: () => void; toggleSort: (column: ContextsSortableColumn) => void; handleNext: () => void; handlePrevious: () => void; handleFirst: () => void; handlePageSizeChange: (newSize: number) => void; } const DEFAULT_LIMIT = getPersistedPageSize(CONTEXTS_TABLE_ID, 10); export const useContextsUrlState = (): ContextsUrlState => { const [searchParams, setSearchParams] = useSearchParams(); const [nextCursor, setNextCursor] = useState(undefined); const [previousCursor, setPreviousCursor] = useState(undefined); const { setPageSize: setPersistedPageSize } = usePersistedPageSize({ tableId: CONTEXTS_TABLE_ID, defaultPageSize: 10, }); const filterValues: ContextsFilter = useMemo(() => { const search = searchParams.get('search') || ''; const orderBy = (searchParams.get('orderBy') as ContextsSortableColumn) || undefined; const orderDirection = (searchParams.get('orderDirection') as DirectionEnum) || undefined; const limit = searchParams.get('limit') ? Number(searchParams.get('limit')) : DEFAULT_LIMIT; const urlAfter = searchParams.get('after') || undefined; const urlBefore = searchParams.get('before') || undefined; return { search: search || undefined, orderBy, orderDirection, limit, after: urlAfter, before: urlBefore, nextCursor, previousCursor, }; }, [searchParams, nextCursor, previousCursor]); const toggleSort = useCallback( (column: ContextsSortableColumn) => { setSearchParams((prev) => { if (prev.get('orderBy') === column) { if (prev.get('orderDirection') === DirectionEnum.ASC) { prev.set('orderDirection', DirectionEnum.DESC); } else if (prev.get('orderDirection') === DirectionEnum.DESC) { prev.delete('orderBy'); prev.delete('orderDirection'); } else { prev.set('orderBy', column); prev.set('orderDirection', DirectionEnum.ASC); } } else { prev.set('orderBy', column); prev.set('orderDirection', DirectionEnum.ASC); } return prev; }); }, [setSearchParams] ); const handleFiltersChange = useCallback( (filter: Partial) => { // Handle cursor state updates if ('nextCursor' in filter) { setNextCursor(filter.nextCursor); } if ('previousCursor' in filter) { setPreviousCursor(filter.previousCursor); } setSearchParams((prev) => { if ('after' in filter) { if (filter.after) { prev.set('after', filter.after); } else { prev.delete('after'); } } if ('before' in filter) { if (filter.before) { prev.set('before', filter.before); } else { prev.delete('before'); } } if ('search' in filter) { if (filter.search) { prev.set('search', filter.search); } else { prev.delete('search'); } } return prev; }); }, [setSearchParams] ); const resetFilters = useCallback(() => { setNextCursor(undefined); setPreviousCursor(undefined); setSearchParams((prev) => { prev.delete('search'); prev.delete('before'); prev.delete('after'); return prev; }); }, [setSearchParams]); const handleNext = useCallback(() => { setSearchParams((prev) => { prev.delete('before'); if (nextCursor) { prev.set('after', nextCursor); } return prev; }); }, [nextCursor, setSearchParams]); const handlePrevious = useCallback(() => { setSearchParams((prev) => { prev.delete('after'); if (previousCursor) { prev.set('before', previousCursor); } return prev; }); }, [previousCursor, setSearchParams]); const handleFirst = useCallback(() => { setSearchParams((prev) => { prev.delete('after'); prev.delete('before'); return prev; }); }, [setSearchParams]); const handlePageSizeChange = useCallback( (newSize: number) => { setPersistedPageSize(newSize); setSearchParams((prev) => { prev.set('limit', newSize.toString()); prev.delete('after'); prev.delete('before'); return prev; }); }, [setSearchParams, setPersistedPageSize] ); return { filterValues, handleFiltersChange, resetFilters, toggleSort, handleNext, handlePrevious, handleFirst, handlePageSizeChange, }; }; ================================================ FILE: apps/dashboard/src/components/contexts/index.ts ================================================ export { ContextDrawer, ContextDrawerButton } from './context-drawer'; export { ContextList } from './context-list'; export { ContextListBlank } from './context-list-blank'; export { ContextOverview, ContextOverviewSkeleton } from './context-overview'; export { ContextRow, ContextRowSkeleton } from './context-row'; export { ContextsFilters } from './contexts-filters'; export { CreateContextDrawer } from './create-context-drawer'; export { CreateContextForm } from './create-context-form'; export { EmptyContextsIllustration } from './empty-contexts-illustration'; export { useContextsNavigate } from './hooks/use-contexts-navigate'; export type { ContextsFilter, ContextsSortableColumn, ContextsUrlState } from './hooks/use-contexts-url-state'; export { useContextsUrlState } from './hooks/use-contexts-url-state'; ================================================ FILE: apps/dashboard/src/components/contexts/schema.ts ================================================ import { z } from 'zod'; const CONTEXT_IDENTIFIER_REGEX = /^[a-zA-Z0-9_-]+$/; export const CreateContextFormSchema = z.object({ id: z .string() .min(1, 'ID is required') .max(100, 'ID must be 100 characters or less') .regex(CONTEXT_IDENTIFIER_REGEX, 'ID must match: /^[a-zA-Z0-9_-]+$/'), type: z .string() .min(1, 'Type is required') .max(100, 'Type must be 100 characters or less') .regex(CONTEXT_IDENTIFIER_REGEX, 'Type must match: /^[a-zA-Z0-9_-]+$/'), data: z .string() .refine( (str) => { if (!str) return true; try { JSON.parse(str); return true; } catch { return false; } }, { message: 'Custom data must be a valid JSON' } ) .optional(), }); export const EditContextFormSchema = z.object({ data: z .string() .refine( (str) => { if (!str) return true; try { JSON.parse(str); return true; } catch { return false; } }, { message: 'Custom data must be a valid JSON' } ) .optional(), }); ================================================ FILE: apps/dashboard/src/components/create-workflow-modal.tsx ================================================ /** biome-ignore-all lint/correctness/useUniqueElementIds: working correctly */ import { standardSchemaResolver } from '@hookform/resolvers/standard-schema'; import { AiAgentTypeEnum, AiResourceTypeEnum, DuplicateWorkflowDto } from '@novu/shared'; import * as Sentry from '@sentry/react'; import { ChatOnDataCallback, generateId, UIMessage } from 'ai'; import { motion } from 'motion/react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; import { RiArrowRightSLine, RiCheckboxCircleFill, RiCloseLine, RiLoader3Line, RiLoader4Fill, RiRouteFill, } from 'react-icons/ri'; import { useNavigate } from 'react-router-dom'; import { z } from 'zod'; import { Sparkling } from '@/components/icons/sparkling'; import { Button } from '@/components/primitives/button'; import { CompactButton } from '@/components/primitives/button-compact'; import { Dialog, DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogTitle, } from '@/components/primitives/dialog'; import { SegmentedControl, SegmentedControlList, SegmentedControlTrigger, } from '@/components/primitives/segmented-control'; import { Separator } from '@/components/primitives/separator'; import { Skeleton } from '@/components/primitives/skeleton'; import { Tag } from '@/components/primitives/tag'; import { Textarea } from '@/components/primitives/textarea'; import { ExternalLink } from '@/components/shared/external-link'; import { CreateWorkflowForm } from '@/components/workflow-editor/create-workflow-form'; import { useEnvironment } from '@/context/environment/hooks'; import { useAiChatStream } from '@/hooks/use-ai-chat-stream'; import { useCreateAiChat } from '@/hooks/use-create-ai-chat'; import { useCreateWorkflow } from '@/hooks/use-create-workflow'; import { useDuplicateWorkflow } from '@/hooks/use-duplicate-workflow'; import { useFetchWorkflow } from '@/hooks/use-fetch-workflow'; import { useFormProtection } from '@/hooks/use-form-protection'; import { useTelemetry } from '@/hooks/use-telemetry'; import { buildRoute, ROUTES } from '@/utils/routes'; import { TelemetryEvent } from '@/utils/telemetry'; import { Badge } from './primitives/badge'; import { Form, FormControl, FormField, FormItem, FormMessage, FormRoot } from './primitives/form/form'; import { showErrorToast } from './primitives/sonner-helpers'; export type WorkflowCreatedEvent = { type: 'workflow-created'; workflowId: string; workflowSlug: string; chatId: string; }; type CreateWorkflowTab = 'guided' | 'manual'; const WORKFLOW_SUGGESTIONS = [ 'Welcome email workflow', 'Order confirmation workflow', 'Payment failed', 'Password reset workflow', ]; export function CreateWorkflowModal({ mode, workflowId }: { mode: 'create' | 'duplicate'; workflowId?: string }) { const navigate = useNavigate(); const { currentEnvironment } = useEnvironment(); const track = useTelemetry(); const [open, setOpen] = useState(true); const createdWorkflowSlugRef = useRef(null); const chatId = useMemo(() => generateId(), []); const persistedChatIdRef = useRef(null); const [tab, setTab] = useState('guided'); const { workflow, isPending: isLoadingWorkflow } = useFetchWorkflow({ workflowSlug: mode === 'duplicate' ? workflowId : undefined, }); const handleClose = (isOpen: boolean) => { if (isLoading) return; setOpen(isOpen); if (!isOpen) { setTimeout(() => { navigate( buildRoute(ROUTES.WORKFLOWS, { environmentSlug: currentEnvironment?.slug ?? '', }) ); }, 300); } }; const { ref, protectedOnValueChange, ProtectionAlert } = useFormProtection({ onValueChange: handleClose, }); const handleData = useCallback>( (data) => { if ( data && typeof data === 'object' && 'type' in data && (data as { type: string }).type === 'data-workflow-created' ) { const workflowCreatedEvent = data.data as unknown as WorkflowCreatedEvent; createdWorkflowSlugRef.current = workflowCreatedEvent.workflowSlug; track(TelemetryEvent.COPILOT_WORKFLOW_GENERATED, { workflowId: workflowCreatedEvent.workflowId, chatId: workflowCreatedEvent.chatId, }); navigate( buildRoute(ROUTES.EDIT_WORKFLOW, { environmentSlug: currentEnvironment?.slug ?? '', workflowSlug: createdWorkflowSlugRef.current ?? '', }), { state: { chatId: workflowCreatedEvent.chatId } } ); } }, [currentEnvironment?.slug, navigate, track] ); const { sendPrompt, stop, isGenerating, error } = useAiChatStream({ id: chatId, agentType: AiAgentTypeEnum.GENERATE_WORKFLOW, onData: handleData, }); useEffect(() => { if (error) { const errorMessage = error.message || 'There was an error starting the stream.'; showErrorToast(errorMessage, 'Failed to start stream'); // ignore errors from the input guard middleware if (!errorMessage.includes('Novu Copilot')) { Sentry.captureException(error, { tags: { feature: 'ai-copilot', action: 'stream-start-error' }, extra: { chatId: persistedChatIdRef.current ?? chatId }, }); } } }, [error, chatId]); useEffect(() => { return () => { stop(); }; }, [stop]); const duplicateWorkflow = useDuplicateWorkflow({ workflowSlug: workflowId || '' }); const createWorkflowHook = useCreateWorkflow(); const { submit: submitWorkflow, isLoading: isSubmitting } = mode === 'duplicate' ? duplicateWorkflow : createWorkflowHook; const { createAiChat, isPending: isCreatingAiChat } = useCreateAiChat(); const isLoading = isSubmitting || isGenerating || isCreatingAiChat; const isLoadingTemplate = mode === 'duplicate' && isLoadingWorkflow; const template: DuplicateWorkflowDto | undefined = mode === 'duplicate' && workflow ? { name: `${workflow.name} (Copy)`, description: workflow.description, tags: workflow.tags, isTranslationEnabled: workflow.isTranslationEnabled, } : undefined; async function handleGuidedSubmit({ prompt }: { prompt: string }) { const clearedPrompt = prompt.trim(); if (!clearedPrompt) { return; } track(TelemetryEvent.COPILOT_GUIDED_SUBMIT, { promptLength: clearedPrompt.length, }); if (persistedChatIdRef.current) { sendPrompt({ chatId: persistedChatIdRef.current, prompt: clearedPrompt }); return; } await createAiChat( { resourceType: AiResourceTypeEnum.WORKFLOW }, { onError: (err) => { showErrorToast(err.message || 'There was an error creating the chat.', 'Failed to create chat'); Sentry.captureException(err, { tags: { feature: 'ai-copilot', action: 'create-ai-chat' }, }); }, onSuccess: async (chat) => { persistedChatIdRef.current = chat._id; sendPrompt({ chatId: chat._id, prompt: clearedPrompt }); }, } ); } const handleTabChange = (value: string) => { const newTab = value as CreateWorkflowTab; setTab(newTab); track(TelemetryEvent.COPILOT_TAB_SWITCHED, { tab: newTab }); }; const isDuplicateMode = mode === 'duplicate'; const showTabs = !isDuplicateMode; const showGuidedContent = !isDuplicateMode && tab === 'guided'; const showManualContent = isDuplicateMode || tab === 'manual'; const title = isDuplicateMode ? 'Duplicate workflow' : 'Create workflow'; const buttonText = showGuidedContent ? 'Generate workflow' : isDuplicateMode ? 'Duplicate workflow' : 'Create workflow'; return ( <>
    {title} Turn product activity into messages across channels.{' '} Learn more
    Close
    {showTabs && ( <>
    Guided{' '} BETA Manual
    )} {showGuidedContent && ( )} {showManualContent && (isLoadingTemplate ? ( ) : ( ))}
    {showGuidedContent ? ( ) : ( )}
    {ProtectionAlert} ); } const schema = z.object({ prompt: z.string().min(1, 'Prompt is required').max(2000), }); type GuidedModeContentProps = { onSubmit: (values: z.infer) => void; isGenerating: boolean; error?: Error; }; const STEP_DELAY_MS = 2000; const GENERATION_STEPS = [ { id: 'spinning', text: 'Spinning up a fresh workflow' }, { id: 'coffee', text: 'Sipping a little bit of coffee' }, { id: 'workflow-id', text: 'Generating a unique workflow ID', }, { id: 'tags', text: 'Setting up tags', }, { id: 'canvas', text: 'Laying out the workflow canvas' }, { id: 'moment', text: 'One moment while we set this up' }, ] as const; type GenerationStepStatus = 'success' | 'progress' | 'pending'; type GenerationStep = { id: string; text: string; status: GenerationStepStatus; }; function GuidedModeContent({ onSubmit, isGenerating, error }: GuidedModeContentProps) { const track = useTelemetry(); const form = useForm({ resolver: standardSchemaResolver(schema), defaultValues: { prompt: '', }, }); const [animatedStepIndex, setAnimatedStepIndex] = useState(-1); useEffect(() => { if (!isGenerating) return; setAnimatedStepIndex(0); const interval = setInterval(() => { setAnimatedStepIndex((prev) => { if (prev >= GENERATION_STEPS.length - 1) { return prev; } return prev + 1; }); }, STEP_DELAY_MS); return () => clearInterval(interval); }, [isGenerating]); useEffect(() => { if (error) { setAnimatedStepIndex(-1); } }, [error]); function handleSuggestionClick(suggestion: string) { track(TelemetryEvent.COPILOT_SUGGESTION_CLICKED, { suggestion }); form.setValue('prompt', suggestion); } const header = useMemo( () => (
    Create a workflow that works out of the box Describe a product activity and how you want to reach users. Novu designs a complete workflow with best practices.
    ), [] ); if (isGenerating || animatedStepIndex >= 0) { const effectiveStepIndex = animatedStepIndex === -1 && isGenerating ? 0 : animatedStepIndex; const steps: GenerationStep[] = GENERATION_STEPS.map((step, index) => { const status: GenerationStepStatus = index < effectiveStepIndex ? 'success' : index === effectiveStepIndex ? 'progress' : 'pending'; return { id: step.id, text: step.text, status }; }); const ITEM_HEIGHT = 16; const GAP = 8; const CONTAINER_HEIGHT = 190; const activeIndex = effectiveStepIndex; return (
    {header}
    {steps.map((step, index) => ( {step.status === 'success' && (
    )} {step.status === 'progress' && (
    )} {step.status === 'pending' && (
    )} {step.text}
    ))}
    ); } return (
    {header}
    {WORKFLOW_SUGGESTIONS.map((suggestion) => ( ))} {/*
    (