diff --git a/.cursor/skills/nv-onboard-dcr-mcp/blocked-mcp-servers.md b/.cursor/skills/nv-onboard-dcr-mcp/blocked-mcp-servers.md index c7441e8f754..ba285bbdd58 100644 --- a/.cursor/skills/nv-onboard-dcr-mcp/blocked-mcp-servers.md +++ b/.cursor/skills/nv-onboard-dcr-mcp/blocked-mcp-servers.md @@ -33,6 +33,8 @@ Copy the template below to the **Open blockers** table (newest first). Fill ever | ID | Name | MCP URL | Blocked | Category | Reason | Docs | Next step | |----|------|---------|---------|----------|--------|------|-----------| +| `new-relic` | New Relic | `https://mcp.newrelic.com/mcp/` | 2026-08-30 | `redirect-whitelist` | Was wrongly catalogued `dcr` because AS metadata advertises `registration_endpoint` `https://mcp.newrelic.com/register`. In practice OAuth is a single pre-registered public client (`client_id: pUWGgnjsQ0bydqCbavTPpw==`, `client_authentication_required: false`) whose redirect allowlist covers only loopback/CLI hosts, and `authorization_endpoint` is the `https://login.newrelic.com/login` page rather than a spec authorize endpoint — after successful login the browser stays on New Relic's success page and the hosted Novu callback never receives the code. Docs list only CLI/desktop clients (Claude Code/Desktop, Gemini CLI, Kiro CLI, Windsurf, VS Code); SSO-enforced orgs fail too. PRM is valid (`resource: https://mcp.newrelic.com/mcp/`, S256, `pkce_required: true`) | [NR MCP overview](https://docs.newrelic.com/docs/agentic-ai/mcp/overview/), [Troubleshoot NR MCP](https://docs.newrelic.com/docs/agentic-ai/mcp/troubleshoot/), [OAuth never redirects to web clients #7](https://github.com/newrelic/mcp-server/issues/7), [OAuth/SSO auth failure #4](https://github.com/newrelic/mcp-server/issues/4) | Keep `provider-managed`; ask New Relic to allowlist `https://api.novu.co/v1/agents/mcp/oauth/callback` on the static client or ship real per-client DCR. API-key auth (`api-key: NRAK-…`) is their documented workaround but needs the unwired `user-app` mode | +| `canva` | Canva | `https://mcp.canva.com/mcp` | 2026-08-30 | `client-allowlist` | Was wrongly catalogued `dcr` because `https://mcp.canva.com/register` exists. Official docs require applying to a Waitlist form to get a redirect URI onto Canva's allowlist, and explicitly deprecate DCR in favour of CIMD (`client_id_metadata_document_supported: true` on AS metadata) — so a DCR client Novu registers still fails at authorize for unapproved hosts. PRM/AS otherwise healthy (issuer `https://mcp.canva.com`, S256, path-suffixed PRM `resource: https://mcp.canva.com/mcp`) but advertises 16 scopes, so any future `dcr` re-enable must pin `oauth.scopes`. Browser clients must also allow responses from both `canva.com` and `canva.ai` | [Canva MCP docs](https://www.canva.dev/docs/mcp/), [Canva MCP troubleshooting](https://www.canva.dev/docs/mcp/troubleshooting/) | Keep `provider-managed`; apply to the Canva waitlist for `https://api.novu.co/v1/agents/mcp/oauth/callback`, or implement CIMD (publish a client metadata document at a Novu HTTPS URL and pass that URL as `client_id`) which is Canva's recommended path | | `fmp` | FMP | `https://financialmodelingprep.com/mcp` | 2026-06-15 | `other` | Official MCP docs require dashboard API key (`?apikey=`) with no OAuth/DCR path; root AS metadata has S256 but no `registration_endpoint`; DCR probes return 401 `Invalid API KEY` | [FMP MCP docs](https://site.financialmodelingprep.com/developer/docs/mcp-server) | Keep `provider-managed`; re-probe only if FMP ships MCP OAuth/DCR with a working registration endpoint | | `adobe-journey-optimizer` | Adobe Journey Optimizer | `https://ajo-mcp.adobe.io/mcp` | 2026-06-15 | `partner-approval` | Beta docs require Adobe-rep provisioning of org-specific endpoint + fixed credentials; only Claude Web/Desktop supported — no self-serve DCR; IMS DCR rejects Novu callback (`invalid_redirect_uri`) while localhost succeeds | [AJO MCP docs](https://experienceleague.adobe.com/en/docs/journey-optimizer/using/ai-capabilities/mcp-server) | Keep `provider-managed`; request Adobe partner approval + Novu callback allowlist | | `adobe-marketing-agent` | Adobe Marketing Agent | `https://aep-ai-ama.adobe.io/mcp` | 2026-06-15 | `other` | DCR at `/register` succeeds for Novu callback (S256, `client_secret_post`) but RFC 9728 PRM is non-compliant — path-suffixed PRM 404; host root returns `resources[]` without `authorization_servers`, blocking Novu `discoverProtectedResource` | [Adobe Marketing Agent](https://claude.com/connectors/adobe-marketing-agent) | Keep `provider-managed`; ask Adobe to publish valid PRM with `authorization_servers`, then re-probe | diff --git a/apps/api/src/app/events/e2e/trigger-event.e2e.ts b/apps/api/src/app/events/e2e/trigger-event.e2e.ts index 67754333179..bd38a544844 100644 --- a/apps/api/src/app/events/e2e/trigger-event.e2e.ts +++ b/apps/api/src/app/events/e2e/trigger-event.e2e.ts @@ -946,6 +946,41 @@ describe('Trigger event - /v1/events/trigger (POST) #novu-v2', () => { expect(message?.providerId).to.equal(payload.providerId); }); + it('should use JsonLogic conditions to select integration by subscriber', async () => { + const payload = { + providerId: EmailProviderIdEnum.Mailgun, + channel: 'email', + credentials: { apiKey: '123', secretKey: 'abc' }, + _environmentId: session.environment._id, + rules: { + '==': [{ var: 'subscriber.subscriberId' }, subscriber.subscriberId], + }, + active: true, + check: false, + }; + + await session.testAgent.post('/v1/integrations').send(payload); + + template = await createTemplate(session, ChannelTypeEnum.EMAIL); + + await sendTrigger(template, subscriber.subscriberId, {}); + + await session.waitForJobCompletion(template._id); + + const createdSubscriber = await subscriberRepository.findBySubscriberId( + session.environment._id, + subscriber.subscriberId + ); + + const message = await messageRepository.findOne({ + _environmentId: session.environment._id, + _subscriberId: createdSubscriber?._id, + channel: ChannelTypeEnum.EMAIL, + }); + + expect(message?.providerId).to.equal(payload.providerId); + }); + it('should use or conditions to select integration', async () => { const payload = { providerId: EmailProviderIdEnum.Mailgun, diff --git a/apps/api/src/app/events/usecases/parse-event-request/parse-event-request.usecase.ts b/apps/api/src/app/events/usecases/parse-event-request/parse-event-request.usecase.ts index 7bcd404f8ac..256e9c4e857 100644 --- a/apps/api/src/app/events/usecases/parse-event-request/parse-event-request.usecase.ts +++ b/apps/api/src/app/events/usecases/parse-event-request/parse-event-request.usecase.ts @@ -420,7 +420,7 @@ export class ParseEventRequest { if (!command.skipQueueInsertion) { await this.workflowQueueService.add({ name: transactionId, data: jobData, groupId: command.organizationId }); - this.logger.info( + this.logger.debug( { ...command, transactionId, discoveredWorkflowId: discoveredWorkflow?.workflowId }, 'Event dispatched to [Workflow] Queue' ); diff --git a/apps/api/src/app/integrations/dtos/create-integration-request.dto.ts b/apps/api/src/app/integrations/dtos/create-integration-request.dto.ts index aa0baa5bd5d..1d66cfe4d7f 100644 --- a/apps/api/src/app/integrations/dtos/create-integration-request.dto.ts +++ b/apps/api/src/app/integrations/dtos/create-integration-request.dto.ts @@ -11,6 +11,7 @@ import { IsObject, IsOptional, IsString, + ValidateIf, ValidateNested, } from 'class-validator'; @@ -76,13 +77,29 @@ export class CreateIntegrationRequestDto implements ICreateIntegrationBodyDto { @ApiPropertyOptional({ type: [StepFilterDto], - description: 'Conditions for the integration', + deprecated: true, + description: 'Legacy StepFilter conditions. Ignored when `rules` is also set.', }) @IsArray() @IsOptional() @ValidateNested({ each: true }) conditions?: StepFilterDto[]; + @ApiPropertyOptional({ + type: 'object', + additionalProperties: true, + nullable: true, + description: + 'JSONLogic used at send time to select this integration. Takes precedence over `conditions`.', + example: { + '==': [{ var: 'context.tenant.id' }, 'acme'], + }, + }) + @IsOptional() + @ValidateIf((_, value) => value !== null) + @IsObject() + rules?: Record | null; + @ApiPropertyOptional({ type: Object, description: 'Configurations for the integration', diff --git a/apps/api/src/app/integrations/dtos/update-integration.dto.ts b/apps/api/src/app/integrations/dtos/update-integration.dto.ts index def202cf7fd..07cb3fc5ee3 100644 --- a/apps/api/src/app/integrations/dtos/update-integration.dto.ts +++ b/apps/api/src/app/integrations/dtos/update-integration.dto.ts @@ -2,7 +2,16 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { CredentialsDto, StepFilterDto } from '@novu/application-generic'; import { IUpdateIntegrationBodyDto } from '@novu/shared'; import { Type } from 'class-transformer'; -import { IsArray, IsBoolean, IsMongoId, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { + IsArray, + IsBoolean, + IsMongoId, + IsObject, + IsOptional, + IsString, + ValidateIf, + ValidateNested, +} from 'class-validator'; export class UpdateIntegrationRequestDto implements IUpdateIntegrationBodyDto { @ApiPropertyOptional({ type: String }) @@ -43,12 +52,29 @@ export class UpdateIntegrationRequestDto implements IUpdateIntegrationBodyDto { @ApiPropertyOptional({ type: [StepFilterDto], + deprecated: true, + description: 'Legacy StepFilter conditions. Ignored when `rules` is also set.', }) @IsArray() @IsOptional() @ValidateNested({ each: true }) conditions?: StepFilterDto[]; + @ApiPropertyOptional({ + type: 'object', + additionalProperties: true, + nullable: true, + description: + 'JSONLogic used at send time to select this integration. Takes precedence over `conditions`.', + example: { + '==': [{ var: 'context.tenant.id' }, 'acme'], + }, + }) + @IsOptional() + @ValidateIf((_, value) => value !== null) + @IsObject() + rules?: Record | null; + @ApiPropertyOptional({ type: Object, description: 'Configurations for the integration', diff --git a/apps/api/src/app/integrations/e2e/create-integration.e2e.ts b/apps/api/src/app/integrations/e2e/create-integration.e2e.ts index f994fdf6c46..599e75597f3 100644 --- a/apps/api/src/app/integrations/e2e/create-integration.e2e.ts +++ b/apps/api/src/app/integrations/e2e/create-integration.e2e.ts @@ -123,6 +123,75 @@ describe('Create Integration - /integration (POST) #novu-v2', () => { expect(body.data.conditions[0].children[0].operator).to.equal('EQUAL'); }); + it('should create integration with JsonLogic conditions', async () => { + const payload = { + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + identifier: 'identifier-conditions-logic', + active: false, + check: false, + rules: { + '==': [{ var: 'context.tenant.id' }, 'acme'], + }, + }; + + const { body } = await session.testAgent.post('/v1/integrations').send(payload); + + expect(body.data.rules).to.deep.equal(payload.rules); + expect(body.data.primary).to.equal(false); + }); + + it('should reject JsonLogic conditions on a disallowed field', async () => { + const payload = { + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + identifier: 'identifier-conditions-logic-invalid', + active: false, + check: false, + rules: { + '==': [{ var: 'payload.foo' }, 'bar'], + }, + }; + + const { body } = await session.testAgent.post('/v1/integrations').send(payload); + + expect(body.statusCode).to.equal(400); + }); + + it('should reject JsonLogic conditions on deprecated tenant fields', async () => { + const payload = { + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + identifier: 'identifier-conditions-logic-tenant', + active: false, + check: false, + rules: { + '==': [{ var: 'tenant.identifier' }, 'acme'], + }, + }; + + const { body } = await session.testAgent.post('/v1/integrations').send(payload); + + expect(body.statusCode).to.equal(400); + }); + + it('should reject JsonLogic conditions with unsupported operators', async () => { + const payload = { + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + identifier: 'identifier-conditions-logic-log', + active: false, + check: false, + rules: { + log: { var: 'subscriber.email' }, + }, + }; + + const { body } = await session.testAgent.post('/v1/integrations').send(payload); + + expect(body.statusCode).to.equal(400); + }); + it('should return error with malformed conditions', async () => { const payload = { providerId: EmailProviderIdEnum.SendGrid, diff --git a/apps/api/src/app/integrations/e2e/set-itegration-as-primary.e2e.ts b/apps/api/src/app/integrations/e2e/set-itegration-as-primary.e2e.ts index 8d408239361..362724c06c7 100644 --- a/apps/api/src/app/integrations/e2e/set-itegration-as-primary.e2e.ts +++ b/apps/api/src/app/integrations/e2e/set-itegration-as-primary.e2e.ts @@ -115,6 +115,36 @@ describe('Set Integration As Primary - /integrations/:integrationId/set-primary expect(found?.primary).to.equal(true); }); + it('clears JsonLogic conditions when set as primary', async () => { + await integrationRepository.deleteMany({ + _organizationId: session.organization._id, + _environmentId: session.environment._id, + }); + + const integration = await integrationRepository.create({ + name: 'Email with jsonlogic conditions', + identifier: 'identifier-logic-1', + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + active: false, + _organizationId: session.organization._id, + _environmentId: session.environment._id, + rules: { + '==': [{ var: 'context.tenant.id' }, 'acme'], + }, + }); + + await session.testAgent.post(`/v1/integrations/${integration._id}/set-primary`).send({}); + + const found = await integrationRepository.findOne({ + _id: integration._id, + _organizationId: session.organization._id, + }); + + expect(found?.rules).to.equal(null); + expect(found?.primary).to.equal(true); + }); + it('push channel does not support primary flag, then for integration it should throw bad request exception', async () => { await integrationRepository.deleteMany({ _organizationId: session.organization._id, diff --git a/apps/api/src/app/integrations/e2e/update-integration.e2e.ts b/apps/api/src/app/integrations/e2e/update-integration.e2e.ts index 60ae7e8de07..956a4433b7c 100644 --- a/apps/api/src/app/integrations/e2e/update-integration.e2e.ts +++ b/apps/api/src/app/integrations/e2e/update-integration.e2e.ts @@ -145,6 +145,34 @@ describe('Update Integration - /integrations/:integrationId (PUT) #novu-v2', () expect((result?.conditions?.at(0)?.children.at(0) as ITenantFilterPart)?.operator).to.equal('EQUAL'); }); + it('should update JsonLogic conditions on integration', async () => { + const payload = { + providerId: EmailProviderIdEnum.SendGrid, + channel: ChannelTypeEnum.EMAIL, + credentials: { apiKey: 'SG.123', secretKey: 'abc' }, + active: true, + check: false, + rules: { + '==': [{ var: 'subscriber.locale' }, 'fr'], + }, + }; + + const { data } = (await session.testAgent.get(`/v1/integrations`)).body; + + const integration = data.find((i) => i.primary && i.channel === 'email'); + + await session.testAgent.put(`/v1/integrations/${integration._id}`).send(payload); + + const result = await integrationRepository.findOne({ + _id: integration._id, + _organizationId: session.organization._id, + }); + + expect(result?.rules).to.deep.equal(payload.rules); + expect(result?.conditions).to.deep.equal([]); + expect(result?.primary).to.equal(false); + }); + it('should return error with malformed conditions', async () => { const payload = { providerId: EmailProviderIdEnum.SendGrid, diff --git a/apps/api/src/app/integrations/integrations.controller.ts b/apps/api/src/app/integrations/integrations.controller.ts index e572a08aaf2..511c83bf01a 100644 --- a/apps/api/src/app/integrations/integrations.controller.ts +++ b/apps/api/src/app/integrations/integrations.controller.ts @@ -294,6 +294,7 @@ export class IntegrationsController { active: body.active ?? false, check: body.check ?? false, conditions: body.conditions, + rules: body.rules, configurations: body.configurations, }) ); @@ -349,6 +350,7 @@ export class IntegrationsController { active: body.active, check: body.check ?? false, conditions: body.conditions, + rules: body.rules, configurations: body.configurations, restrictToUserEnvironment: isEnvironmentScopedAuthScheme(user.scheme), }) diff --git a/apps/api/src/app/integrations/usecases/create-integration/create-integration.command.ts b/apps/api/src/app/integrations/usecases/create-integration/create-integration.command.ts index eefd815b84a..07aade6f73b 100644 --- a/apps/api/src/app/integrations/usecases/create-integration/create-integration.command.ts +++ b/apps/api/src/app/integrations/usecases/create-integration/create-integration.command.ts @@ -43,6 +43,10 @@ export class CreateIntegrationCommand extends EnvironmentCommand { @ValidateNested({ each: true }) conditions?: MessageFilter[]; + @IsOptional() + @IsObject() + rules?: Record | null; + @IsOptional() @IsObject() configurations?: Record; diff --git a/apps/api/src/app/integrations/usecases/create-integration/create-integration.usecase.ts b/apps/api/src/app/integrations/usecases/create-integration/create-integration.usecase.ts index e5f4d3e2120..a113d4a7eb8 100644 --- a/apps/api/src/app/integrations/usecases/create-integration/create-integration.usecase.ts +++ b/apps/api/src/app/integrations/usecases/create-integration/create-integration.usecase.ts @@ -29,6 +29,7 @@ import { slugify, } from '@novu/shared'; import shortid from 'shortid'; +import { assertValidIntegrationRules } from '../../utils/assert-integration-rules'; import { validateOutboundIntegrationCredentials } from '../../utils/validate-outbound-integration-credentials'; import { CheckIntegrationCommand } from '../check-integration/check-integration.command'; import { CheckIntegration } from '../check-integration/check-integration.usecase'; @@ -162,6 +163,7 @@ export class CreateIntegration { } await this.validate(command); + assertValidIntegrationRules(command.rules); const isAgentKind = command.kind === IntegrationKindEnum.AGENT; @@ -209,6 +211,7 @@ export class CreateIntegration { credentials: encryptCredentials(managedCredentials), active: command.active, conditions: command.conditions, + rules: command.rules ?? undefined, configurations: command.configurations, kind: command.kind ?? IntegrationKindEnum.DELIVERY, }; diff --git a/apps/api/src/app/integrations/usecases/set-integration-as-primary/set-integration-as-primary.usecase.ts b/apps/api/src/app/integrations/usecases/set-integration-as-primary/set-integration-as-primary.usecase.ts index 26643aa2607..bae9b6e1017 100644 --- a/apps/api/src/app/integrations/usecases/set-integration-as-primary/set-integration-as-primary.usecase.ts +++ b/apps/api/src/app/integrations/usecases/set-integration-as-primary/set-integration-as-primary.usecase.ts @@ -43,6 +43,7 @@ export class SetIntegrationAsPrimary { active: true, primary: true, conditions: [], + rules: null, }, } ); diff --git a/apps/api/src/app/integrations/usecases/update-integration/update-integration.command.ts b/apps/api/src/app/integrations/usecases/update-integration/update-integration.command.ts index ab0069e71e3..9483b488338 100644 --- a/apps/api/src/app/integrations/usecases/update-integration/update-integration.command.ts +++ b/apps/api/src/app/integrations/usecases/update-integration/update-integration.command.ts @@ -48,6 +48,10 @@ export class UpdateIntegrationCommand extends OrganizationCommand { @ValidateNested({ each: true }) conditions?: MessageFilter[]; + @IsOptional() + @IsObject() + rules?: Record | null; + @IsOptional() @IsObject() configurations?: IConfigurations; diff --git a/apps/api/src/app/integrations/usecases/update-integration/update-integration.usecase.ts b/apps/api/src/app/integrations/usecases/update-integration/update-integration.usecase.ts index 9823260fbe3..b72f9450c77 100644 --- a/apps/api/src/app/integrations/usecases/update-integration/update-integration.usecase.ts +++ b/apps/api/src/app/integrations/usecases/update-integration/update-integration.usecase.ts @@ -1,7 +1,15 @@ import { BadRequestException, ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; -import { AnalyticsService, decryptCredentials, encryptCredentials, PinoLogger } from '@novu/application-generic'; +import { + AnalyticsService, + decryptCredentials, + encryptCredentials, + hasIntegrationRules, + hasLegacyIntegrationConditions, + PinoLogger, +} from '@novu/application-generic'; import { EnvironmentRepository, IntegrationEntity, IntegrationRepository } from '@novu/dal'; import { CHANNELS_WITH_PRIMARY } from '@novu/shared'; +import { assertValidIntegrationRules } from '../../utils/assert-integration-rules'; import { assertIntegrationEnvironmentScope } from '../../utils/assert-integration-environment-scope'; import { validateOutboundIntegrationCredentials } from '../../utils/validate-outbound-integration-credentials'; import { CheckIntegrationCommand } from '../check-integration/check-integration.command'; @@ -212,11 +220,23 @@ export class UpdateIntegration { updatePayload.conditions = command.conditions; } + if (command.rules !== undefined) { + assertValidIntegrationRules(command.rules); + + if (hasIntegrationRules(command.rules)) { + updatePayload.rules = command.rules; + updatePayload.conditions = []; + } else { + updatePayload.rules = null; + } + } + if (!Object.keys(updatePayload).length) { throw new BadRequestException('No properties found for update'); } - const haveConditions = updatePayload.conditions && updatePayload.conditions?.length > 0; + const haveConditions = + hasIntegrationRules(updatePayload.rules) || hasLegacyIntegrationConditions(updatePayload.conditions); const isChannelSupportsPrimary = !!existingIntegration.channel && CHANNELS_WITH_PRIMARY.includes(existingIntegration.channel); diff --git a/apps/api/src/app/integrations/utils/assert-integration-rules.ts b/apps/api/src/app/integrations/utils/assert-integration-rules.ts new file mode 100644 index 00000000000..b48d8b1194d --- /dev/null +++ b/apps/api/src/app/integrations/utils/assert-integration-rules.ts @@ -0,0 +1,14 @@ +import { BadRequestException } from '@nestjs/common'; +import { getIntegrationRulesIssues, hasIntegrationRules } from '@novu/application-generic'; + +export function assertValidIntegrationRules(rules?: unknown): void { + if (!hasIntegrationRules(rules)) { + return; + } + + const issues = getIntegrationRulesIssues(rules); + + if (issues.length > 0) { + throw new BadRequestException(issues.join('; ')); + } +} diff --git a/apps/dashboard/src/api/integrations.ts b/apps/dashboard/src/api/integrations.ts index d60638a8f99..0abe1fd85b9 100644 --- a/apps/dashboard/src/api/integrations.ts +++ b/apps/dashboard/src/api/integrations.ts @@ -31,6 +31,7 @@ export type CreateIntegrationData = { active: boolean; primary?: boolean; _environmentId?: string; + rules?: Record | null; }; export enum CheckIntegrationResponseEnum { @@ -48,6 +49,7 @@ export type UpdateIntegrationData = { credentials: Record; configurations: Record; check: boolean; + rules?: Record | null; }; export async function generateLinkUserOAuthUrl({ diff --git a/apps/dashboard/src/components/conditions-editor/conditions-editor.tsx b/apps/dashboard/src/components/conditions-editor/conditions-editor.tsx index 9c48a881557..89b93c70bf9 100644 --- a/apps/dashboard/src/components/conditions-editor/conditions-editor.tsx +++ b/apps/dashboard/src/components/conditions-editor/conditions-editor.tsx @@ -17,7 +17,11 @@ import { 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 { DEFAULT_MAX_CONDITIONS_PER_GROUP, normalizeMaxConditionsPerGroup } from '@/components/conditions-editor/types'; +import { + type ConditionsValueInput, + DEFAULT_MAX_CONDITIONS_PER_GROUP, + normalizeMaxConditionsPerGroup, +} from '@/components/conditions-editor/types'; import { ValueEditor } from '@/components/conditions-editor/value-editor'; import { useDataRef } from '@/hooks/use-data-ref'; import { useNumericFeatureFlag } from '@/hooks/use-feature-flag'; @@ -82,6 +86,7 @@ function InternalConditionsEditor({ saveForm, enhancedVariables, disabled, + valueInput, }: { fields: EnhancedField[]; variables: LiquidVariable[]; @@ -91,6 +96,7 @@ function InternalConditionsEditor({ saveForm: () => void; enhancedVariables?: EnhancedLiquidVariable[]; disabled?: boolean; + valueInput?: ConditionsValueInput; }) { const fieldDataMap = useMemo(() => { if (!enhancedVariables) return new Map(); @@ -207,8 +213,9 @@ function InternalConditionsEditor({ saveForm, getPlaceholder, getHelpText, + valueInput, }), - [variables, isAllowedVariable, saveForm, getPlaceholder, getHelpText] + [variables, isAllowedVariable, saveForm, getPlaceholder, getHelpText, valueInput] ); return ( @@ -238,6 +245,7 @@ export type ConditionsEditorContext = { fieldName: string, operator: string ) => { title: string; description: string; examples: string[]; notes?: string[] }; + valueInput?: ConditionsValueInput; }; export function ConditionsEditor({ @@ -249,6 +257,7 @@ export function ConditionsEditor({ isAllowedVariable, enhancedVariables, disabled, + valueInput, }: { query: RuleGroupType; onQueryChange: (query: RuleGroupType) => void; @@ -258,6 +267,7 @@ export function ConditionsEditor({ isAllowedVariable: IsAllowedVariable; enhancedVariables?: EnhancedLiquidVariable[]; disabled?: boolean; + valueInput?: ConditionsValueInput; }) { const configuredMaxConditionsPerGroup = useNumericFeatureFlag( FeatureFlagsKeysEnum.MAX_STEP_CONDITIONS_PER_GROUP_NUMBER, @@ -295,6 +305,7 @@ export function ConditionsEditor({ saveForm={saveForm} enhancedVariables={enhancedVariables} disabled={disabled} + valueInput={valueInput} /> ); diff --git a/apps/dashboard/src/components/conditions-editor/types.ts b/apps/dashboard/src/components/conditions-editor/types.ts index 9a5d0bee529..0e7f5b109de 100644 --- a/apps/dashboard/src/components/conditions-editor/types.ts +++ b/apps/dashboard/src/components/conditions-editor/types.ts @@ -1,4 +1,22 @@ +import type { ComponentType } from 'react'; import type { BaseOption, Path, RuleGroupTypeAny, RuleType } from 'react-querybuilder'; +import type { IsAllowedVariable, LiquidVariable } from '@/utils/parseStepVariables'; + +/** + * Editor used for a rule's value. Defaults to a plain input. The workflow editor + * should pass `ControlInput`; other callers may inject their own. + */ +export type ConditionsValueInput = ComponentType<{ + value: string; + onChange: (value: string) => void; + variables: LiquidVariable[]; + isAllowedVariable: IsAllowedVariable; + placeholder?: string; + multiline?: boolean; + indentWithTab?: boolean; + size?: 'md' | 'sm' | '2xs' | '3xs'; + disabled?: boolean; +}>; export const DEFAULT_MAX_CONDITIONS_PER_GROUP = 10; diff --git a/apps/dashboard/src/components/conditions-editor/value-editor.tsx b/apps/dashboard/src/components/conditions-editor/value-editor.tsx index 966b7ed9407..2dca27017bb 100644 --- a/apps/dashboard/src/components/conditions-editor/value-editor.tsx +++ b/apps/dashboard/src/components/conditions-editor/value-editor.tsx @@ -4,9 +4,9 @@ import type { HelpTextInfo } from '@/components/conditions-editor/field-type-edi import { shouldUseRelativeDateEditor } from '@/components/conditions-editor/field-type-editors'; import { isValuelessOperator } from '@/components/conditions-editor/field-type-operators'; import { HelpIcon } from '@/components/conditions-editor/help-icon'; -import { InputRoot, InputWrapper } from '@/components/primitives/input'; +import type { ConditionsValueInput } from '@/components/conditions-editor/types'; +import { InputPure, 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 = { @@ -19,6 +19,7 @@ type ExtendedContext = { isAllowedVariable: IsAllowedVariable; getPlaceholder?: (fieldName: string, operator: string) => string; getHelpText?: (fieldName: string, operator: string) => HelpTextInfo; + valueInput?: ConditionsValueInput; }; const TIME_UNITS = [ @@ -30,6 +31,23 @@ const TIME_UNITS = [ { value: 'years', label: 'years' }, ] as const; +/** + * Neutral default. The workflow editor injects `ControlInput`; integrations inject a + * sized plain input. Do not default to workflow `ControlInput` here. + */ +const DefaultConditionsValueInput: ConditionsValueInput = ({ value, onChange, placeholder, disabled }) => { + + return ( + onChange(event.target.value)} + /> + ); +}; + type BaseEditorProps = { value: string; onChange: (newValue: string) => void; @@ -40,13 +58,20 @@ type BaseEditorProps = { helpText: HelpTextInfo | null; errorMessage?: string; disabled?: boolean; + ValueInput: ConditionsValueInput; }; 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 { + variables = [], + isAllowedVariable, + getPlaceholder, + getHelpText, + valueInput: ValueInput = DefaultConditionsValueInput, + } = (props.context as ExtendedContext) ?? {}; const { value, handleOnChange, operator, field, disabled } = props; const { valueAsArray, multiValueHandler } = useValueEditor(props); const stringValue = typeof value === 'string' ? value : `${value}`; @@ -72,6 +97,7 @@ export const ValueEditor = (props: ValueEditorProps) => { helpText={helpText} errorMessage={error?.message} disabled={isDisabled} + ValueInput={ValueInput} /> ); } @@ -88,6 +114,7 @@ export const ValueEditor = (props: ValueEditorProps) => { helpText={helpText} errorMessage={error?.message} disabled={isDisabled} + ValueInput={ValueInput} /> ); } @@ -103,6 +130,7 @@ export const ValueEditor = (props: ValueEditorProps) => { helpText={helpText} errorMessage={error?.message} disabled={isDisabled} + ValueInput={ValueInput} /> ); }; @@ -117,11 +145,12 @@ function SingleValueEditor({ helpText, errorMessage, disabled, + ValueInput, }: BaseEditorProps) { return ( - void; @@ -158,6 +188,7 @@ function BetweenValueEditor({ helpText: HelpTextInfo | null; errorMessage?: string; disabled?: boolean; + ValueInput: ConditionsValueInput; }) { const [fromPlaceholder, toPlaceholder] = placeholder.split(',').map((p) => p.trim()); @@ -168,7 +199,7 @@ function BetweenValueEditor({ return ( - void; @@ -212,6 +244,7 @@ function RelativeDateEditor({ helpText: HelpTextInfo | null; errorMessage?: string; disabled?: boolean; + ValueInput: ConditionsValueInput; }) { const parseRelativeDateValue = (val: string): RelativeDateValue => { let parsedValue: RelativeDateValue = { amount: '', unit: 'days' }; @@ -277,7 +310,7 @@ function RelativeDateEditor({
- This is your primary integration for the {provider.channel} channel. )} + {((integration.rules && Object.keys(integration.rules).length > 0) || + (integration.conditions && integration.conditions.length > 0)) && ( + if + )} {integration.channel === ChannelTypeEnum.IN_APP && isFreePlan && ( { + return ( + onChange(event.target.value)} + /> + ); +}; diff --git a/apps/dashboard/src/components/integrations/components/integration-conditions-drawer.tsx b/apps/dashboard/src/components/integrations/components/integration-conditions-drawer.tsx new file mode 100644 index 00000000000..8a9f7697cba --- /dev/null +++ b/apps/dashboard/src/components/integrations/components/integration-conditions-drawer.tsx @@ -0,0 +1,214 @@ +import { IMessageFilter } from '@novu/shared'; +import { useCallback, useState } from 'react'; +import { Control, UseFormSetValue, useForm, useWatch } from 'react-hook-form'; +import { RiArrowRightSLine, RiGuideFill, RiInputField } from 'react-icons/ri'; +import { formatQuery, RQBJsonLogic, RuleGroupType } from 'react-querybuilder'; +import { parseJsonLogic } from 'react-querybuilder/parseJsonLogic'; +import { ConditionsEditor } from '@/components/conditions-editor/conditions-editor'; +import { ConfirmationModal } from '@/components/confirmation-modal'; +import { Button } from '@/components/primitives/button'; +import { Form, FormField } from '@/components/primitives/form/form'; +import { Panel, PanelContent, PanelHeader } from '@/components/primitives/panel'; +import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/primitives/sheet'; +import { VisuallyHidden } from '@/components/primitives/visually-hidden'; +import { useDataRef } from '@/hooks/use-data-ref'; +import { countConditions, customRuleProcessor, parseJsonLogicOptions } from '@/utils/conditions'; +import { cn } from '@/utils/ui'; +import { IntegrationFormData } from '../types'; +import { + countLegacyIntegrationConditions, + createEmptyConditionsQuery, + INTEGRATION_CONDITION_FIELDS, + INTEGRATION_CONDITION_VARIABLES, + isAllowedIntegrationConditionVariable, +} from '../utils/integration-conditions'; +import { IntegrationConditionValueInput } from './integration-condition-value-input'; + +const SIDEPANEL_ACTION_ROW_CLASS = 'flex h-12 w-full justify-start gap-1.5 rounded-none px-3 text-xs font-medium'; + +type IntegrationConditionsDrawerProps = { + control: Control; + setValue: UseFormSetValue; + legacyConditions?: IMessageFilter[]; + isReadOnly?: boolean; +}; + +type ConditionsFormValues = { + query: RuleGroupType; +}; + +function queryToRules(query: RuleGroupType): Record | null { + if (!query.rules.length) { + return null; + } + + const logic = formatQuery(query, { + format: 'jsonlogic', + ruleProcessor: customRuleProcessor, + }); + + if (!logic || typeof logic !== 'object') { + return null; + } + + return logic as Record; +} + +export function IntegrationConditionsDrawer({ + control, + setValue, + legacyConditions, + isReadOnly, +}: IntegrationConditionsDrawerProps) { + const rules = useWatch({ control, name: 'rules' }); + const primary = useWatch({ control, name: 'primary' }); + const integrationName = useWatch({ control, name: 'name' }); + const rulesRef = useDataRef(rules); + const legacyConditionsCount = countLegacyIntegrationConditions(legacyConditions); + const buildQuery = useCallback(() => { + if (rulesRef.current) { + return parseJsonLogic(rulesRef.current as RQBJsonLogic, { + generateIDs: true, + ...parseJsonLogicOptions, + }); + } + + return createEmptyConditionsQuery(); + }, [rulesRef]); + + const form = useForm({ + defaultValues: { + query: buildQuery(), + }, + }); + const [isOpen, setIsOpen] = useState(false); + const [pendingQuery, setPendingQuery] = useState(null); + + const query = form.watch('query'); + const conditionsCount = rules ? countConditions(rules as RQBJsonLogic) : legacyConditionsCount; + + const applyQuery = (nextQuery: RuleGroupType, unsetPrimary = false) => { + form.setValue('query', nextQuery); + setValue('rules', queryToRules(nextQuery), { shouldDirty: true }); + + if (unsetPrimary) { + setValue('primary', false, { shouldDirty: true }); + } + }; + + const handleQueryChange = (nextQuery: RuleGroupType) => { + const addingConditionsWhilePrimary = primary && nextQuery.rules.length > 0 && query.rules.length === 0; + + if (addingConditionsWhilePrimary) { + setPendingQuery(nextQuery); + + return; + } + + applyQuery(nextQuery); + }; + + const handleOpenChange = (open: boolean) => { + if (open) { + form.reset({ query: buildQuery() }); + } + + setIsOpen(open); + }; + + return ( + <> + + + + +
+ + Integration conditions +
+ + + Conditions that decide when this integration is selected to deliver a notification. + + + +
+
+ + + + Conditions for {integrationName || 'this integration'} + + + ( + undefined} + disabled={isReadOnly} + /> + )} + /> + + +

+ When a notification is sent, the first active integration whose conditions match is used. If none match, + the primary integration is used. +

+ {!rules && legacyConditionsCount > 0 && ( +

+ This integration still uses {legacyConditionsCount} legacy condition + {legacyConditionsCount === 1 ? '' : 's'} at send time. Saving new conditions here replaces them. +

+ )} +
+
+ +
+ +
+
+
+ + { + if (!open) { + setPendingQuery(null); + } + }} + onConfirm={() => { + if (pendingQuery) { + applyQuery(pendingQuery, true); + } + + setPendingQuery(null); + }} + title="Remove primary integration?" + description="An integration with conditions cannot be primary. Saving these conditions will unset this integration as primary." + confirmButtonText="Continue" + /> + + ); +} diff --git a/apps/dashboard/src/components/integrations/components/integration-general-settings.tsx b/apps/dashboard/src/components/integrations/components/integration-general-settings.tsx index 622fae7556f..5c48b5eb0dc 100644 --- a/apps/dashboard/src/components/integrations/components/integration-general-settings.tsx +++ b/apps/dashboard/src/components/integrations/components/integration-general-settings.tsx @@ -5,7 +5,9 @@ import { IProviderConfig, PermissionsEnum, } from '@novu/shared'; -import { Control } from 'react-hook-form'; +import { useState } from 'react'; +import { Control, useWatch, UseFormSetValue } from 'react-hook-form'; +import { ConfirmationModal } from '@/components/confirmation-modal'; import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/primitives/form/form'; import { Input } from '@/components/primitives/input'; import { Separator } from '@/components/primitives/separator'; @@ -17,6 +19,7 @@ import { ConfigurationGroup } from './configuration-group'; type GeneralSettingsProps = { control: Control; + setValue: UseFormSetValue; mode: 'create' | 'update'; isReadOnly?: boolean; hidePrimarySelector?: boolean; @@ -31,6 +34,7 @@ type GeneralSettingsProps = { export function GeneralSettings({ control, + setValue, mode, isReadOnly, hidePrimarySelector, @@ -47,6 +51,8 @@ export function GeneralSettings({ FeatureFlagsKeysEnum.IS_INBOUND_WEBHOOKS_CONFIGURATION_ENABLED, false ); + const rules = useWatch({ control, name: 'rules' }); + const [showPrimaryConfirm, setShowPrimaryConfirm] = useState(false); return (
@@ -86,7 +92,15 @@ export function GeneralSettings({ { + if (checked && rules) { + setShowPrimaryConfirm(true); + + return; + } + + field.onChange(checked); + }} disabled={disabledPrimary || isReadOnly} /> @@ -166,6 +180,19 @@ export function GeneralSettings({ )} + + { + setValue('rules', null, { shouldDirty: true }); + setValue('primary', true, { shouldDirty: true }); + setShowPrimaryConfirm(false); + }} + title="Remove conditions?" + description="A primary integration cannot have conditions. Making this integration primary will remove its conditions." + confirmButtonText="Continue" + />
); } diff --git a/apps/dashboard/src/components/integrations/components/integration-settings.tsx b/apps/dashboard/src/components/integrations/components/integration-settings.tsx index 3b151ccf261..cd1c34bd2a8 100644 --- a/apps/dashboard/src/components/integrations/components/integration-settings.tsx +++ b/apps/dashboard/src/components/integrations/components/integration-settings.tsx @@ -21,7 +21,9 @@ import { ROUTES } from '@/utils/routes'; import { cn } from '../../../utils/ui'; import { InlineToast } from '../../primitives/inline-toast'; import { EnvironmentDropdown } from '../../side-navigation/environment-dropdown'; +import { IntegrationFormData } from '../types'; import { CredentialSection } from './credential-section'; +import { IntegrationConditionsDrawer } from './integration-conditions-drawer'; import { GeneralSettings } from './integration-general-settings'; import { ProviderDeprecationNotice } from './provider-deprecation'; import { SlackCredentialsPaste } from './slack-credentials-paste'; @@ -33,17 +35,6 @@ import { isDemoIntegration } from './utils/helpers'; import { WhatsAppCredentialsPaste } from './whatsapp-credentials-paste'; import { WhatsAppCredentialsValidator } from './whatsapp-credentials-validator'; -type IntegrationFormData = { - name: string; - identifier: string; - credentials: Record; - configurations: Record; - active: boolean; - check: boolean; - primary: boolean; - environmentId: string; -}; - type IntegrationConfigurationProps = { provider: IProviderConfig; integration?: IIntegration; @@ -98,6 +89,7 @@ export function IntegrationSettings({ credentials: integration.credentials as Record, configurations: integration.configurations as Record, environmentId: integration._environmentId, + rules: integration.rules ?? null, } : { name: provider?.displayName ?? '', @@ -107,6 +99,7 @@ export function IntegrationSettings({ credentials: {}, configurations: {}, environmentId: currentEnvironment?._id ?? '', + rules: null, }, }); @@ -236,6 +229,7 @@ export function IntegrationSettings({
)} + + {!isAgentOnboarding && ( + + )} ); diff --git a/apps/dashboard/src/components/integrations/components/update-integration-sidebar.tsx b/apps/dashboard/src/components/integrations/components/update-integration-sidebar.tsx index 504a4fe0173..41b22b991fe 100644 --- a/apps/dashboard/src/components/integrations/components/update-integration-sidebar.tsx +++ b/apps/dashboard/src/components/integrations/components/update-integration-sidebar.tsx @@ -91,10 +91,11 @@ export function UpdateIntegrationSidebar({ isOpened }: UpdateIntegrationSidebarP credentials: cleanCredentials(data.credentials), check: data.check, configurations: data.configurations, + rules: data.rules ?? null, }, }); - if (data.primary && data.active && isChannelSupportPrimary) { + if (data.primary && data.active && isChannelSupportPrimary && !data.rules) { await setPrimaryIntegration({ integrationId: integration._id }); } diff --git a/apps/dashboard/src/components/integrations/components/utils/handle-integration-error.ts b/apps/dashboard/src/components/integrations/components/utils/handle-integration-error.ts index 6a5f2e13e99..3c2c8303bab 100644 --- a/apps/dashboard/src/components/integrations/components/utils/handle-integration-error.ts +++ b/apps/dashboard/src/components/integrations/components/utils/handle-integration-error.ts @@ -30,9 +30,7 @@ function formatValidationMessages(rawError: unknown): string | undefined { } if (Array.isArray(errorData.message)) { - const messages = (errorData.message as string[]) - .map((msg) => msg.replace(/^credentials\./, '')) - .filter(Boolean); + const messages = (errorData.message as string[]).map((msg) => msg.replace(/^credentials\./, '')).filter(Boolean); return messages.length > 0 ? messages.join('. ') : undefined; } diff --git a/apps/dashboard/src/components/integrations/types.ts b/apps/dashboard/src/components/integrations/types.ts index 4d499d59745..af2d9eb419e 100644 --- a/apps/dashboard/src/components/integrations/types.ts +++ b/apps/dashboard/src/components/integrations/types.ts @@ -9,7 +9,6 @@ export type TableIntegration = { channel: ChannelTypeEnum; environment: string; active: boolean; - conditions?: string[]; primary?: boolean; isPrimary?: boolean; }; @@ -23,6 +22,7 @@ export type IntegrationFormData = { configurations: Record; check: boolean; environmentId: string; + rules?: Record | null; }; export type IntegrationStep = 'select' | 'configure'; diff --git a/apps/dashboard/src/components/integrations/utils/integration-conditions.ts b/apps/dashboard/src/components/integrations/utils/integration-conditions.ts new file mode 100644 index 00000000000..eba9a3e4f0a --- /dev/null +++ b/apps/dashboard/src/components/integrations/utils/integration-conditions.ts @@ -0,0 +1,53 @@ +import { IMessageFilter } from '@novu/shared'; +import { generateID, RuleGroupType } from 'react-querybuilder'; +import type { EnhancedField } from '@/components/conditions-editor/conditions-editor'; +import type { EnhancedLiquidVariable, FieldDataType, IsAllowedVariable } from '@/utils/parseStepVariables'; + +const INTEGRATION_CONDITION_FIELD_DEFS: Array<{ name: string; dataType: FieldDataType }> = [ + { name: 'context.tenant.id', dataType: 'string' }, + { name: 'subscriber.subscriberId', dataType: 'string' }, + { name: 'subscriber.email', dataType: 'string' }, + { name: 'subscriber.phone', dataType: 'string' }, + { name: 'subscriber.firstName', dataType: 'string' }, + { name: 'subscriber.lastName', dataType: 'string' }, + { name: 'subscriber.locale', dataType: 'string' }, + { name: 'subscriber.data', dataType: 'object' }, +]; + +export const INTEGRATION_CONDITION_FIELDS: EnhancedField[] = INTEGRATION_CONDITION_FIELD_DEFS.map((field) => ({ + name: field.name, + label: field.name, + value: field.name, + dataType: field.dataType, +})); + +export const INTEGRATION_CONDITION_VARIABLES: EnhancedLiquidVariable[] = INTEGRATION_CONDITION_FIELD_DEFS.map( + (field) => ({ + name: field.name, + displayLabel: field.name, + dataType: field.dataType, + }) +); + +const ALLOWED_PREFIXES = ['context.', 'subscriber.'] as const; + +export const isAllowedIntegrationConditionVariable: IsAllowedVariable = (variable) => { + if (variable.name === 'subscriber.data') { + return true; + } + + return ALLOWED_PREFIXES.some((prefix) => variable.name.startsWith(prefix) && variable.name.length > prefix.length); +}; + +export function countLegacyIntegrationConditions(conditions?: IMessageFilter[]): number { + if (!conditions?.length) { + return 0; + } + + return conditions.reduce((sum, group) => sum + (group.children?.length ?? 0), 0); +} + +export function createEmptyConditionsQuery(): RuleGroupType { + + return { id: generateID(), combinator: 'and', rules: [] }; +} diff --git a/apps/dashboard/src/components/workflow-editor/steps/conditions/edit-step-conditions-form.tsx b/apps/dashboard/src/components/workflow-editor/steps/conditions/edit-step-conditions-form.tsx index 45bf47b84bd..f5e0765828d 100644 --- a/apps/dashboard/src/components/workflow-editor/steps/conditions/edit-step-conditions-form.tsx +++ b/apps/dashboard/src/components/workflow-editor/steps/conditions/edit-step-conditions-form.tsx @@ -2,24 +2,14 @@ import { standardSchemaResolver } from '@hookform/resolvers/standard-schema'; import { ContentIssueEnum, EnvironmentTypeEnum, type StepUpdateDto } from '@novu/shared'; import { useEffect, useMemo } from 'react'; import { useForm } from 'react-hook-form'; -import { - defaultRuleProcessorJsonLogic, - formatQuery, - generateID, - RQBJsonLogic, - RuleGroupType, - RuleType, -} from 'react-querybuilder'; +import { formatQuery, generateID, RQBJsonLogic, RuleGroupType, RuleType } from 'react-querybuilder'; import { parseJsonLogic } from 'react-querybuilder/parseJsonLogic'; import { z } from 'zod'; import { ConditionsEditor } from '@/components/conditions-editor/conditions-editor'; -import { - isRelativeDateOperator, - isUnaryJsonLogicOperator, - isValuelessOperator, -} from '@/components/conditions-editor/field-type-operators'; +import { isRelativeDateOperator, isValuelessOperator } from '@/components/conditions-editor/field-type-operators'; import { Form, FormField } from '@/components/primitives/form/form'; +import { ControlInput } from '@/components/workflow-editor/control-input'; import { updateStepInWorkflow } from '@/components/workflow-editor/step-utils'; import { useWorkflow } from '@/components/workflow-editor/workflow-provider'; import { useEnvironment } from '@/context/environment/hooks'; @@ -29,6 +19,7 @@ import { useParseVariables } from '@/hooks/use-parse-variables'; import { useTelemetry } from '@/hooks/use-telemetry'; import { countConditions, + customRuleProcessor, getUniqueFieldNamespaces, getUniqueOperators, parseJsonLogicOptions, @@ -41,60 +32,6 @@ const PAYLOAD_FIELD_PREFIX = 'payload.'; const SUBSCRIBER_DATA_FIELD_PREFIX = 'subscriber.data.'; const CONTEXT_FIELD_PREFIX = 'context.'; -const CONTAINS_ANY_OPERATORS = ['containsAny', 'doesNotContainAny'] as const; - -function isContainsAnyOperator(operator: string): boolean { - return (CONTAINS_ANY_OPERATORS as readonly string[]).includes(operator); -} - -const customRuleProcessor = (rule: RuleType, options: any) => { - if (isUnaryJsonLogicOperator(rule.operator)) { - return { - [rule.operator]: [{ var: rule.field }], - }; - } - - if (isRelativeDateOperator(rule.operator)) { - try { - const parsedValue = JSON.parse(rule.value as string); - - if ( - parsedValue && - (typeof parsedValue.amount === 'number' || typeof parsedValue.amount === 'string') && - parsedValue.unit - ) { - return { - [rule.operator]: [{ var: rule.field }, parsedValue], - }; - } - } catch (error) { - console.warn('Failed to parse relative date value:', rule.value, error); - } - } - - if (isContainsAnyOperator(rule.operator)) { - const trimmedValue = (rule.value as string).trim(); - const variableMatch = trimmedValue.match(/^\{\{(.+?)\}\}$/); - - if (variableMatch) { - return { - [rule.operator]: [{ var: rule.field }, { var: variableMatch[1].trim() }], - }; - } - - const values = trimmedValue - .split(',') - .map((v) => v.trim()) - .filter(Boolean); - - return { - [rule.operator]: [{ var: rule.field }, values], - }; - } - - return defaultRuleProcessorJsonLogic(rule, options); -}; - const getRuleSchema = ( fields: Array<{ value: string }>, isAllowedVariableFn: (variable: { name: string }) => boolean @@ -358,6 +295,7 @@ export const EditStepConditionsForm = () => { variables={variables} isAllowedVariable={isAllowedVariable} enhancedVariables={filteredEnhancedVariables} + valueInput={ControlInput} disabled={isReadOnly} /> )} diff --git a/apps/dashboard/src/utils/conditions.ts b/apps/dashboard/src/utils/conditions.ts index e78dd0b1314..8adbe024434 100644 --- a/apps/dashboard/src/utils/conditions.ts +++ b/apps/dashboard/src/utils/conditions.ts @@ -1,5 +1,6 @@ -import { RQBJsonLogic, RuleGroupType } from 'react-querybuilder'; +import { defaultRuleProcessorJsonLogic, RQBJsonLogic, RuleGroupType, RuleType } from 'react-querybuilder'; import { parseJsonLogic } from 'react-querybuilder/parseJsonLogic'; +import { isRelativeDateOperator, isUnaryJsonLogicOperator } from '@/components/conditions-editor/field-type-operators'; function parseArrayOperatorArgs(val: any, operator: string) { if (!val || !Array.isArray(val) || val.length < 2) { @@ -150,5 +151,58 @@ export const getUniqueOperators = (jsonLogic?: RQBJsonLogic): string[] => { return recursiveGetUniqueOperators(query); }; -// Export shared configuration for use in other files +const CONTAINS_ANY_OPERATORS = ['containsAny', 'doesNotContainAny'] as const; + +function isContainsAnyOperator(operator: string): boolean { + return (CONTAINS_ANY_OPERATORS as readonly string[]).includes(operator); +} + +export const customRuleProcessor = (rule: RuleType, options: Parameters[1]) => { + if (isUnaryJsonLogicOperator(rule.operator)) { + return { + [rule.operator]: [{ var: rule.field }], + }; + } + + if (isRelativeDateOperator(rule.operator)) { + try { + const parsedValue = JSON.parse(rule.value as string); + + if ( + parsedValue && + (typeof parsedValue.amount === 'number' || typeof parsedValue.amount === 'string') && + parsedValue.unit + ) { + return { + [rule.operator]: [{ var: rule.field }, parsedValue], + }; + } + } catch { + // Fall through to the default processor when the relative-date payload is invalid. + } + } + + if (isContainsAnyOperator(rule.operator)) { + const trimmedValue = (rule.value as string).trim(); + const variableMatch = trimmedValue.match(/^\{\{(.+?)\}\}$/); + + if (variableMatch) { + return { + [rule.operator]: [{ var: rule.field }, { var: variableMatch[1].trim() }], + }; + } + + const values = trimmedValue + .split(',') + .map((v) => v.trim()) + .filter(Boolean); + + return { + [rule.operator]: [{ var: rule.field }, values], + }; + } + + return defaultRuleProcessorJsonLogic(rule, options); +}; + export { parseJsonLogicOptions }; diff --git a/apps/worker/src/app/workflow/usecases/send-message/channel-endpoint-resolution/resolve-channel-endpoints.command.ts b/apps/worker/src/app/workflow/usecases/send-message/channel-endpoint-resolution/resolve-channel-endpoints.command.ts index edd1c3a7cbf..722a4485020 100644 --- a/apps/worker/src/app/workflow/usecases/send-message/channel-endpoint-resolution/resolve-channel-endpoints.command.ts +++ b/apps/worker/src/app/workflow/usecases/send-message/channel-endpoint-resolution/resolve-channel-endpoints.command.ts @@ -1,6 +1,6 @@ -import { EnvironmentWithUserCommand } from '@novu/application-generic'; -import { ChannelTypeEnum } from '@novu/shared'; -import { IsArray, IsDefined, IsEnum, IsOptional, IsString } from 'class-validator'; +import { EnvironmentWithUserCommand, type ICompileContext } from '@novu/application-generic'; +import { ChannelTypeEnum, ITenantDefine } from '@novu/shared'; +import { IsArray, IsDefined, IsEnum, IsObject, IsString } from 'class-validator'; export class ResolveChannelEndpointsCommand extends EnvironmentWithUserCommand { @IsDefined() @@ -14,4 +14,16 @@ export class ResolveChannelEndpointsCommand extends EnvironmentWithUserCommand { @IsArray() @IsString({ each: true }) contextKeys: string[]; + + /** + * Same shape as `SelectIntegrationCommand.filterData`. Only `subscriber` and `context` + * feed integration rule evaluation; `tenant` is reachable through `context.tenant`. + */ + @IsDefined() + @IsObject() + filterData: { + tenant?: ITenantDefine | string; + subscriber?: ICompileContext['subscriber'] | Record; + context?: ICompileContext['context'] | Record; + }; } diff --git a/apps/worker/src/app/workflow/usecases/send-message/channel-endpoint-resolution/resolve-channel-endpoints.usecase.spec.ts b/apps/worker/src/app/workflow/usecases/send-message/channel-endpoint-resolution/resolve-channel-endpoints.usecase.spec.ts index aa2459e94ad..3e5cedc101e 100644 --- a/apps/worker/src/app/workflow/usecases/send-message/channel-endpoint-resolution/resolve-channel-endpoints.usecase.spec.ts +++ b/apps/worker/src/app/workflow/usecases/send-message/channel-endpoint-resolution/resolve-channel-endpoints.usecase.spec.ts @@ -620,6 +620,126 @@ describe('ResolveChannelEndpoints - Tool Webhook', () => { }); }); +describe('ResolveChannelEndpoints - integration rules', () => { + let sandbox: sinon.SinonSandbox; + let channelEndpointRepository: Record; + let channelConnectionRepository: Record; + let integrationRepository: Record; + let usecase: ResolveChannelEndpoints; + + /** Mirrors a subscriber registered on both a Telegram and a chat-webhook integration. */ + function givenIntegrations(integrations: Array<{ identifier: string; rules?: unknown }>) { + integrationRepository.find.resolves(integrations); + channelEndpointRepository.find.resolves( + integrations.map(({ identifier }) => + buildTelegramEndpoint({ identifier: `${identifier}-endpoint`, integrationIdentifier: identifier }) + ) + ); + } + + function resolvedIdentifiers(groups: Array<{ integrationIdentifier: string }>) { + return groups.map((group) => group.integrationIdentifier); + } + + beforeEach(() => { + sandbox = sinon.createSandbox(); + + channelEndpointRepository = { + find: sandbox.stub(), + buildContextExactMatchQuery: sandbox.stub().returns({}), + }; + channelConnectionRepository = { + find: sandbox.stub().resolves([]), + buildContextExactMatchQuery: sandbox.stub().returns({}), + }; + integrationRepository = { + findOne: sandbox.stub(), + find: sandbox.stub().resolves([]), + }; + + usecase = new ResolveChannelEndpoints( + channelEndpointRepository as any, + channelConnectionRepository as any, + integrationRepository as any, + { getBotFrameworkToken: sandbox.stub() } as any, + { getConnectionToken: sandbox.stub() } as any + ); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('fans out to every integration when none define rules', async () => { + givenIntegrations([{ identifier: 'telegram-integration' }, { identifier: 'chat-webhook' }]); + + const result = await usecase.execute(buildCommand()); + + expect(resolvedIdentifiers(result)).to.deep.equal(['telegram-integration', 'chat-webhook']); + }); + + it('keeps only the integration whose rules match the subscriber', async () => { + givenIntegrations([ + { identifier: 'telegram-integration', rules: { '==': [{ var: 'subscriber.locale' }, 'fr'] } }, + { identifier: 'chat-webhook', rules: { '==': [{ var: 'subscriber.locale' }, 'de'] } }, + ]); + + const result = await usecase.execute(buildCommand({ filterData: { subscriber: { locale: 'fr' } } })); + + expect(resolvedIdentifiers(result)).to.deep.equal(['telegram-integration']); + }); + + it('keeps only the integration whose rules match the trigger tenant context', async () => { + givenIntegrations([ + { identifier: 'telegram-integration', rules: { and: [{ '==': [{ var: 'context.tenant.id' }, 'vasilib'] }] } }, + { identifier: 'chat-webhook', rules: { and: [{ '==': [{ var: 'context.tenant.id' }, 'acme'] }] } }, + ]); + + const result = await usecase.execute(buildCommand({ filterData: { context: { tenant: { id: 'acme' } } } })); + + expect(resolvedIdentifiers(result)).to.deep.equal(['chat-webhook']); + }); + + it('still delivers through integrations without rules alongside a matching one', async () => { + givenIntegrations([ + { identifier: 'telegram-integration', rules: { '==': [{ var: 'subscriber.locale' }, 'fr'] } }, + { identifier: 'chat-webhook' }, + ]); + + const result = await usecase.execute(buildCommand({ filterData: { subscriber: { locale: 'fr' } } })); + + expect(resolvedIdentifiers(result)).to.deep.equal(['telegram-integration', 'chat-webhook']); + }); + + it('resolves no endpoints when no integration rules match', async () => { + givenIntegrations([ + { identifier: 'telegram-integration', rules: { '==': [{ var: 'subscriber.locale' }, 'fr'] } }, + { identifier: 'chat-webhook', rules: { '==': [{ var: 'subscriber.locale' }, 'de'] } }, + ]); + + const result = await usecase.execute(buildCommand({ filterData: { subscriber: { locale: 'es' } } })); + + expect(result).to.deep.equal([]); + sinon.assert.notCalled(channelConnectionRepository.find); + }); + + it('skips integrations whose rules use unsupported json-logic operators', async () => { + givenIntegrations([{ identifier: 'telegram-integration', rules: { log: { var: 'subscriber.email' } } }]); + + const result = await usecase.execute(buildCommand({ filterData: { subscriber: { email: 'secret@example.com' } } })); + + expect(result).to.deep.equal([]); + }); + + it('reads the rules field so gating can be applied', async () => { + givenIntegrations([{ identifier: 'telegram-integration' }]); + + await usecase.execute(buildCommand()); + + expect(integrationRepository.find.firstCall.args[1]).to.equal('identifier rules'); + }); +}); + function buildCommand(overrides: Record = {}) { return { organizationId: ORGANIZATION_ID, @@ -628,10 +748,27 @@ function buildCommand(overrides: Record = {}) { subscriberId: SUBSCRIBER_ID, channelType: ChannelTypeEnum.CHAT, contextKeys: [], + filterData: {}, ...overrides, } as any; } +function buildTelegramEndpoint(overrides: Record = {}) { + return { + _environmentId: ENVIRONMENT_ID, + _organizationId: ORGANIZATION_ID, + identifier: 'telegram-endpoint', + integrationIdentifier: INTEGRATION_IDENTIFIER, + providerId: ChatProviderIdEnum.Telegram, + channel: ChannelTypeEnum.CHAT, + subscriberId: SUBSCRIBER_ID, + contextKeys: [], + type: ENDPOINT_TYPES.TELEGRAM_CHAT, + endpoint: { chatId: '495078234' }, + ...overrides, + }; +} + function buildWebexEndpoint(overrides: Record = {}) { return { _environmentId: ENVIRONMENT_ID, diff --git a/apps/worker/src/app/workflow/usecases/send-message/channel-endpoint-resolution/resolve-channel-endpoints.usecase.ts b/apps/worker/src/app/workflow/usecases/send-message/channel-endpoint-resolution/resolve-channel-endpoints.usecase.ts index 6051563b45d..d22bb123943 100644 --- a/apps/worker/src/app/workflow/usecases/send-message/channel-endpoint-resolution/resolve-channel-endpoints.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/send-message/channel-endpoint-resolution/resolve-channel-endpoints.usecase.ts @@ -1,8 +1,11 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { decryptChannelConnectionAuth, decryptChannelEndpoint, decryptCredentials, + evaluateRules, + getIntegrationRulesIssues, + hasIntegrationRules, InstrumentUsecase, MsTeamsTokenService, RotatingConnectionTokenService, @@ -12,12 +15,16 @@ import { ChannelConnectionRepository, ChannelEndpointEntity, ChannelEndpointRepository, + IntegrationEntity, IntegrationRepository, } from '@novu/dal'; import { ProvidersIdEnum } from '@novu/shared'; import { ChannelData, ENDPOINT_TYPES, ENDPOINT_TYPES_REQUIRING_TOKEN } from '@novu/stateless'; +import { AdditionalOperation, RulesLogic } from 'json-logic-js'; import { ResolveChannelEndpointsCommand } from './resolve-channel-endpoints.command'; +const LOG_CONTEXT = 'ResolveChannelEndpoints'; + type EndpointStoredSecretConfig = { providerLabel: string; /** Fields that must all be present (non-empty) after decrypt; missing any triggers one combined error. */ @@ -82,13 +89,18 @@ export class ResolveChannelEndpoints { return []; } - const deliverableEndpoints = await this.keepEndpointsForActiveIntegrations(command, endpoints); + const deliverableEndpoints = await this.keepEndpointsForDeliverableIntegrations(command, endpoints); + + if (deliverableEndpoints.length === 0) { + return []; + } + const connectionMap = await this.fetchConnectionMap(command, deliverableEndpoints); return this.buildIntegrationGroups(deliverableEndpoints, connectionMap); } - private async keepEndpointsForActiveIntegrations( + private async keepEndpointsForDeliverableIntegrations( command: ResolveChannelEndpointsCommand, endpoints: ChannelEndpointEntity[] ): Promise { @@ -102,11 +114,59 @@ export class ResolveChannelEndpoints { channel: command.channelType, active: true, }, - 'identifier' + 'identifier rules' + ); + const deliverableIdentifiers = new Set( + activeIntegrations + .filter((integration) => this.integrationRulesMatch(command, integration)) + .map((integration) => integration.identifier) + ); + + return endpoints.filter((endpoint) => deliverableIdentifiers.has(endpoint.integrationIdentifier)); + } + + /** + * Endpoint-routed delivery pins the integration by identifier, which makes `SelectIntegration` + * take its identifier shortcut and skip conditions entirely. Rules are therefore applied here, + * otherwise a subscriber holding endpoints on several integrations is notified through every one + * of them regardless of their conditions. + * + * Only `rules` (JSONLogic) are evaluated — legacy `conditions` predate the endpoint model and are + * left to `SelectIntegration`, matching the precedence rules take there. + */ + private integrationRulesMatch( + command: ResolveChannelEndpointsCommand, + integration: Pick + ): boolean { + if (!hasIntegrationRules(integration.rules)) { + return true; + } + + const issues = getIntegrationRulesIssues(integration.rules); + if (issues.length > 0) { + Logger.warn( + { + issues, + integrationIdentifier: integration.identifier, + environmentId: command.environmentId, + subscriberId: command.subscriberId, + }, + `${LOG_CONTEXT} — skipping endpoints for integration with invalid rules` + ); + + return false; + } + + const { result } = evaluateRules( + integration.rules as RulesLogic, + { + subscriber: command.filterData?.subscriber, + context: command.filterData?.context, + }, + true ); - const activeIdentifiers = new Set(activeIntegrations.map((integration) => integration.identifier)); - return endpoints.filter((endpoint) => activeIdentifiers.has(endpoint.integrationIdentifier)); + return result; } private async fetchChannelEndpoints(command: ResolveChannelEndpointsCommand): Promise { diff --git a/apps/worker/src/app/workflow/usecases/send-message/send-message-chat.usecase.ts b/apps/worker/src/app/workflow/usecases/send-message/send-message-chat.usecase.ts index 3017288277b..d2abab48be8 100644 --- a/apps/worker/src/app/workflow/usecases/send-message/send-message-chat.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/send-message/send-message-chat.usecase.ts @@ -455,9 +455,7 @@ export class SendMessageChat extends SendMessageBase { channelType: ChannelTypeEnum.CHAT, providerId, userId: command.userId, - filterData: { - tenant: command.job.tenant, - }, + filterData: this.getIntegrationFilterData(command), }) ); @@ -644,6 +642,7 @@ export class SendMessageChat extends SendMessageBase { subscriberId: command.subscriberId, channelType: ChannelTypeEnum.CHAT, contextKeys: command.contextKeys, + filterData: this.getIntegrationFilterData(command), }) ); } @@ -1022,9 +1021,7 @@ export class SendMessageChat extends SendMessageBase { providerId, channelType: ChannelTypeEnum.CHAT, userId: command.userId, - filterData: { - tenant: command.job.tenant, - }, + filterData: this.getIntegrationFilterData(command), ...(integrationId && { id: integrationId }), ...(integrationIdentifier && { identifier: integrationIdentifier }), }; diff --git a/apps/worker/src/app/workflow/usecases/send-message/send-message-email.usecase.ts b/apps/worker/src/app/workflow/usecases/send-message/send-message-email.usecase.ts index decd409f5f8..614257b1e72 100644 --- a/apps/worker/src/app/workflow/usecases/send-message/send-message-email.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/send-message/send-message-email.usecase.ts @@ -104,9 +104,7 @@ export class SendMessageEmail extends SendMessageBase { userId: command.userId, recipientEmail: email, identifier: overrideSelectedIntegration as string, - filterData: { - tenant: command.job.tenant, - }, + filterData: this.getIntegrationFilterData(command), }); } catch (e) { let detailEnum = DetailEnum.LIMIT_PASSED_NOVU_INTEGRATION; diff --git a/apps/worker/src/app/workflow/usecases/send-message/send-message-in-app.usecase.ts b/apps/worker/src/app/workflow/usecases/send-message/send-message-in-app.usecase.ts index afa24ad210a..b3f43790567 100644 --- a/apps/worker/src/app/workflow/usecases/send-message/send-message-in-app.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/send-message/send-message-in-app.usecase.ts @@ -76,9 +76,7 @@ export class SendMessageInApp extends SendMessageBase { environmentId: command.environmentId, channelType: ChannelTypeEnum.IN_APP, userId: command.userId, - filterData: { - tenant: command.job.tenant, - }, + filterData: this.getIntegrationFilterData(command), }); if (!integration) { diff --git a/apps/worker/src/app/workflow/usecases/send-message/send-message-push.usecase.ts b/apps/worker/src/app/workflow/usecases/send-message/send-message-push.usecase.ts index 816df2a535f..18138cce7d4 100644 --- a/apps/worker/src/app/workflow/usecases/send-message/send-message-push.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/send-message/send-message-push.usecase.ts @@ -547,9 +547,7 @@ export class SendMessagePush extends SendMessageBase { channelType: ChannelTypeEnum.PUSH, providerId: channel.providerId, userId: command.userId, - filterData: { - tenant: command.job.tenant, - }, + filterData: this.getIntegrationFilterData(command), }); if (!integration) { @@ -836,9 +834,7 @@ export class SendMessagePush extends SendMessageBase { channelType: ChannelTypeEnum.PUSH, providerId: providerOverride.providerId, userId: command.userId, - filterData: { - tenant: command.job.tenant, - }, + filterData: this.getIntegrationFilterData(command), }); if (!integration) continue; diff --git a/apps/worker/src/app/workflow/usecases/send-message/send-message-sms.usecase.ts b/apps/worker/src/app/workflow/usecases/send-message/send-message-sms.usecase.ts index bb35730d185..eed0f93fb4d 100644 --- a/apps/worker/src/app/workflow/usecases/send-message/send-message-sms.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/send-message/send-message-sms.usecase.ts @@ -68,9 +68,7 @@ export class SendMessageSms extends SendMessageBase { channelType: ChannelTypeEnum.SMS, userId: command.userId, identifier: overrideSelectedIntegration as string, - filterData: { - tenant: command.job.tenant, - }, + filterData: this.getIntegrationFilterData(command), }); addBreadcrumb({ diff --git a/apps/worker/src/app/workflow/usecases/send-message/send-message-tool.usecase.ts b/apps/worker/src/app/workflow/usecases/send-message/send-message-tool.usecase.ts index ef62bc2e111..817b5fc5dc6 100644 --- a/apps/worker/src/app/workflow/usecases/send-message/send-message-tool.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/send-message/send-message-tool.usecase.ts @@ -224,6 +224,7 @@ export class SendMessageTool extends SendMessageBase { subscriberId: command.subscriberId, channelType: ChannelTypeEnum.TOOL, contextKeys: command.contextKeys, + filterData: this.getIntegrationFilterData(command), }) ); diff --git a/apps/worker/src/app/workflow/usecases/send-message/send-message.base.ts b/apps/worker/src/app/workflow/usecases/send-message/send-message.base.ts index 188905abed5..ab4955a938b 100644 --- a/apps/worker/src/app/workflow/usecases/send-message/send-message.base.ts +++ b/apps/worker/src/app/workflow/usecases/send-message/send-message.base.ts @@ -97,7 +97,9 @@ export abstract class SendMessageBase extends SendMessageType { userId: string; recipientEmail?: string; filterData: { - tenant: ITenantDefine | undefined; + tenant?: ITenantDefine; + subscriber?: SendMessageChannelCommand['compileContext']['subscriber']; + context?: SendMessageChannelCommand['compileContext']['context']; }; }): Promise { const integration = await this.selectIntegration.execute(SelectIntegrationCommand.create(params)); @@ -124,6 +126,14 @@ export abstract class SendMessageBase extends SendMessageType { return integration; } + protected getIntegrationFilterData(command: SendMessageChannelCommand) { + return { + tenant: command.job.tenant, + subscriber: command.compileContext?.subscriber, + context: command.compileContext?.context, + }; + } + protected storeContent(): boolean { return this.channelType === ChannelTypeEnum.IN_APP || process.env.STORE_NOTIFICATION_CONTENT === 'true'; } diff --git a/docs/api-reference/integrations/integration-schema.mdx b/docs/api-reference/integrations/integration-schema.mdx index ff21257f162..486e4d6de50 100644 --- a/docs/api-reference/integrations/integration-schema.mdx +++ b/docs/api-reference/integrations/integration-schema.mdx @@ -24,7 +24,8 @@ Integration is third party service used by Novu to send notification for a speci | `deletedAt` | `string` | The timestamp indicating when the integration was deleted. This is set when the integration is soft deleted. | | `deletedBy` | `string` | The identifier of the user who performed the deletion of this integration. Useful for audit trails. | | `primary` | `boolean` | Indicates whether this integration is marked as primary. A primary integration is often the default choice for processing. | -| `conditions` | `StepFilterDto[]` | An array of conditions associated with the integration that may influence its behavior or processing logic. | +| `conditions` | `StepFilterDto[]` | Legacy StepFilter conditions. Ignored when `rules` is also set. | +| `rules` | `Record` | JSONLogic used at send time to select this integration. Takes precedence over `conditions`. | ### Credentials diff --git a/docs/platform/concepts/workflows.mdx b/docs/platform/concepts/workflows.mdx index 63127e8e705..129c3e5514f 100644 --- a/docs/platform/concepts/workflows.mdx +++ b/docs/platform/concepts/workflows.mdx @@ -190,6 +190,6 @@ These are some of the most frequently asked questions about workflows in Novu. - Currently, only a single workflow can be synced at a time. + Yes. The [Publish changes](/platform/developer/environments#publish-changes-to-other-environments) flow in the dashboard lets you select multiple workflows to publish at once. The [Publish resources to target environment](/api-reference/environments/publish-resources-to-target-environment) API endpoint publishes all resources when no specific resources are listed in the request. diff --git a/docs/platform/developer/environments.mdx b/docs/platform/developer/environments.mdx index 709d72002d2..de56c778c21 100644 --- a/docs/platform/developer/environments.mdx +++ b/docs/platform/developer/environments.mdx @@ -86,6 +86,16 @@ You can promote changes to other environments by following these steps: ![Publish changes](/images/developer-tools/publish-changes-modal.png) 5. Click the publish button to publish the selected workflows to the selected environment. +### Publish via API + +You can also publish programmatically with the [Publish resources to target environment](/api-reference/environments/publish-resources-to-target-environment) endpoint (`POST /v2/environments/{targetEnvironmentId}/publish`). The request body accepts: + +- `sourceEnvironmentId`: the environment to sync from. Defaults to the Development environment. +- `resources`: an array of specific resources to publish, each with a `resourceType` (`workflow`, `layout`, or `agent`) and a `resourceId`. `resourceId` is the public identifier: the workflow trigger identifier (`workflowId`), layout identifier, or agent identifier. Do not pass an internal database `_id`. If omitted, all resources are published. If none of the supplied IDs match a resource in the source environment, the request succeeds but publishes nothing. +- `dryRun`: set to `true` to preview the changes without applying them. + +Publishing happens at the workflow level. There is no per-step publish action. + ## Frequently asked questions Frequently asked questions related to managing environments: diff --git a/libs/application-generic/src/dtos/integration-response.dto.ts b/libs/application-generic/src/dtos/integration-response.dto.ts index 39129ebef1d..7335027a39f 100644 --- a/libs/application-generic/src/dtos/integration-response.dto.ts +++ b/libs/application-generic/src/dtos/integration-response.dto.ts @@ -107,8 +107,21 @@ export class IntegrationResponseDto { @ApiPropertyOptional({ description: - 'An array of conditions associated with the integration that may influence its behavior or processing logic.', + 'Legacy StepFilter conditions. Ignored when `rules` is also set.', type: [StepFilterDto], + deprecated: true, }) conditions?: StepFilterDto[]; + + @ApiPropertyOptional({ + description: + 'JSONLogic used at send time to select this integration. Takes precedence over `conditions`.', + type: 'object', + additionalProperties: true, + nullable: true, + example: { + '==': [{ var: 'context.tenant.id' }, 'acme'], + }, + }) + rules?: object | null; } diff --git a/libs/application-generic/src/services/workflow-run.service.ts b/libs/application-generic/src/services/workflow-run.service.ts index 997a4fcf11d..b1063c7e980 100644 --- a/libs/application-generic/src/services/workflow-run.service.ts +++ b/libs/application-generic/src/services/workflow-run.service.ts @@ -911,7 +911,7 @@ export class WorkflowRunService { const channelJobs = jobs.filter((job) => job.type && ['in_app', 'email', 'sms', 'chat', 'push'].includes(job.type)); const logResolution = (status: DeliveryLifecycleStatusEnum, extra?: Record) => { - this.logger.info( + this.logger.debug( { notificationId, resolvedStatus: status, diff --git a/libs/application-generic/src/usecases/select-integration/select-integration.command.ts b/libs/application-generic/src/usecases/select-integration/select-integration.command.ts index 38c1dd3f0ee..2089ac6d75e 100644 --- a/libs/application-generic/src/usecases/select-integration/select-integration.command.ts +++ b/libs/application-generic/src/usecases/select-integration/select-integration.command.ts @@ -2,6 +2,7 @@ import { ChannelTypeEnum, ITenantDefine, ProvidersIdEnum } from '@novu/shared'; import { IsDefined, IsMongoId, IsOptional } from 'class-validator'; import { EnvironmentCommand } from '../../commands/project.command'; +import type { ICompileContext } from '../../types/compile-context'; export class SelectIntegrationCommand extends EnvironmentCommand { @IsOptional() @@ -19,7 +20,9 @@ export class SelectIntegrationCommand extends EnvironmentCommand { @IsDefined() filterData: { - tenant?: ITenantDefine; + tenant?: ITenantDefine | string; + subscriber?: ICompileContext['subscriber'] | Record; + context?: ICompileContext['context'] | Record; }; @IsOptional() diff --git a/libs/application-generic/src/usecases/select-integration/select-integration.spec.ts b/libs/application-generic/src/usecases/select-integration/select-integration.spec.ts index b887115b549..499efdb6739 100644 --- a/libs/application-generic/src/usecases/select-integration/select-integration.spec.ts +++ b/libs/application-generic/src/usecases/select-integration/select-integration.spec.ts @@ -8,7 +8,13 @@ import { SubscriberRepository, TenantRepository, } from '@novu/dal'; -import { ChannelTypeEnum, EmailProviderIdEnum } from '@novu/shared'; +import { + ChannelTypeEnum, + EmailProviderIdEnum, + FieldLogicalOperatorEnum, + FieldOperatorEnum, + FilterPartTypeEnum, +} from '@novu/shared'; import { FeatureFlagsService, TraceLogRepository } from '../../services'; import { CompileTemplate } from '../compile-template'; import { ConditionsFilter } from '../conditions-filter'; @@ -69,11 +75,13 @@ const novuIntegration: IntegrationEntity = { }; const findOneMock = jest.fn(() => testIntegration); +const findMock = jest.fn(() => []); jest.mock('@novu/dal', () => ({ ...jest.requireActual('@novu/dal'), IntegrationRepository: jest.fn(() => ({ findOne: findOneMock, + find: findMock, })), })); @@ -99,9 +107,23 @@ describe('select integration', () => { { setContext: jest.fn(), info: jest.fn() } as any ); beforeEach(async () => { - // @ts-expect-error - useCase = new SelectIntegration(integrationRepository, conditionsFilter, new TenantRepository()); jest.clearAllMocks(); + findMock.mockReturnValue([]); + findOneMock.mockReturnValue(testIntegration); + + const featureFlagsService = { + getFlag: jest.fn().mockResolvedValue(false), + }; + const normalizeVariablesUsecase = { + execute: jest.fn().mockResolvedValue({}), + }; + useCase = new SelectIntegration( + integrationRepository, + conditionsFilter, + new TenantRepository(), + normalizeVariablesUsecase as never, + featureFlagsService as never + ); }); it('should select the integration', async () => { @@ -250,4 +272,234 @@ describe('select integration', () => { expect(integration).not.toBeUndefined(); expect(integration?.identifier).toEqual(identifier); }); + + it('should select the first integration matching JsonLogic conditions', async () => { + const matchingIntegration: IntegrationEntity = { + ...testIntegration, + _id: 'conditioned-integration', + identifier: 'conditioned-integration-identifier', + primary: false, + rules: { + '==': [{ var: 'subscriber.locale' }, 'fr'], + }, + }; + + findOneMock.mockReturnValue(testIntegration); + findMock.mockReturnValue([matchingIntegration]); + + const integration = await useCase.execute( + SelectIntegrationCommand.create({ + channelType: ChannelTypeEnum.EMAIL, + environmentId: 'environmentId', + organizationId: 'organizationId', + userId: 'userId', + filterData: { + subscriber: { locale: 'fr' }, + }, + }) + ); + + expect(integration?.identifier).toEqual(matchingIntegration.identifier); + }); + + it('should not apply unsafe json-logic operators and fall back to primary', async () => { + const unsafeIntegration: IntegrationEntity = { + ...testIntegration, + _id: 'unsafe-integration', + identifier: 'unsafe-integration-identifier', + primary: false, + rules: { + log: { var: 'subscriber.email' }, + }, + }; + + findOneMock.mockReturnValue(testIntegration); + findMock.mockReturnValue([unsafeIntegration]); + + const integration = await useCase.execute( + SelectIntegrationCommand.create({ + channelType: ChannelTypeEnum.EMAIL, + environmentId: 'environmentId', + organizationId: 'organizationId', + userId: 'userId', + filterData: { + subscriber: { email: 'secret@example.com' }, + }, + }) + ); + + expect(integration?.identifier).toEqual(testIntegration.identifier); + }); + + it('should fall back to primary when JsonLogic conditions do not match', async () => { + const matchingIntegration: IntegrationEntity = { + ...testIntegration, + _id: 'conditioned-integration', + identifier: 'conditioned-integration-identifier', + primary: false, + rules: { + '==': [{ var: 'context.tenant.id' }, 'acme'], + }, + }; + + findOneMock.mockReturnValue(testIntegration); + findMock.mockReturnValue([matchingIntegration]); + + const integration = await useCase.execute( + SelectIntegrationCommand.create({ + channelType: ChannelTypeEnum.EMAIL, + environmentId: 'environmentId', + organizationId: 'organizationId', + userId: 'userId', + filterData: { + context: { tenant: { id: 'other' } }, + }, + }) + ); + + expect(integration?.identifier).toEqual(testIntegration.identifier); + }); + + it('queries only conditioned integrations when no identifier is provided', async () => { + await useCase.execute( + SelectIntegrationCommand.create({ + channelType: ChannelTypeEnum.EMAIL, + environmentId: 'environmentId', + organizationId: 'organizationId', + userId: 'userId', + filterData: {}, + }) + ); + + expect(findMock).toHaveBeenCalledWith( + { + _organizationId: 'organizationId', + _environmentId: 'environmentId', + channel: ChannelTypeEnum.EMAIL, + active: true, + $or: [{ rules: { $type: 'object' } }, { 'conditions.0': { $exists: true } }], + }, + '', + { sort: { priority: -1, createdAt: -1 } } + ); + expect(findOneMock).toHaveBeenCalled(); + }); + + it('does not scan conditioned integrations when identifier is provided', async () => { + await useCase.execute( + SelectIntegrationCommand.create({ + channelType: ChannelTypeEnum.EMAIL, + environmentId: 'environmentId', + organizationId: 'organizationId', + userId: 'userId', + identifier: 'test-integration-identifier', + filterData: {}, + }) + ); + + expect(findMock).not.toHaveBeenCalled(); + }); + + it('selects the first matching integration in priority then createdAt order', async () => { + const firstMatch: IntegrationEntity = { + ...testIntegration, + _id: 'first-match', + identifier: 'first-match-identifier', + primary: false, + priority: 5, + rules: { + '==': [{ var: 'subscriber.locale' }, 'fr'], + }, + }; + const secondMatch: IntegrationEntity = { + ...testIntegration, + _id: 'second-match', + identifier: 'second-match-identifier', + primary: false, + priority: 1, + rules: { + '==': [{ var: 'subscriber.locale' }, 'fr'], + }, + }; + + findOneMock.mockReturnValue(testIntegration); + findMock.mockReturnValue([firstMatch, secondMatch]); + + const integration = await useCase.execute( + SelectIntegrationCommand.create({ + channelType: ChannelTypeEnum.EMAIL, + environmentId: 'environmentId', + organizationId: 'organizationId', + userId: 'userId', + filterData: { + subscriber: { locale: 'fr' }, + }, + }) + ); + + expect(findMock).toHaveBeenCalledWith( + expect.objectContaining({ + $or: [{ rules: { $type: 'object' } }, { 'conditions.0': { $exists: true } }], + }), + '', + { sort: { priority: -1, createdAt: -1 } } + ); + expect(integration?.identifier).toEqual(firstMatch.identifier); + }); + + it('prefers rules over contradictory legacy conditions', async () => { + const dualFormatIntegration: IntegrationEntity = { + ...testIntegration, + _id: 'dual-format', + identifier: 'dual-format-identifier', + primary: false, + rules: { + '==': [{ var: 'subscriber.locale' }, 'fr'], + }, + conditions: [ + { + value: FieldLogicalOperatorEnum.AND, + children: [ + { + field: 'locale', + value: 'de', + operator: FieldOperatorEnum.EQUAL, + on: FilterPartTypeEnum.SUBSCRIBER, + }, + ], + }, + ], + }; + + findOneMock.mockReturnValue(testIntegration); + findMock.mockReturnValue([dualFormatIntegration]); + + const ignoredLegacy = await useCase.execute( + SelectIntegrationCommand.create({ + channelType: ChannelTypeEnum.EMAIL, + environmentId: 'environmentId', + organizationId: 'organizationId', + userId: 'userId', + filterData: { + subscriber: { locale: 'de' }, + }, + }) + ); + + expect(ignoredLegacy?.identifier).toEqual(testIntegration.identifier); + + const matchedRules = await useCase.execute( + SelectIntegrationCommand.create({ + channelType: ChannelTypeEnum.EMAIL, + environmentId: 'environmentId', + organizationId: 'organizationId', + userId: 'userId', + filterData: { + subscriber: { locale: 'fr' }, + }, + }) + ); + + expect(matchedRules?.identifier).toEqual(dualFormatIntegration.identifier); + }); }); diff --git a/libs/application-generic/src/usecases/select-integration/select-integration.usecase.ts b/libs/application-generic/src/usecases/select-integration/select-integration.usecase.ts index d5e5308327e..28dcb2b7208 100644 --- a/libs/application-generic/src/usecases/select-integration/select-integration.usecase.ts +++ b/libs/application-generic/src/usecases/select-integration/select-integration.usecase.ts @@ -1,8 +1,15 @@ import { Injectable } from '@nestjs/common'; -import { IntegrationEntity, IntegrationRepository, TenantEntity, TenantRepository } from '@novu/dal'; +import { IntegrationEntity, IntegrationQuery, IntegrationRepository, TenantEntity, TenantRepository } from '@novu/dal'; import { CHANNELS_WITH_PRIMARY, FeatureFlagsKeysEnum } from '@novu/shared'; +import { AdditionalOperation, RulesLogic } from 'json-logic-js'; import { Instrument, InstrumentUsecase } from '../../instrumentation'; import { FeatureFlagsService } from '../../services/feature-flags'; +import { evaluateRules } from '../../services/query-parser'; +import { + getIntegrationRulesIssues, + hasIntegrationRules, + hasLegacyIntegrationConditions, +} from '../../utils/integration-conditions'; import { ConditionsFilter, ConditionsFilterCommand } from '../conditions-filter'; import { GetDecryptedIntegrations } from '../get-decrypted-integrations'; import { NormalizeVariables, NormalizeVariablesCommand } from '../normalize-variables'; @@ -27,54 +34,23 @@ export class SelectIntegration { isCrossEnvironmentIntegrationEnabled ); - if (!command.identifier && command.filterData.tenant && command.userId) { - const query = this.getIntegrationQuery(command, isCrossEnvironmentIntegrationEnabled); + if (!command.identifier) { + const integrations = await this.integrationRepository.find( + this.getConditionedIntegrationsQuery(command, isCrossEnvironmentIntegrationEnabled), + '', + { sort: { priority: -1, createdAt: -1 } } + ); - const integrations = await this.integrationRepository.find(query); + if (integrations.length > 0) { + const tenant = await this.resolveTenant(command); - let tenant: TenantEntity | null = null; - const commandTenantIdentifier = - typeof command.filterData.tenant === 'string' - ? command.filterData.tenant - : command.filterData.tenant.identifier; - if (commandTenantIdentifier) { - tenant = await this.tenantRepository.findOne({ - _organizationId: command.organizationId, - _environmentId: command.environmentId, - identifier: commandTenantIdentifier, - }); - } - - for (const currentIntegration of integrations) { - if (!currentIntegration.conditions || currentIntegration.conditions.length === 0) { - continue; - } + for (const currentIntegration of integrations) { + const passed = await this.integrationMatchesConditions(command, currentIntegration, tenant); - const variables = await this.normalizeVariablesUsecase.execute( - NormalizeVariablesCommand.create({ - filters: currentIntegration.conditions || [], - environmentId: command.environmentId, - organizationId: command.organizationId, - userId: command.userId, - variables: { - tenant, - }, - }) - ); - - const { passed } = await this.conditionsFilter.filter( - ConditionsFilterCommand.create({ - filters: currentIntegration.conditions, - environmentId: command.environmentId, - organizationId: command.organizationId, - userId: command.userId, - variables, - }) - ); - - if (passed) { - integration = currentIntegration; - break; + if (passed) { + integration = currentIntegration; + break; + } } } } @@ -86,6 +62,76 @@ export class SelectIntegration { return GetDecryptedIntegrations.getDecryptedCredentials(integration); } + private async resolveTenant(command: SelectIntegrationCommand): Promise { + if (!command.filterData.tenant) { + return null; + } + + const commandTenantIdentifier = + typeof command.filterData.tenant === 'string' ? command.filterData.tenant : command.filterData.tenant.identifier; + + if (!commandTenantIdentifier) { + return null; + } + + return await this.tenantRepository.findOne({ + _organizationId: command.organizationId, + _environmentId: command.environmentId, + identifier: commandTenantIdentifier, + }); + } + + private async integrationMatchesConditions( + command: SelectIntegrationCommand, + currentIntegration: IntegrationEntity, + tenant: TenantEntity | null + ): Promise { + if (hasIntegrationRules(currentIntegration.rules)) { + if (getIntegrationRulesIssues(currentIntegration.rules).length > 0) { + return false; + } + + const { result } = evaluateRules( + currentIntegration.rules as RulesLogic, + { + subscriber: command.filterData.subscriber, + context: command.filterData.context, + }, + true + ); + + return result; + } + + if (!hasLegacyIntegrationConditions(currentIntegration.conditions) || !command.userId) { + return false; + } + + const variables = await this.normalizeVariablesUsecase.execute( + NormalizeVariablesCommand.create({ + filters: currentIntegration.conditions || [], + environmentId: command.environmentId, + organizationId: command.organizationId, + userId: command.userId, + variables: { + tenant, + }, + }) + ); + + const { passed } = await this.conditionsFilter.filter( + ConditionsFilterCommand.create({ + filters: currentIntegration.conditions, + environmentId: command.environmentId, + organizationId: command.organizationId, + userId: command.userId, + variables, + }) + ); + + return passed; + } + @Instrument() private async getPrimaryIntegration( command: SelectIntegrationCommand, @@ -147,4 +193,14 @@ export class SelectIntegration { return query; } + + private getConditionedIntegrationsQuery( + command: SelectIntegrationCommand, + isCrossEnvironmentIntegrationEnabled: boolean + ): IntegrationQuery { + return { + ...this.getIntegrationQuery(command, isCrossEnvironmentIntegrationEnabled), + $or: [{ rules: { $type: 'object' } }, { 'conditions.0': { $exists: true } }], + }; + } } diff --git a/libs/application-generic/src/utils/index.ts b/libs/application-generic/src/utils/index.ts index c10b384a77c..c83257721c6 100644 --- a/libs/application-generic/src/utils/index.ts +++ b/libs/application-generic/src/utils/index.ts @@ -21,6 +21,7 @@ export * from './generate-payload-example'; export * from './hmac'; export * from './html'; export * from './inbound-email-references'; +export * from './integration-conditions'; export * from './infobip-sms-credentials'; export * from './issues'; export * from './json-schema-mock'; diff --git a/libs/application-generic/src/utils/integration-conditions.spec.ts b/libs/application-generic/src/utils/integration-conditions.spec.ts new file mode 100644 index 00000000000..8e1d6b92115 --- /dev/null +++ b/libs/application-generic/src/utils/integration-conditions.spec.ts @@ -0,0 +1,86 @@ +import { expect } from 'chai'; +import { getIntegrationRulesIssues, hasIntegrationRules } from './integration-conditions'; + +describe('integration rules helpers', () => { + it('detects non-empty JsonLogic', () => { + expect(hasIntegrationRules({ '==': [{ var: 'context.tenant.id' }, 'acme'] })).to.equal(true); + expect(hasIntegrationRules({})).to.equal(false); + expect(hasIntegrationRules(null)).to.equal(false); + }); + + it('rejects payload and deprecated tenant fields and accepts subscriber fields', () => { + const invalidPayload = getIntegrationRulesIssues({ + '==': [{ var: 'payload.foo' }, 'bar'], + }); + const invalidTenant = getIntegrationRulesIssues({ + '==': [{ var: 'tenant.identifier' }, 'acme'], + }); + const valid = getIntegrationRulesIssues({ + '==': [{ var: 'subscriber.locale' }, 'fr'], + }); + + expect(invalidPayload.length).to.be.greaterThan(0); + expect(invalidTenant.length).to.be.greaterThan(0); + expect(valid).to.deep.equal([]); + }); + + it('accepts context.tenant.id', () => { + const valid = getIntegrationRulesIssues({ + '==': [{ var: 'context.tenant.id' }, 'acme'], + }); + + expect(valid).to.deep.equal([]); + }); + + it('rejects json-logic operators that skip QueryValidatorService', () => { + const logIssues = getIntegrationRulesIssues({ + log: { var: 'subscriber.email' }, + }); + const mapIssues = getIntegrationRulesIssues({ + map: [[{ var: 'subscriber.data' }], { var: '' }], + }); + const nestedReduceIssues = getIntegrationRulesIssues({ + and: [ + { + '==': [{ var: 'subscriber.locale' }, { '+': [1, 2] }], + }, + ], + }); + + expect(logIssues.some((issue) => issue.includes('Unsupported operator "log"'))).to.equal(true); + expect(mapIssues.some((issue) => issue.includes('Unsupported operator "map"'))).to.equal(true); + expect(nestedReduceIssues.some((issue) => issue.includes('Unsupported operator "+"'))).to.equal(true); + }); + + it('rejects multi-key nodes used to smuggle operators and vars past validation', () => { + const smuggledOperatorIssues = getIntegrationRulesIssues({ + and: [{ log: { var: 'subscriber.email' }, dummy: 'bypass' }], + }); + const smuggledVarIssues = getIntegrationRulesIssues({ + and: [{ var: 'payload.secret', dummy: 'bypass' }], + }); + const nestedUnderNegationIssues = getIntegrationRulesIssues({ + '!': { map: [[{ var: 'subscriber.data' }], { var: '' }], dummy: 'bypass' }, + }); + + expect(smuggledOperatorIssues.length).to.be.greaterThan(0); + expect(smuggledVarIssues.length).to.be.greaterThan(0); + expect(nestedUnderNegationIssues.length).to.be.greaterThan(0); + }); + + it('rejects vars nested under operators QueryValidatorService does not inspect', () => { + const issues = getIntegrationRulesIssues({ + null: [{ var: 'payload.foo' }], + }); + + expect(issues.length).to.be.greaterThan(0); + }); + + it('accepts and/or groups of comparison rules', () => { + const valid = getIntegrationRulesIssues({ + and: [{ '==': [{ var: 'context.tenant.id' }, 'acme'] }, { '==': [{ var: 'subscriber.locale' }, 'fr'] }], + }); + + expect(valid).to.deep.equal([]); + }); +}); diff --git a/libs/application-generic/src/utils/integration-conditions.ts b/libs/application-generic/src/utils/integration-conditions.ts new file mode 100644 index 00000000000..c0a468e3a32 --- /dev/null +++ b/libs/application-generic/src/utils/integration-conditions.ts @@ -0,0 +1,133 @@ +import { AdditionalOperation, RulesLogic } from 'json-logic-js'; +import { + COMPARISON_OPERATORS, + isValidRule, + LOGICAL_OPERATORS, + QueryValidatorService, + UNARY_STRING_OPERATORS, +} from '../services/query-parser'; + +export const INTEGRATION_CONDITION_NAMESPACES = ['context.', 'subscriber.']; + +export const INTEGRATION_CONDITION_VARIABLES = [ + 'context.tenant.id', + 'subscriber.subscriberId', + 'subscriber.email', + 'subscriber.phone', + 'subscriber.firstName', + 'subscriber.lastName', + 'subscriber.locale', + 'subscriber.data', +]; + +/** + * Operators the conditions editor and QueryValidatorService actually inspect. + * Native json-logic ops outside this set (`log`, `map`, `reduce`, `if`, `+`, …) + * must not be persisted or applied — jsonLogic.apply would still execute them. + */ +const INTEGRATION_RULE_OPERATORS = new Set([ + ...LOGICAL_OPERATORS, + ...COMPARISON_OPERATORS, + ...UNARY_STRING_OPERATORS, + 'var', + 'contains', + 'doesNotContain', + 'doesNotBeginWith', + 'doesNotEndWith', + 'containsAny', + 'doesNotContainAny', + 'null', + 'notNull', + 'notIn', +]); + +export function hasIntegrationRules(rules?: unknown): rules is Record { + return !!rules && typeof rules === 'object' && !Array.isArray(rules) && Object.keys(rules).length > 0; +} + +export function hasLegacyIntegrationConditions(conditions?: unknown[] | null): boolean { + return Array.isArray(conditions) && conditions.length > 0; +} + +function collectDisallowedOperatorIssues(node: unknown, issues: string[]): void { + if (node === null || typeof node !== 'object') { + return; + } + + if (Array.isArray(node)) { + for (const item of node) { + collectDisallowedOperatorIssues(item, issues); + } + + return; + } + + const entries = Object.entries(node); + + /* + * json-logic only recognises single-key objects as operations, so a node with any + * other number of keys is never a valid rule. Descending into just its values would + * let an operator or `var` slip past this walk without ever being checked. + */ + if (entries.length !== 1) { + issues.push(`Invalid rule node with ${entries.length} keys, expected a single operator`); + + return; + } + + const [operator, value] = entries[0]; + + if (!INTEGRATION_RULE_OPERATORS.has(operator)) { + issues.push(`Unsupported operator "${operator}"`); + + return; + } + + if (operator === 'var') { + const fieldValue = typeof value === 'string' ? value : ''; + + if (!isAllowedIntegrationVar(fieldValue)) { + issues.push('Value is not valid'); + } + + return; + } + + collectDisallowedOperatorIssues(value, issues); +} + +function isAllowedIntegrationVar(fieldValue: string): boolean { + if (!fieldValue) { + return false; + } + + if (fieldValue === 'subscriber.data') { + return true; + } + + const isWithinAllowedPrefixes = INTEGRATION_CONDITION_NAMESPACES.some( + (prefix) => fieldValue.startsWith(prefix) && fieldValue.length > prefix.length + ); + + return isWithinAllowedPrefixes || INTEGRATION_CONDITION_VARIABLES.includes(fieldValue); +} + +export function getIntegrationRulesIssues(logic: Record): string[] { + if (!isValidRule(logic as RulesLogic)) { + return ['Invalid integration conditions']; + } + + const disallowedOperatorIssues: string[] = []; + collectDisallowedOperatorIssues(logic, disallowedOperatorIssues); + + const queryValidatorService = new QueryValidatorService( + INTEGRATION_CONDITION_VARIABLES, + INTEGRATION_CONDITION_NAMESPACES + ); + + const fieldAndStructureIssues = queryValidatorService + .validateQueryRules(logic as RulesLogic) + .map((issue) => issue.message); + + return [...disallowedOperatorIssues, ...fieldAndStructureIssues]; +} diff --git a/libs/dal/src/repositories/integration/integration.entity.ts b/libs/dal/src/repositories/integration/integration.entity.ts index 8e3d84dd890..a0b8039ef62 100644 --- a/libs/dal/src/repositories/integration/integration.entity.ts +++ b/libs/dal/src/repositories/integration/integration.entity.ts @@ -55,6 +55,13 @@ export class IntegrationEntity { conditions?: StepFilter[]; + /** + * Opaque JSONLogic blob. Typed as `object` rather than `Record` so mongoose + * does not derive `rules.${string}` projection paths, which would break array-of-field + * projections on this repository. + */ + rules?: object | null; + connected?: boolean; _parentId?: string; diff --git a/libs/dal/src/repositories/integration/integration.schema.ts b/libs/dal/src/repositories/integration/integration.schema.ts index 16103852e34..6b0756d9f51 100644 --- a/libs/dal/src/repositories/integration/integration.schema.ts +++ b/libs/dal/src/repositories/integration/integration.schema.ts @@ -145,6 +145,10 @@ const integrationSchema = new Schema( ], }, ], + rules: { + type: Schema.Types.Mixed, + required: false, + }, connected: Schema.Types.Boolean, _parentId: { type: Schema.Types.ObjectId, diff --git a/nx.json b/nx.json index 273687517ac..20b857b2b0c 100644 --- a/nx.json +++ b/nx.json @@ -3,7 +3,8 @@ "targetDefaults": { "build": { "dependsOn": ["^build"], - "cache": true + "cache": true, + "outputs": ["{projectRoot}/dist", "{projectRoot}/build"] }, "test": { "cache": true diff --git a/packages/js/src/web-chat/agent-conversation-runtime.test.ts b/packages/js/src/web-chat/agent-conversation-runtime.test.ts index 62b3ab564a6..3a2e9c80623 100644 --- a/packages/js/src/web-chat/agent-conversation-runtime.test.ts +++ b/packages/js/src/web-chat/agent-conversation-runtime.test.ts @@ -160,6 +160,37 @@ describe('AgentConversationRuntime', () => { runtime.dispose(); }); + it('publishes a run-error to the snapshot and keeps it across a history load', async () => { + const runErrorEnvelope = { + version: 1, + conversationId: 'internal', + conversationIdentifier: 'conv_abcdefghijkl', + agentId: 'agent_1', + runId: 'run_1', + turnId: 'turn_1', + sequence: 1, + timestamp: '2026-08-07T12:00:00.000Z', + event: { type: 'run-error', message: 'agent handler failed', code: 'handler_failed' }, + } as const; + getEvents.mockResolvedValue({ events: [], olderCursor: null }); + + const runtime = webChat.conversation({ agentId: 'agent_1', conversationId: 'conv_abcdefghijkl' }); + await runtime.load(); + + emitter.emit('web_chat.agent_event', { result: runErrorEnvelope }); + + expect(runtime.getSnapshot().run.isRunning).toBe(false); + expect(runtime.getSnapshot().error).toMatchObject({ message: 'agent handler failed' }); + + // A resumed runtime absorbs history into its own entry; the replayed run-error must survive. + getEvents.mockResolvedValue({ events: [runErrorEnvelope], olderCursor: null }); + await runtime.load(); + + expect(runtime.getSnapshot().error).toMatchObject({ message: 'agent handler failed' }); + + runtime.dispose(); + }); + it('replaces a stale resume runtime when the create-flow runtime registers', async () => { sendMessage.mockResolvedValue({ identifier: 'conv_abcdefghijkl', messageId: 'msg_abcdefghijkl' }); diff --git a/packages/js/src/web-chat/agent-conversation-runtime.ts b/packages/js/src/web-chat/agent-conversation-runtime.ts index c4e8e371313..b49c98bf24a 100644 --- a/packages/js/src/web-chat/agent-conversation-runtime.ts +++ b/packages/js/src/web-chat/agent-conversation-runtime.ts @@ -132,6 +132,7 @@ export class AgentConversationRuntime { isRecovering: data.isRecovering, catchUpError: data.catchUpError, conversationId: data.conversationId, + error: data.error, sessionStatus: this.#snapshot.status === 'loading' || this.#snapshot.status === 'fetching' ? this.#snapshot.status @@ -230,6 +231,7 @@ export class AgentConversationRuntime { pagination: store?.pagination ?? storePagination(response.data.hasMore), isRecovering: store?.isRecovering ?? false, catchUpError: store?.catchUpError, + error: store?.error, conversationId: response.data.conversationId, sessionStatus: 'ready', }, @@ -276,6 +278,7 @@ export class AgentConversationRuntime { isRecovering: store?.isRecovering ?? this.#snapshot.isRecovering, catchUpError: store?.catchUpError, conversationId: store?.conversationId ?? this.#conversationId, + error: store?.error, sessionStatus: 'ready', }); } @@ -427,6 +430,7 @@ export class AgentConversationRuntime { isRecovering: boolean; catchUpError?: NovuError; conversationId?: string; + error?: NovuError; sessionStatus: AgentConversationSessionStatus; }, meta?: AgentConversationPublicationMeta @@ -446,7 +450,7 @@ export class AgentConversationRuntime { pendingActions: derivePendingActions([...args.messages]), isRecovering: args.isRecovering, catchUpError: args.catchUpError, - error: undefined, + error: args.error, }, meta ); diff --git a/packages/js/src/web-chat/apply-envelope.test.ts b/packages/js/src/web-chat/apply-envelope.test.ts index 511f354d655..1757e8f1cf0 100644 --- a/packages/js/src/web-chat/apply-envelope.test.ts +++ b/packages/js/src/web-chat/apply-envelope.test.ts @@ -14,7 +14,11 @@ const BASE_IDS = { turnId: 'turn-1', } as const; -function envelope(sequence: number, event: AgentEvent, overrides: Partial = {}): AgentEventEnvelope { +function envelope( + sequence: number, + event: AgentEvent, + overrides: Partial<{ conversationId: string; agentId: string; runId: string; turnId: string }> = {} +): AgentEventEnvelope { return { version: AGENT_EVENT_PROTOCOL_VERSION, sequence, @@ -376,6 +380,63 @@ describe('applyEnvelope', () => { }); }); + it('does not merge a later MCP connection into an earlier one after run-finish', () => { + const historyRun = { runId: 'history' }; + const state = applyEnvelopes(createInitialAgentConversationState(), [ + envelope(1, { + type: 'message', + role: 'user', + messageId: 'u1', + content: { markdown: 'Find a Notion page' }, + }), + envelope( + 2, + { + type: 'mcp-connection-request', + actionId: 'sevt_notion', + mcpId: 'notion', + displayName: 'Notion', + authorizeUrl: 'https://example.com/notion', + }, + historyRun + ), + envelope(3, { + type: 'message', + role: 'assistant', + messageId: 'm1', + content: { markdown: 'I do not have Notion connected yet.' }, + }), + envelope(4, { type: 'run-finish', outcome: 'completed' }), + envelope(5, { + type: 'message', + role: 'user', + messageId: 'u2', + content: { markdown: 'retrieve last Linear task' }, + }), + envelope( + 6, + { + type: 'mcp-connection-request', + actionId: 'sevt_linear', + mcpId: 'linear', + displayName: 'Linear', + authorizeUrl: 'https://example.com/linear', + }, + historyRun + ), + ]); + + const notionMessage = state.messages.find((message) => + message.parts.some((part) => part.type === 'mcp-connection' && part.mcpId === 'notion') + ); + const linearMessage = state.messages.find((message) => + message.parts.some((part) => part.type === 'mcp-connection' && part.mcpId === 'linear') + ); + + expect(linearMessage?.id).not.toBe(notionMessage?.id); + expect(linearMessage?.parts.filter((part) => part.type === 'mcp-connection')).toHaveLength(1); + }); + it('folds durable user messages when role is user', () => { const state = applyEnvelopes(createInitialAgentConversationState(), [ envelope(1, { diff --git a/packages/js/src/web-chat/apply-envelope.ts b/packages/js/src/web-chat/apply-envelope.ts index 358840f87ae..a88955a09f7 100644 --- a/packages/js/src/web-chat/apply-envelope.ts +++ b/packages/js/src/web-chat/apply-envelope.ts @@ -161,9 +161,11 @@ function applyEvent(state: AgentConversationState, envelope: AgentEventEnvelope) case 'tool-approval-response': return applyApprovalResponse(state, event.approvalId, event.decision); - case 'mcp-connection-request': + case 'mcp-connection-request': { + const messageId = state.activeAssistantMessageId ?? event.actionId; + return clearTyping( - withActiveAssistantMessage(state, envelope, (message) => ({ + withMessage(state, envelope, messageId, 'assistant', (message) => ({ ...message, parts: [ ...message.parts, @@ -179,6 +181,7 @@ function applyEvent(state: AgentConversationState, envelope: AgentEventEnvelope) ], })) ); + } case 'mcp-connection-result': return applyMcpConnectionResult(state, event.actionId, event.status, event.message); diff --git a/packages/providers/README.md b/packages/providers/README.md index 18c9d1e0031..33b9073a9d6 100644 --- a/packages/providers/README.md +++ b/packages/providers/README.md @@ -42,3 +42,13 @@ await provider.sendMessage({ ``` For all supported providers, visit the [Novu Providers package](https://github.com/novuhq/novu/tree/next/packages/providers/src/lib). + +## HTTP timeouts + +Providers that call their API over HTTP directly go through the shared clients in `src/utils/http`: `createProviderHttpClient` for axios and `providerFetch` for fetch. Both cap a single request at `PROVIDER_HTTP_TIMEOUT_MS` (120 seconds), so an unresponsive provider API fails within a bounded time instead of hanging. + +Set `NOVU_PROVIDER_HTTP_TIMEOUT_MS` to override the default. It is read once at module load. + +Providers backed by a vendor SDK (Twilio, SendGrid, Firebase, nodemailer, and others) use that SDK's own timeout instead. + +If you are adding a provider, use these clients rather than importing `axios` or calling `fetch` directly — a bare axios instance has no timeout at all. diff --git a/packages/providers/src/index.ts b/packages/providers/src/index.ts index be0278c108a..f82fd41163d 100644 --- a/packages/providers/src/index.ts +++ b/packages/providers/src/index.ts @@ -1,3 +1,4 @@ export * from './lib/index'; +export * from './utils/http'; export { resolveSafeInfobipBaseUrl } from './utils/safe-infobip-base-url'; export { resolveSafeProviderUrl } from './utils/safe-provider-url'; diff --git a/packages/providers/src/lib/chat/line/line.provider.spec.ts b/packages/providers/src/lib/chat/line/line.provider.spec.ts index db7ff518a6e..f49ea222e33 100644 --- a/packages/providers/src/lib/chat/line/line.provider.spec.ts +++ b/packages/providers/src/lib/chat/line/line.provider.spec.ts @@ -1,6 +1,7 @@ import { ChannelEndpointByType, ENDPOINT_TYPES, IChatOptions } from '@novu/stateless'; import { nanoid } from 'nanoid'; import { expect, test } from 'vitest'; +import { PROVIDER_HTTP_TIMEOUT_MS } from '../../../utils/http'; import { axiosSpy } from '../../../utils/test/spy-axios'; import { LineChatProvider } from './line.provider'; @@ -28,6 +29,7 @@ const expectedHeaders = { Authorization: `Bearer ${mockProviderConfig.channelAccessToken}`, 'Content-Type': 'application/json', }, + timeout: PROVIDER_HTTP_TIMEOUT_MS, }; test('should trigger LINE library correctly with text message', async () => { diff --git a/packages/providers/src/lib/chat/line/line.provider.ts b/packages/providers/src/lib/chat/line/line.provider.ts index 88e25e9a61e..a39a8598098 100644 --- a/packages/providers/src/lib/chat/line/line.provider.ts +++ b/packages/providers/src/lib/chat/line/line.provider.ts @@ -6,8 +6,9 @@ import { ISendMessageSuccessResponse, isChannelDataOfType, } from '@novu/stateless'; -import Axios, { AxiosInstance } from 'axios'; +import { AxiosInstance } from 'axios'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { createProviderHttpClient } from '../../../utils/http'; import { WithPassthrough } from '../../../utils/types'; import { ILineSentMessagesResponse } from './line.types'; @@ -22,7 +23,7 @@ export class LineChatProvider extends BaseProvider implements IChatProvider { constructor(private config: { channelAccessToken: string }) { super(); - this.axiosClient = Axios.create({ + this.axiosClient = createProviderHttpClient({ baseURL: 'https://api.line.me/v2/bot/message', headers: { Authorization: `Bearer ${this.config.channelAccessToken}`, diff --git a/packages/providers/src/lib/chat/msTeams/msTeams.provider.ts b/packages/providers/src/lib/chat/msTeams/msTeams.provider.ts index 3ba4390e962..4716973c0d3 100644 --- a/packages/providers/src/lib/chat/msTeams/msTeams.provider.ts +++ b/packages/providers/src/lib/chat/msTeams/msTeams.provider.ts @@ -14,6 +14,7 @@ import { import axios, { AxiosInstance } from 'axios'; import { BaseProvider, CasingEnum } from '../../../base.provider'; import { esmImport } from '../../../utils/esm-import'; +import { createProviderHttpClient } from '../../../utils/http'; import { safeChatWebhookJsonRequest } from '../../../utils/safe-chat-webhook-request'; import { WithPassthrough } from '../../../utils/types'; import { omitIncompleteLinkButtons } from '../card-render.utils'; @@ -36,7 +37,7 @@ export class MsTeamsProvider extends BaseProvider implements IChatProvider { channelType = ChannelTypeEnum.CHAT as ChannelTypeEnum.CHAT; public id = ChatProviderIdEnum.MsTeams; protected casing: CasingEnum = CasingEnum.CAMEL_CASE; - private axiosInstance: AxiosInstance = axios.create(); + private axiosInstance: AxiosInstance = createProviderHttpClient(); private static readonly BOT_FRAMEWORK_SERVICE_URL = 'https://smba.trafficmanager.net'; diff --git a/packages/providers/src/lib/chat/sendblue/sendblue.provider.spec.ts b/packages/providers/src/lib/chat/sendblue/sendblue.provider.spec.ts index 01a6f8f5352..ca2014c41e3 100644 --- a/packages/providers/src/lib/chat/sendblue/sendblue.provider.spec.ts +++ b/packages/providers/src/lib/chat/sendblue/sendblue.provider.spec.ts @@ -1,6 +1,7 @@ import { ChannelEndpointByType, ENDPOINT_TYPES, IChatOptions } from '@novu/stateless'; import { nanoid } from 'nanoid'; import { expect, test } from 'vitest'; +import { PROVIDER_HTTP_TIMEOUT_MS } from '../../../utils/http'; import { axiosSpy } from '../../../utils/test/spy-axios'; import { SendblueChatProvider } from './sendblue.provider'; @@ -117,5 +118,6 @@ function expectedHeaders(apiKey: string, secretKey: string) { 'sb-api-secret-key': secretKey, 'Content-Type': 'application/json', }, + timeout: PROVIDER_HTTP_TIMEOUT_MS, }; } diff --git a/packages/providers/src/lib/chat/sendblue/sendblue.provider.ts b/packages/providers/src/lib/chat/sendblue/sendblue.provider.ts index 327b0405735..efe868c8804 100644 --- a/packages/providers/src/lib/chat/sendblue/sendblue.provider.ts +++ b/packages/providers/src/lib/chat/sendblue/sendblue.provider.ts @@ -6,8 +6,9 @@ import { ISendMessageSuccessResponse, isChannelDataOfType, } from '@novu/stateless'; -import Axios, { AxiosInstance } from 'axios'; +import { AxiosInstance } from 'axios'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { createProviderHttpClient } from '../../../utils/http'; import { WithPassthrough } from '../../../utils/types'; import { ISendblueMessageResponse } from './types/sendblue.types'; @@ -27,7 +28,7 @@ export class SendblueChatProvider extends BaseProvider implements IChatProvider } ) { super(); - this.axiosClient = Axios.create({ + this.axiosClient = createProviderHttpClient({ headers: { 'sb-api-key-id': this.config.apiKey, 'sb-api-secret-key': this.config.secretKey, diff --git a/packages/providers/src/lib/chat/slack/slack.provider.ts b/packages/providers/src/lib/chat/slack/slack.provider.ts index da769e57d88..e3407b75acb 100644 --- a/packages/providers/src/lib/chat/slack/slack.provider.ts +++ b/packages/providers/src/lib/chat/slack/slack.provider.ts @@ -12,9 +12,9 @@ import { SlackUserData, WebhookData, } from '@novu/stateless'; -import axios from 'axios'; import { BaseProvider, CasingEnum } from '../../../base.provider'; import { esmImport } from '../../../utils/esm-import'; +import { createProviderHttpClient } from '../../../utils/http'; import { safeChatWebhookJsonRequest } from '../../../utils/safe-chat-webhook-request'; import { WithPassthrough } from '../../../utils/types'; import { omitIncompleteLinkButtons } from '../card-render.utils'; @@ -30,7 +30,7 @@ export class SlackProvider extends BaseProvider implements IChatProvider { protected casing: CasingEnum = CasingEnum.SNAKE_CASE; public id = ChatProviderIdEnum.Slack; private slackAPI = 'https://slack.com/api'; - private axiosInstance = axios.create(); + private axiosInstance = createProviderHttpClient(); /** * Rich Chat: serialize a `CardElement` to Slack Block Kit + mrkdwn fallback text. diff --git a/packages/providers/src/lib/chat/telegram/telegram.provider.ts b/packages/providers/src/lib/chat/telegram/telegram.provider.ts index c6df025e3ad..5610492bacf 100644 --- a/packages/providers/src/lib/chat/telegram/telegram.provider.ts +++ b/packages/providers/src/lib/chat/telegram/telegram.provider.ts @@ -9,8 +9,9 @@ import { ISendMessageSuccessResponse, isChannelDataOfType, } from '@novu/stateless'; -import Axios, { AxiosInstance } from 'axios'; +import { AxiosInstance } from 'axios'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { createProviderHttpClient } from '../../../utils/http'; import { WithPassthrough } from '../../../utils/types'; import { cardToTelegramHtml } from './card-render.utils'; import { ISendMessageRes } from './types/telegram.types'; @@ -26,7 +27,7 @@ export class TelegramChatProvider extends BaseProvider implements IChatProvider constructor(private config: { botToken: string }) { super(); this.baseUrl = `https://api.telegram.org/bot${this.config.botToken}`; - this.axiosInstance = Axios.create({ + this.axiosInstance = createProviderHttpClient({ headers: { 'Content-Type': 'application/json', }, diff --git a/packages/providers/src/lib/chat/webex-messaging/webex-messaging.provider.spec.ts b/packages/providers/src/lib/chat/webex-messaging/webex-messaging.provider.spec.ts index 0e762ed3934..8705e9d7cf7 100644 --- a/packages/providers/src/lib/chat/webex-messaging/webex-messaging.provider.spec.ts +++ b/packages/providers/src/lib/chat/webex-messaging/webex-messaging.provider.spec.ts @@ -2,6 +2,7 @@ import { ChatProviderIdEnum } from '@novu/shared'; import { ChannelTypeEnum, ENDPOINT_TYPES } from '@novu/stateless'; import axios from 'axios'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { PROVIDER_HTTP_TIMEOUT_MS } from '../../../utils/http'; import { WebexMessagingProvider } from './webex-messaging.provider'; vi.mock('axios'); @@ -32,7 +33,7 @@ describe('WebexMessagingProvider', () => { headers: { 'Content-Type': 'application/json', }, - timeout: 30000, + timeout: PROVIDER_HTTP_TIMEOUT_MS, }); }); @@ -46,7 +47,7 @@ describe('WebexMessagingProvider', () => { headers: { 'Content-Type': 'application/json', }, - timeout: 30000, + timeout: PROVIDER_HTTP_TIMEOUT_MS, }); }); diff --git a/packages/providers/src/lib/chat/webex-messaging/webex-messaging.provider.ts b/packages/providers/src/lib/chat/webex-messaging/webex-messaging.provider.ts index 8986735d134..4ea94a7b96c 100644 --- a/packages/providers/src/lib/chat/webex-messaging/webex-messaging.provider.ts +++ b/packages/providers/src/lib/chat/webex-messaging/webex-messaging.provider.ts @@ -9,6 +9,7 @@ import { } from '@novu/stateless'; import axios, { AxiosInstance } from 'axios'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { createProviderHttpClient } from '../../../utils/http'; import { WithPassthrough } from '../../../utils/types'; type WebexMessagingProviderConfig = { @@ -28,8 +29,6 @@ type WebexMessagePayload = { text: string; }; -const WEBEX_MESSAGE_REQUEST_TIMEOUT_MS = 30000; - export class WebexMessagingProvider extends BaseProvider implements IChatProvider { id = ChatProviderIdEnum.WebexMessaging; channelType = ChannelTypeEnum.CHAT as ChannelTypeEnum.CHAT; @@ -43,12 +42,11 @@ export class WebexMessagingProvider extends BaseProvider implements IChatProvide const normalizedBaseUrl = this.normalizeBaseUrl(config.baseUrl); - this.axiosInstance = axios.create({ + this.axiosInstance = createProviderHttpClient({ baseURL: normalizedBaseUrl, headers: { 'Content-Type': 'application/json', }, - timeout: WEBEX_MESSAGE_REQUEST_TIMEOUT_MS, }); } diff --git a/packages/providers/src/lib/chat/whatsapp-business/whatsapp-business.provider.spec.ts b/packages/providers/src/lib/chat/whatsapp-business/whatsapp-business.provider.spec.ts index 4f9749fc424..6c9e28c2496 100644 --- a/packages/providers/src/lib/chat/whatsapp-business/whatsapp-business.provider.spec.ts +++ b/packages/providers/src/lib/chat/whatsapp-business/whatsapp-business.provider.spec.ts @@ -1,6 +1,7 @@ import { ChannelEndpointByType, ENDPOINT_TYPES, IChatOptions } from '@novu/stateless'; import { nanoid } from 'nanoid'; import { expect, test } from 'vitest'; +import { PROVIDER_HTTP_TIMEOUT_MS } from '../../../utils/http'; import { axiosSpy } from '../../../utils/test/spy-axios'; import { WhatsAppMessageTypeEnum } from './consts/whatsapp-business.enum'; import { WhatsappBusinessChatProvider } from './whatsapp-business.provider'; @@ -465,5 +466,6 @@ function expectedHeaders(accessToken: string) { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, + timeout: PROVIDER_HTTP_TIMEOUT_MS, }; } diff --git a/packages/providers/src/lib/chat/whatsapp-business/whatsapp-business.provider.ts b/packages/providers/src/lib/chat/whatsapp-business/whatsapp-business.provider.ts index 2f3c845e4d3..203ce49fcd6 100644 --- a/packages/providers/src/lib/chat/whatsapp-business/whatsapp-business.provider.ts +++ b/packages/providers/src/lib/chat/whatsapp-business/whatsapp-business.provider.ts @@ -8,8 +8,9 @@ import { ISendMessageSuccessResponse, isChannelDataOfType, } from '@novu/stateless'; -import Axios, { AxiosInstance } from 'axios'; +import { AxiosInstance } from 'axios'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { createProviderHttpClient } from '../../../utils/http'; import { WithPassthrough } from '../../../utils/types'; import { cardToWhatsAppText } from './card-render.utils'; import { WhatsAppMessageTypeEnum } from './consts/whatsapp-business.enum'; @@ -30,7 +31,7 @@ export class WhatsappBusinessChatProvider extends BaseProvider implements IChatP } ) { super(); - this.axiosClient = Axios.create({ + this.axiosClient = createProviderHttpClient({ headers: { Authorization: `Bearer ${this.config.accessToken}`, 'Content-Type': 'application/json', diff --git a/packages/providers/src/lib/email/brevo/brevo.provider.ts b/packages/providers/src/lib/email/brevo/brevo.provider.ts index e30d8db721b..8923bf0f37b 100644 --- a/packages/providers/src/lib/email/brevo/brevo.provider.ts +++ b/packages/providers/src/lib/email/brevo/brevo.provider.ts @@ -10,8 +10,9 @@ import { IEmailProvider, ISendMessageSuccessResponse, } from '@novu/stateless'; -import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; +import { AxiosInstance, AxiosRequestConfig } from 'axios'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { createProviderHttpClient } from '../../../utils/http'; import { WithPassthrough } from '../../../utils/types'; export class BrevoEmailProvider extends BaseProvider implements IEmailProvider { @@ -29,7 +30,7 @@ export class BrevoEmailProvider extends BaseProvider implements IEmailProvider { } ) { super(); - this.axiosInstance = axios.create({ + this.axiosInstance = createProviderHttpClient({ baseURL: this.BASE_URL, }); } diff --git a/packages/providers/src/lib/email/email-webhook/email-webhook.provider.spec.ts b/packages/providers/src/lib/email/email-webhook/email-webhook.provider.spec.ts index b04d8689450..ffc3fbd8c6e 100644 --- a/packages/providers/src/lib/email/email-webhook/email-webhook.provider.spec.ts +++ b/packages/providers/src/lib/email/email-webhook/email-webhook.provider.spec.ts @@ -209,6 +209,89 @@ describe('without SSRF protection (self-hosted)', () => { }); }); +describe('retry budget', () => { + const PAYLOAD = { + to: ['johndoe@example.com'], + from: 'janedoe@example.com', + subject: 'test', + html: '

test

', + text: 'test', + }; + + let failingServer: http.Server; + let failingUrl: string; + let attempts: number; + + beforeAll(() => { + process.env.NOVU_ENTERPRISE = 'true'; + process.env.IS_SELF_HOSTED = 'true'; + }); + + afterAll(() => { + restoreEnv('NOVU_ENTERPRISE', ORIGINAL_ENTERPRISE); + restoreEnv('IS_SELF_HOSTED', ORIGINAL_SELF_HOSTED); + }); + + beforeEach(async () => { + attempts = 0; + failingServer = http.createServer((_req, res) => { + attempts += 1; + res.writeHead(500); + res.end('nope'); + }); + + await new Promise((resolve) => failingServer.listen(0, '127.0.0.1', () => resolve())); + const addr = failingServer.address(); + if (!addr || typeof addr === 'string') throw new Error('listen failed'); + failingUrl = `http://127.0.0.1:${addr.port}/webhook`; + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + vi.resetModules(); + await new Promise((resolve) => failingServer.close(() => resolve())); + }); + + test('stops retrying once the wall-clock budget is spent, ignoring the remaining retryCount', async () => { + vi.stubEnv('NOVU_PROVIDER_HTTP_TIMEOUT_MS', '300'); + vi.resetModules(); + const { EmailWebhookProvider: BudgetedProvider } = await import('./email-webhook.provider.js'); + + // Left uncapped this would run 10 attempts with 200ms between them. + const provider = new BudgetedProvider({ + webhookUrl: failingUrl, + hmacSecretKey: 'super-secret-key', + retryCount: 10, + retryDelay: 200, + }); + + const startedAt = Date.now(); + + await expect(provider.sendMessage(PAYLOAD)).rejects.toThrow('webhook send failed !'); + + expect(Date.now() - startedAt).toBeLessThan(1_500); + expect(attempts).toBeGreaterThanOrEqual(1); + expect(attempts).toBeLessThan(10); + }); + + test('still honours retryCount when it is exhausted before the budget', async () => { + vi.stubEnv('NOVU_PROVIDER_HTTP_TIMEOUT_MS', '10000'); + vi.resetModules(); + const { EmailWebhookProvider: BudgetedProvider } = await import('./email-webhook.provider.js'); + + const provider = new BudgetedProvider({ + webhookUrl: failingUrl, + hmacSecretKey: 'super-secret-key', + retryCount: 3, + retryDelay: 1, + }); + + await expect(provider.sendMessage(PAYLOAD)).rejects.toThrow('webhook send failed !'); + + expect(attempts).toBe(3); + }); +}); + describe('computeHmac secret key encodings', () => { const PAYLOAD = '{"to":["johndoe@example.com"],"from":"janedoe@example.com","subject":"test","html":"

test

","text":"test"}'; diff --git a/packages/providers/src/lib/email/email-webhook/email-webhook.provider.ts b/packages/providers/src/lib/email/email-webhook/email-webhook.provider.ts index 634f79b61f4..09ce398c257 100644 --- a/packages/providers/src/lib/email/email-webhook/email-webhook.provider.ts +++ b/packages/providers/src/lib/email/email-webhook/email-webhook.provider.ts @@ -15,12 +15,15 @@ import { IEmailProvider, ISendMessageSuccessResponse, } from '@novu/stateless'; -import axios from 'axios'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { createProviderHttpClient, PROVIDER_HTTP_TIMEOUT_MS } from '../../../utils/http'; import { WithPassthrough } from '../../../utils/types'; const PROTECTED_HEADER_NAMES = new Set(['content-type', 'x-novu-signature']); +/** Mirrors the default in `safeOutboundJsonRequest`, which takes no timeout of its own here. */ +const SSRF_REQUEST_TIMEOUT_MS = 30_000; + /** * How the `hmacSecretKey` value is turned into signing bytes: * - `text`: the raw UTF-8 bytes of the stored string (legacy/default behavior) @@ -40,6 +43,7 @@ export class EmailWebhookProvider extends BaseProvider implements IEmailProvider protected casing: CasingEnum = CasingEnum.CAMEL_CASE; readonly id = EmailProviderIdEnum.EmailWebhook; readonly channelType = ChannelTypeEnum.EMAIL as ChannelTypeEnum.EMAIL; + private readonly httpClient = createProviderHttpClient(); constructor( private config: { @@ -93,23 +97,63 @@ export class EmailWebhookProvider extends BaseProvider implements IEmailProvider }; } - private async sendWithAxios(bodyData: string, requestHeaders: Record): Promise { - let sent = false; + /** + * Runs `attempt` up to `retryCount` times, bounded by a single wall-clock budget of + * {@link PROVIDER_HTTP_TIMEOUT_MS} covering every request and every delay between them. + * + * Without the budget, the configured `retryCount` and `retryDelay` multiply the + * per-request timeout: three attempts at 120s with two 30s delays would hold a worker + * slot for over seven minutes on a single step. `retryCount` and `retryDelay` are + * therefore an upper bound rather than a guarantee — the loop stops early once the + * budget is spent. + * + * `attempt` receives the milliseconds left, so it can cap its own request accordingly. + */ + private async sendWithinBudget(attempt: (remainingMs: number) => Promise): Promise { + const deadline = Date.now() + PROVIDER_HTTP_TIMEOUT_MS; + const remaining = () => deadline - Date.now(); + const lastAttemptIndex = this.config.retryCount - 1; + + for (let index = 0; index < this.config.retryCount; index += 1) { + const remainingMs = remaining(); + + if (remainingMs <= 0) { + break; + } - for (let retries = 0; !sent && retries < this.config.retryCount; retries += 1) { try { - await axios.create().post(this.config.webhookUrl, bodyData, { - headers: requestHeaders, - }); - sent = true; - } catch { - await setTimeout(this.config.retryDelay); + await attempt(remainingMs); + + return; + } catch (error) { + if (error instanceof EmailWebhookUrlBlockedError || error instanceof SsrfBlockedError) { + throw error; + } + + if (index === lastAttemptIndex) { + break; + } + + const delayMs = Math.min(this.config.retryDelay, remaining()); + + if (delayMs <= 0) { + break; + } + + await setTimeout(delayMs); } } - if (!sent) { - throw new Error('webhook send failed !'); - } + throw new Error('webhook send failed !'); + } + + private async sendWithAxios(bodyData: string, requestHeaders: Record): Promise { + await this.sendWithinBudget(async (remainingMs) => { + await this.httpClient.post(this.config.webhookUrl, bodyData, { + headers: requestHeaders, + timeout: remainingMs, + }); + }); } private async sendWithSsrfProtection(bodyData: string, requestHeaders: Record): Promise { @@ -130,38 +174,24 @@ export class EmailWebhookProvider extends BaseProvider implements IEmailProvider throw err; } - let sent = false; - - for (let retries = 0; !sent && retries < this.config.retryCount; retries += 1) { - try { - const response = await safeOutboundJsonRequest({ - url: webhookUrl, - method: 'POST', - headers: requestHeaders, - body: bodyData, - }).catch((err: unknown) => { - if (err instanceof SsrfBlockedError) { - throw new EmailWebhookUrlBlockedError(`Email webhook URL blocked: ${err.message}`); - } - throw err; - }); - - if (response.statusCode < 200 || response.statusCode >= 300) { - throw new Error(`webhook send failed with status ${response.statusCode}`); + await this.sendWithinBudget(async (remainingMs) => { + const response = await safeOutboundJsonRequest({ + url: webhookUrl, + method: 'POST', + headers: requestHeaders, + body: bodyData, + timeoutMs: Math.min(SSRF_REQUEST_TIMEOUT_MS, remainingMs), + }).catch((err: unknown) => { + if (err instanceof SsrfBlockedError) { + throw new EmailWebhookUrlBlockedError(`Email webhook URL blocked: ${err.message}`); } + throw err; + }); - sent = true; - } catch (error) { - if (error instanceof EmailWebhookUrlBlockedError || error instanceof SsrfBlockedError) { - throw error; - } - await setTimeout(this.config.retryDelay); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw new Error(`webhook send failed with status ${response.statusCode}`); } - } - - if (!sent) { - throw new Error('webhook send failed !'); - } + }); } createBody(options: WithPassthrough>): string { diff --git a/packages/providers/src/lib/email/mailgun/mailgun.provider.ts b/packages/providers/src/lib/email/mailgun/mailgun.provider.ts index 85fe740f55e..b10061ca272 100644 --- a/packages/providers/src/lib/email/mailgun/mailgun.provider.ts +++ b/packages/providers/src/lib/email/mailgun/mailgun.provider.ts @@ -9,13 +9,13 @@ import { IEmailProvider, ISendMessageSuccessResponse, } from '@novu/stateless'; -import axios from 'axios'; import { createHmac } from 'crypto'; import formData from 'form-data'; import Mailgun from 'mailgun.js'; import { IMailgunClient } from 'mailgun.js/interfaces/IMailgunClient'; import { MailgunMessageData } from 'mailgun.js/interfaces/Messages'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { createProviderHttpClient } from '../../../utils/http'; import { resolveSafeProviderUrl } from '../../../utils/safe-provider-url'; import { WithPassthrough } from '../../../utils/types'; @@ -188,7 +188,7 @@ export class MailgunEmailProvider extends BaseProvider implements IEmailProvider const baseUrl = this.config.baseUrl || 'https://api.mailgun.net'; const authHeader = `Basic ${Buffer.from(`api:${this.config.apiKey}`).toString('base64')}`; - const response = await axios.get(`${baseUrl}/v5/accounts/http_signing_key`, { + const response = await createProviderHttpClient().get(`${baseUrl}/v5/accounts/http_signing_key`, { headers: { Authorization: authHeader, }, diff --git a/packages/providers/src/lib/email/netcore/netcore.provider.ts b/packages/providers/src/lib/email/netcore/netcore.provider.ts index b2d80afc558..5a882045784 100644 --- a/packages/providers/src/lib/email/netcore/netcore.provider.ts +++ b/packages/providers/src/lib/email/netcore/netcore.provider.ts @@ -9,8 +9,9 @@ import { IEmailProvider, ISendMessageSuccessResponse, } from '@novu/stateless'; -import axios, { AxiosInstance } from 'axios'; +import { AxiosInstance } from 'axios'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { createProviderHttpClient } from '../../../utils/http'; import { WithPassthrough } from '../../../utils/types'; import { IEmailBody, IEmailResponse } from './netcore-types'; @@ -40,7 +41,7 @@ export class NetCoreProvider extends BaseProvider implements IEmailProvider { } ) { super(); - this.axiosInstance = axios.create({ + this.axiosInstance = createProviderHttpClient({ baseURL: this.BASE_URL, }); } diff --git a/packages/providers/src/lib/email/sparkpost/sparkpost.provider.ts b/packages/providers/src/lib/email/sparkpost/sparkpost.provider.ts index 8cd883ffcf9..834c12a3e09 100644 --- a/packages/providers/src/lib/email/sparkpost/sparkpost.provider.ts +++ b/packages/providers/src/lib/email/sparkpost/sparkpost.provider.ts @@ -10,6 +10,7 @@ import { import axios, { AxiosError } from 'axios'; import { randomUUID } from 'crypto'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { createProviderHttpClient } from '../../../utils/http'; import { WithPassthrough } from '../../../utils/types'; import { ISparkPostErrorResponse, SparkPostError } from './sparkpost.error'; @@ -70,7 +71,7 @@ export class SparkPostEmailProvider extends BaseProvider implements IEmailProvid }); try { - const sent = await axios.create().post('/transmissions', data.body, { + const sent = await createProviderHttpClient().post('/transmissions', data.body, { headers: { 'Content-Type': 'application/json', Authorization: this.config.apiKey, diff --git a/packages/providers/src/lib/push/appio/appio.provider.ts b/packages/providers/src/lib/push/appio/appio.provider.ts index a29a5eacc24..83755f2ad20 100644 --- a/packages/providers/src/lib/push/appio/appio.provider.ts +++ b/packages/providers/src/lib/push/appio/appio.provider.ts @@ -1,15 +1,15 @@ import { isOutboundSsrfProtectionEnabled, PushProviderIdEnum } from '@novu/shared'; import { safeOutboundJsonRequest } from '@novu/shared/utils/safe-outbound-http'; import { ChannelTypeEnum, IPushOptions, IPushProvider, ISendMessageSuccessResponse } from '@novu/stateless'; -import axios from 'axios'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { createProviderHttpClient } from '../../../utils/http'; import { resolveSafeProviderUrl } from '../../../utils/safe-provider-url'; export class AppioPushProvider extends BaseProvider implements IPushProvider { id = PushProviderIdEnum.AppIO; channelType = ChannelTypeEnum.PUSH as const; protected casing: CasingEnum = CasingEnum.SNAKE_CASE; - private axiosInstance = axios.create(); + private axiosInstance = createProviderHttpClient(); constructor(private config: { AppIOBaseUrl?: string }) { super(); diff --git a/packages/providers/src/lib/push/one-signal/one-signal.provider.ts b/packages/providers/src/lib/push/one-signal/one-signal.provider.ts index 1cca2cf2153..85f8d28c278 100644 --- a/packages/providers/src/lib/push/one-signal/one-signal.provider.ts +++ b/packages/providers/src/lib/push/one-signal/one-signal.provider.ts @@ -1,8 +1,9 @@ import { PushProviderIdEnum } from '@novu/shared'; import { ChannelTypeEnum, IPushOptions, IPushProvider, ISendMessageSuccessResponse } from '@novu/stateless'; -import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; +import { AxiosInstance, AxiosRequestConfig } from 'axios'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { createProviderHttpClient } from '../../../utils/http'; import { WithPassthrough } from '../../../utils/types'; export class OneSignalPushProvider extends BaseProvider implements IPushProvider { @@ -24,7 +25,7 @@ export class OneSignalPushProvider extends BaseProvider implements IPushProvider super(); this.apiVersion = config.apiVersion; - this.axiosInstance = axios.create({ + this.axiosInstance = createProviderHttpClient({ baseURL: config.apiVersion === 'externalId' ? this.BASE_URL_USER_MODEL : this.BASE_URL_PLAYER_MODEL, }); } diff --git a/packages/providers/src/lib/push/pusher-beams/pusher-beams.provider.ts b/packages/providers/src/lib/push/pusher-beams/pusher-beams.provider.ts index 01684a05c25..30fe3c1cb79 100644 --- a/packages/providers/src/lib/push/pusher-beams/pusher-beams.provider.ts +++ b/packages/providers/src/lib/push/pusher-beams/pusher-beams.provider.ts @@ -1,7 +1,8 @@ import { PushProviderIdEnum } from '@novu/shared'; import { ChannelTypeEnum, IPushOptions, IPushProvider, ISendMessageSuccessResponse } from '@novu/stateless'; -import axios, { AxiosInstance } from 'axios'; +import { AxiosInstance } from 'axios'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { createProviderHttpClient } from '../../../utils/http'; import { resolveSafePusherBeamsBaseUrl } from '../../../utils/safe-pusher-beams-url'; import { WithPassthrough } from '../../../utils/types'; @@ -21,7 +22,7 @@ export class PusherBeamsPushProvider extends BaseProvider implements IPushProvid super(); const baseURL = resolveSafePusherBeamsBaseUrl(this.config.instanceId); - this.axiosInstance = axios.create({ + this.axiosInstance = createProviderHttpClient({ baseURL, headers: { 'Content-Type': 'application/json', diff --git a/packages/providers/src/lib/sms/afro-sms/afro-sms.provider.ts b/packages/providers/src/lib/sms/afro-sms/afro-sms.provider.ts index 11bcbb539d6..95a9999b13c 100644 --- a/packages/providers/src/lib/sms/afro-sms/afro-sms.provider.ts +++ b/packages/providers/src/lib/sms/afro-sms/afro-sms.provider.ts @@ -1,8 +1,8 @@ import { SmsProviderIdEnum } from '@novu/shared'; import { ChannelTypeEnum, ISendMessageSuccessResponse, ISmsOptions, ISmsProvider } from '@novu/stateless'; -import axios from 'axios'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { createProviderHttpClient } from '../../../utils/http'; import { WithPassthrough } from '../../../utils/types'; export class AfroSmsProvider extends BaseProvider implements ISmsProvider { @@ -11,6 +11,7 @@ export class AfroSmsProvider extends BaseProvider implements ISmsProvider { protected casing = CasingEnum.SNAKE_CASE; private readonly BASE_URL = 'https://api.afromessage.com'; private readonly ENDPOINT = '/api/send'; + private readonly httpClient = createProviderHttpClient(); constructor( private config: { @@ -35,7 +36,7 @@ export class AfroSmsProvider extends BaseProvider implements ISmsProvider { message: options.content, }; - const { data } = await axios.get(url, { + const { data } = await this.httpClient.get(url, { params: this.transform(bridgeProviderData, queryParams).body, headers: { Authorization: `Bearer ${this.config.apiKey}`, diff --git a/packages/providers/src/lib/sms/brevo-sms/brevo-sms.provider.ts b/packages/providers/src/lib/sms/brevo-sms/brevo-sms.provider.ts index 87d296aea49..0084e26535f 100644 --- a/packages/providers/src/lib/sms/brevo-sms/brevo-sms.provider.ts +++ b/packages/providers/src/lib/sms/brevo-sms/brevo-sms.provider.ts @@ -3,6 +3,7 @@ import { ProxyAgent } from 'proxy-agent'; import 'cross-fetch'; import { SmsProviderIdEnum } from '@novu/shared'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { providerFetch } from '../../../utils/http'; import { WithPassthrough } from '../../../utils/types'; export class BrevoSmsProvider extends BaseProvider implements ISmsProvider { @@ -30,7 +31,7 @@ export class BrevoSmsProvider extends BaseProvider implements ISmsProvider { content: options.content, }); - const response = await fetch(`${this.BASE_URL}/transactionalSMS/sms`, { + const response = await providerFetch(`${this.BASE_URL}/transactionalSMS/sms`, { method: 'POST', headers: { 'api-key': this.config.apiKey, diff --git a/packages/providers/src/lib/sms/bulk-sms/bulk-sms.provider.ts b/packages/providers/src/lib/sms/bulk-sms/bulk-sms.provider.ts index 8887ba0bfbf..3d30b6f7a6d 100644 --- a/packages/providers/src/lib/sms/bulk-sms/bulk-sms.provider.ts +++ b/packages/providers/src/lib/sms/bulk-sms/bulk-sms.provider.ts @@ -1,7 +1,7 @@ import { SmsProviderIdEnum } from '@novu/shared'; import { ChannelTypeEnum, ISendMessageSuccessResponse, ISmsOptions, ISmsProvider } from '@novu/stateless'; -import axios from 'axios'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { createProviderHttpClient } from '../../../utils/http'; import { WithPassthrough } from '../../../utils/types'; export class BulkSmsProvider extends BaseProvider implements ISmsProvider { @@ -9,6 +9,7 @@ export class BulkSmsProvider extends BaseProvider implements ISmsProvider { channelType = ChannelTypeEnum.SMS as ChannelTypeEnum.SMS; public readonly DEFAULT_BASE_URL = 'https://api.bulksms.com/v1/messages'; protected casing = CasingEnum.CAMEL_CASE; + private readonly httpClient = createProviderHttpClient(); constructor( private config: { @@ -36,7 +37,7 @@ export class BulkSmsProvider extends BaseProvider implements ISmsProvider { const url = this.DEFAULT_BASE_URL; const encodedToken = Buffer.from(this.config.apiToken).toString('base64'); - const response = await axios.create().post(url, JSON.stringify(payload.body), { + const response = await this.httpClient.post(url, JSON.stringify(payload.body), { headers: { Authorization: `Basic ${encodedToken}`, 'Content-Type': 'application/json', diff --git a/packages/providers/src/lib/sms/burst-sms/burst-sms.provider.ts b/packages/providers/src/lib/sms/burst-sms/burst-sms.provider.ts index b3046e99a8b..41009e861cb 100644 --- a/packages/providers/src/lib/sms/burst-sms/burst-sms.provider.ts +++ b/packages/providers/src/lib/sms/burst-sms/burst-sms.provider.ts @@ -1,8 +1,9 @@ import { SmsProviderIdEnum } from '@novu/shared'; import { ChannelTypeEnum, ISendMessageSuccessResponse, ISmsOptions, ISmsProvider } from '@novu/stateless'; -import axios, { AxiosInstance } from 'axios'; +import { AxiosInstance } from 'axios'; import qs from 'qs'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { createProviderHttpClient } from '../../../utils/http'; import { WithPassthrough } from '../../../utils/types'; export class BurstSmsProvider extends BaseProvider implements ISmsProvider { @@ -18,7 +19,7 @@ export class BurstSmsProvider extends BaseProvider implements ISmsProvider { } ) { super(); - this.axiosInstance = axios.create({ + this.axiosInstance = createProviderHttpClient({ auth: { username: config.apiKey, password: config.secretKey, diff --git a/packages/providers/src/lib/sms/clickatell/clickatell.provider.ts b/packages/providers/src/lib/sms/clickatell/clickatell.provider.ts index 0df9f72faf5..b42487f2bf0 100644 --- a/packages/providers/src/lib/sms/clickatell/clickatell.provider.ts +++ b/packages/providers/src/lib/sms/clickatell/clickatell.provider.ts @@ -1,14 +1,15 @@ import { SmsProviderIdEnum } from '@novu/shared'; import { ChannelTypeEnum, ISendMessageSuccessResponse, ISmsOptions, ISmsProvider } from '@novu/stateless'; -import axios from 'axios'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { createProviderHttpClient } from '../../../utils/http'; import { WithPassthrough } from '../../../utils/types'; export class ClickatellSmsProvider extends BaseProvider implements ISmsProvider { id = SmsProviderIdEnum.Clickatell; channelType = ChannelTypeEnum.SMS as ChannelTypeEnum.SMS; protected casing = CasingEnum.CAMEL_CASE; + private readonly httpClient = createProviderHttpClient(); constructor( private config: { @@ -32,7 +33,7 @@ export class ClickatellSmsProvider extends BaseProvider implements ISmsProvider binary: true, }); - const response = await axios.create().post(url, data.body, { + const response = await this.httpClient.post(url, data.body, { headers: { Authorization: this.config.apiKey, ...data.headers, diff --git a/packages/providers/src/lib/sms/clicksend/clicksend.provider.ts b/packages/providers/src/lib/sms/clicksend/clicksend.provider.ts index e3208179d6e..02dceef820f 100644 --- a/packages/providers/src/lib/sms/clicksend/clicksend.provider.ts +++ b/packages/providers/src/lib/sms/clicksend/clicksend.provider.ts @@ -1,13 +1,14 @@ import { SmsProviderIdEnum } from '@novu/shared'; import { ChannelTypeEnum, ISendMessageSuccessResponse, ISmsOptions, ISmsProvider } from '@novu/stateless'; -import axios from 'axios'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { createProviderHttpClient } from '../../../utils/http'; import { WithPassthrough } from '../../../utils/types'; export class ClicksendSmsProvider extends BaseProvider implements ISmsProvider { id = SmsProviderIdEnum.Clicksend; channelType = ChannelTypeEnum.SMS as ChannelTypeEnum.SMS; protected casing = CasingEnum.SNAKE_CASE; + private readonly httpClient = createProviderHttpClient(); constructor( private config: { @@ -26,7 +27,7 @@ export class ClicksendSmsProvider extends BaseProvider implements ISmsProvider { to: options.to, body: options.content, }); - const response = await axios.create().post( + const response = await this.httpClient.post( 'https://rest.clicksend.com/v3/sms/send', { messages: [data.body], diff --git a/packages/providers/src/lib/sms/cm-telecom/cm-telecom.provider.spec.ts b/packages/providers/src/lib/sms/cm-telecom/cm-telecom.provider.spec.ts index dc647d82a73..dbbb0b3fa84 100644 --- a/packages/providers/src/lib/sms/cm-telecom/cm-telecom.provider.spec.ts +++ b/packages/providers/src/lib/sms/cm-telecom/cm-telecom.provider.spec.ts @@ -13,6 +13,9 @@ describe('CmTelecomSmsProvider', () => { let provider: CmTelecomSmsProvider; beforeEach(() => { + // The provider builds its client through `createProviderHttpClient`, so point the + // instance's `post` back at the module-level mock these tests already assert on. + vi.mocked(axios.create).mockReturnValue({ post: axios.post } as never); provider = new CmTelecomSmsProvider(mockConfig); vi.clearAllMocks(); }); diff --git a/packages/providers/src/lib/sms/cm-telecom/cm-telecom.provider.ts b/packages/providers/src/lib/sms/cm-telecom/cm-telecom.provider.ts index 95f042bf211..bb916962655 100644 --- a/packages/providers/src/lib/sms/cm-telecom/cm-telecom.provider.ts +++ b/packages/providers/src/lib/sms/cm-telecom/cm-telecom.provider.ts @@ -1,8 +1,8 @@ import { SmsProviderIdEnum } from '@novu/shared'; import { ChannelTypeEnum, ISendMessageSuccessResponse, ISmsOptions, ISmsProvider } from '@novu/stateless'; -import axios from 'axios'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { createProviderHttpClient } from '../../../utils/http'; import { WithPassthrough } from '../../../utils/types'; export class CmTelecomSmsProvider extends BaseProvider implements ISmsProvider { @@ -10,6 +10,7 @@ export class CmTelecomSmsProvider extends BaseProvider implements ISmsProvider { channelType = ChannelTypeEnum.SMS as ChannelTypeEnum.SMS; protected casing = CasingEnum.CAMEL_CASE; private readonly BASE_URL = 'https://gw.messaging.cm.com/v1.0/message'; + private readonly httpClient = createProviderHttpClient(); constructor( private config: { @@ -41,7 +42,7 @@ export class CmTelecomSmsProvider extends BaseProvider implements ISmsProvider { }, }); - const { data } = await axios.post(this.BASE_URL, payload.body, { + const { data } = await this.httpClient.post(this.BASE_URL, payload.body, { headers: { 'Content-Type': 'application/json', 'X-CM-PRODUCTTOKEN': this.config.productToken, diff --git a/packages/providers/src/lib/sms/eazy-sms/eazy-sms.provider.ts b/packages/providers/src/lib/sms/eazy-sms/eazy-sms.provider.ts index 49c51e7d31a..50bd045015a 100644 --- a/packages/providers/src/lib/sms/eazy-sms/eazy-sms.provider.ts +++ b/packages/providers/src/lib/sms/eazy-sms/eazy-sms.provider.ts @@ -1,7 +1,7 @@ import { SmsProviderIdEnum } from '@novu/shared'; import { ChannelTypeEnum, ISendMessageSuccessResponse, ISmsOptions, ISmsProvider } from '@novu/stateless'; -import axios from 'axios'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { createProviderHttpClient } from '../../../utils/http'; import { WithPassthrough } from '../../../utils/types'; export class EazySmsProvider extends BaseProvider implements ISmsProvider { @@ -10,6 +10,8 @@ export class EazySmsProvider extends BaseProvider implements ISmsProvider { protected casing = CasingEnum.CAMEL_CASE; public readonly DEFAULT_BASE_URL = 'https://api.eazy.im/v3'; public readonly EAZY_SMS_CHANNEL = '@sms.eazy.im'; + private readonly httpClient = createProviderHttpClient(); + constructor( private config: { apiKey: string; @@ -30,19 +32,17 @@ export class EazySmsProvider extends BaseProvider implements ISmsProvider { }, }); - const response = await axios - .create() - .post( - `${this.DEFAULT_BASE_URL}/channels/${this.config.channelId}/messages/${options.to}${this.EAZY_SMS_CHANNEL}`, - payload.body, - { - headers: { - Authorization: `Bearer ${this.config.apiKey}`, - 'Content-Type': 'application/json', - ...payload.headers, - }, - } - ); + const response = await this.httpClient.post( + `${this.DEFAULT_BASE_URL}/channels/${this.config.channelId}/messages/${options.to}${this.EAZY_SMS_CHANNEL}`, + payload.body, + { + headers: { + Authorization: `Bearer ${this.config.apiKey}`, + 'Content-Type': 'application/json', + ...payload.headers, + }, + } + ); return { id: response.data.id, diff --git a/packages/providers/src/lib/sms/firetext/firetext.provider.spec.ts b/packages/providers/src/lib/sms/firetext/firetext.provider.spec.ts index e57c07598f0..f97652286b3 100644 --- a/packages/providers/src/lib/sms/firetext/firetext.provider.spec.ts +++ b/packages/providers/src/lib/sms/firetext/firetext.provider.spec.ts @@ -52,7 +52,8 @@ describe('FiretextSmsProvider', () => { }); expect(fetchMock).toHaveBeenCalledWith( - 'https://www.firetext.co.uk/api/sendsms?apiKey=apiKey&to=%2B44123456789&from=testFrom&message=content' + 'https://www.firetext.co.uk/api/sendsms?apiKey=apiKey&to=%2B44123456789&from=testFrom&message=content', + { signal: expect.any(AbortSignal) } ); }); @@ -83,7 +84,8 @@ describe('FiretextSmsProvider', () => { ); expect(fetchMock).toHaveBeenCalledWith( - 'https://www.firetext.co.uk/api/sendsms?apiKey=apiKey&to=%2B24123456789&from=testFrom&message=content' + 'https://www.firetext.co.uk/api/sendsms?apiKey=apiKey&to=%2B24123456789&from=testFrom&message=content', + { signal: expect.any(AbortSignal) } ); }); diff --git a/packages/providers/src/lib/sms/firetext/firetext.provider.ts b/packages/providers/src/lib/sms/firetext/firetext.provider.ts index 65c42e508ac..e849b19223d 100644 --- a/packages/providers/src/lib/sms/firetext/firetext.provider.ts +++ b/packages/providers/src/lib/sms/firetext/firetext.provider.ts @@ -1,6 +1,7 @@ import { SmsProviderIdEnum } from '@novu/shared'; import { ChannelTypeEnum, ISendMessageSuccessResponse, ISmsOptions, ISmsProvider } from '@novu/stateless'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { providerFetch } from '../../../utils/http'; import { WithPassthrough } from '../../../utils/types'; export class FiretextSmsProvider extends BaseProvider implements ISmsProvider { @@ -52,7 +53,7 @@ export class FiretextSmsProvider extends BaseProvider implements ISmsProvider { const url = new URL(this.BASE_URL); url.search = urlSearchParams.toString(); - const response = await fetch(url.toString()); + const response = await providerFetch(url.toString()); const body = await response.text(); const [code, message] = this.parseResponse(body); diff --git a/packages/providers/src/lib/sms/forty-six-elks/forty-six-elks.provider.ts b/packages/providers/src/lib/sms/forty-six-elks/forty-six-elks.provider.ts index c21ddba4c9b..5c5476249c3 100644 --- a/packages/providers/src/lib/sms/forty-six-elks/forty-six-elks.provider.ts +++ b/packages/providers/src/lib/sms/forty-six-elks/forty-six-elks.provider.ts @@ -1,7 +1,7 @@ import { SmsProviderIdEnum } from '@novu/shared'; import { ChannelTypeEnum, ISendMessageSuccessResponse, ISmsOptions, ISmsProvider } from '@novu/stateless'; -import axios from 'axios'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { createProviderHttpClient } from '../../../utils/http'; import { WithPassthrough } from '../../../utils/types'; interface IFortySixElksSuccessObject { @@ -24,6 +24,7 @@ export class FortySixElksSmsProvider extends BaseProvider implements ISmsProvide id = SmsProviderIdEnum.FortySixElks; channelType = ChannelTypeEnum.SMS as ChannelTypeEnum.SMS; protected casing = CasingEnum.SNAKE_CASE; + private readonly httpClient = createProviderHttpClient(); constructor( private config: { @@ -49,7 +50,7 @@ export class FortySixElksSmsProvider extends BaseProvider implements ISmsProvide const data = new URLSearchParams(transformedData.body).toString(); - const res: IFortySixElksRequestResponse = await axios.create().post('https://api.46elks.com/a1/sms', data, { + const res: IFortySixElksRequestResponse = await this.httpClient.post('https://api.46elks.com/a1/sms', data, { headers: { Authorization: `Basic ${authKey}`, ...transformedData.headers, diff --git a/packages/providers/src/lib/sms/generic-sms/generic-sms.provider.ts b/packages/providers/src/lib/sms/generic-sms/generic-sms.provider.ts index 94cf999c02f..25b98c0112f 100644 --- a/packages/providers/src/lib/sms/generic-sms/generic-sms.provider.ts +++ b/packages/providers/src/lib/sms/generic-sms/generic-sms.provider.ts @@ -7,8 +7,9 @@ import { } from '@novu/shared/utils/ssrf-url-validation'; import { ChannelTypeEnum, ISendMessageSuccessResponse, ISmsOptions, ISmsProvider } from '@novu/stateless'; -import axios, { AxiosInstance } from 'axios'; +import { AxiosInstance } from 'axios'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { createProviderHttpClient } from '../../../utils/http'; import { WithPassthrough } from '../../../utils/types'; export class GenericSmsProvider extends BaseProvider implements ISmsProvider { @@ -43,7 +44,7 @@ export class GenericSmsProvider extends BaseProvider implements ISmsProvider { } if (!this.config?.authenticateByToken) { - this.axiosInstance = axios.create({ + this.axiosInstance = createProviderHttpClient({ baseURL: config.baseUrl, headers: this.headers, }); @@ -71,7 +72,7 @@ export class GenericSmsProvider extends BaseProvider implements ISmsProvider { }); if (this.config?.authenticateByToken) { - const tokenAxiosInstance = await axios.request({ + const tokenAxiosInstance = await createProviderHttpClient().request({ method: 'POST', baseURL: this.config.domain, headers: this.headers, @@ -79,7 +80,7 @@ export class GenericSmsProvider extends BaseProvider implements ISmsProvider { const token = tokenAxiosInstance.data.data[this.config.authenticationTokenKey!]; - this.axiosInstance = axios.create({ + this.axiosInstance = createProviderHttpClient({ baseURL: this.config.baseUrl, headers: { [this.config.authenticationTokenKey!]: token, diff --git a/packages/providers/src/lib/sms/gupshup/gupshup.provider.ts b/packages/providers/src/lib/sms/gupshup/gupshup.provider.ts index 12d9a898975..f6cd3a6b235 100644 --- a/packages/providers/src/lib/sms/gupshup/gupshup.provider.ts +++ b/packages/providers/src/lib/sms/gupshup/gupshup.provider.ts @@ -1,7 +1,7 @@ import { SmsProviderIdEnum } from '@novu/shared'; import { ChannelTypeEnum, ISendMessageSuccessResponse, ISmsOptions, ISmsProvider } from '@novu/stateless'; -import axios from 'axios'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { createProviderHttpClient } from '../../../utils/http'; import { WithPassthrough } from '../../../utils/types'; export class GupshupSmsProvider extends BaseProvider implements ISmsProvider { @@ -9,6 +9,7 @@ export class GupshupSmsProvider extends BaseProvider implements ISmsProvider { protected casing = CasingEnum.SNAKE_CASE; channelType = ChannelTypeEnum.SMS as ChannelTypeEnum.SMS; public static BASE_URL = 'https://enterprise.smsgupshup.com/GatewayAPI/rest'; + private readonly httpClient = createProviderHttpClient(); constructor( private config: { @@ -41,7 +42,7 @@ export class GupshupSmsProvider extends BaseProvider implements ISmsProvider { }), }).body; - const response = await axios.create().post(GupshupSmsProvider.BASE_URL, params); + const response = await this.httpClient.post(GupshupSmsProvider.BASE_URL, params); const body = response.data; const result = body.split(' | '); diff --git a/packages/providers/src/lib/sms/imedia/imedia.provider.ts b/packages/providers/src/lib/sms/imedia/imedia.provider.ts index 35916c288be..2893a5ce071 100644 --- a/packages/providers/src/lib/sms/imedia/imedia.provider.ts +++ b/packages/providers/src/lib/sms/imedia/imedia.provider.ts @@ -7,9 +7,10 @@ import { type ISmsProvider, SmsEventStatusEnum, } from '@novu/stateless'; -import axios, { type AxiosInstance } from 'axios'; +import { type AxiosInstance } from 'axios'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { createProviderHttpClient } from '../../../utils/http'; import type { WithPassthrough } from '../../../utils/types'; interface IMediaSmsConfig { @@ -51,7 +52,7 @@ export class IMediaSmsProvider extends BaseProvider implements ISmsProvider { constructor(private config: IMediaSmsConfig) { super(); - this.axiosInstance = axios.create({ + this.axiosInstance = createProviderHttpClient({ baseURL: IMediaSmsProvider.BASE_URL, headers: { 'Content-Type': 'application/json', diff --git a/packages/providers/src/lib/sms/isend-sms/isend-sms.provider.ts b/packages/providers/src/lib/sms/isend-sms/isend-sms.provider.ts index 1725e97a547..00c6b465300 100644 --- a/packages/providers/src/lib/sms/isend-sms/isend-sms.provider.ts +++ b/packages/providers/src/lib/sms/isend-sms/isend-sms.provider.ts @@ -1,7 +1,8 @@ import { SmsProviderIdEnum } from '@novu/shared'; import { ChannelTypeEnum, ISendMessageSuccessResponse, ISmsOptions, ISmsProvider } from '@novu/stateless'; -import axios, { AxiosInstance } from 'axios'; +import { AxiosInstance } from 'axios'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { createProviderHttpClient } from '../../../utils/http'; import { WithPassthrough } from '../../../utils/types'; export interface ISendSmsData { @@ -43,7 +44,7 @@ export class ISendSmsProvider extends BaseProvider implements ISmsProvider { } ) { super(); - this.Instance = axios.create({ + this.Instance = createProviderHttpClient({ baseURL: 'https://send.com.ly', headers: { Accept: 'application/json', diff --git a/packages/providers/src/lib/sms/isendpro-sms/isendpro-sms.provider.ts b/packages/providers/src/lib/sms/isendpro-sms/isendpro-sms.provider.ts index 1f9e91d3d39..86449441394 100644 --- a/packages/providers/src/lib/sms/isendpro-sms/isendpro-sms.provider.ts +++ b/packages/providers/src/lib/sms/isendpro-sms/isendpro-sms.provider.ts @@ -1,7 +1,7 @@ import { SmsProviderIdEnum } from '@novu/shared'; import { ChannelTypeEnum, ISendMessageSuccessResponse, ISmsOptions, ISmsProvider } from '@novu/stateless'; -import axios from 'axios'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { createProviderHttpClient } from '../../../utils/http'; import { WithPassthrough } from '../../../utils/types'; // Define payload type to ensure TypeScript knows the structure @@ -21,6 +21,7 @@ export class ISendProSmsProvider extends BaseProvider implements ISmsProvider { protected casing = CasingEnum.CAMEL_CASE; public readonly DEFAULT_BASE_URL = 'https://apirest.isendpro.com/cgi-bin'; + private readonly httpClient = createProviderHttpClient(); constructor( private config: { @@ -57,7 +58,7 @@ export class ISendProSmsProvider extends BaseProvider implements ISmsProvider { params.append('emetteur', this.config.from || 'NOVU'); // Send the SMS via iSendPro API - const response = await axios.post(`${this.DEFAULT_BASE_URL}/sms`, params, { + const response = await this.httpClient.post(`${this.DEFAULT_BASE_URL}/sms`, params, { headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...payload.headers, diff --git a/packages/providers/src/lib/sms/isendpro-sms/isendpro-sms.test.provider.spec.ts b/packages/providers/src/lib/sms/isendpro-sms/isendpro-sms.test.provider.spec.ts index 750490bff42..dd4059a5928 100644 --- a/packages/providers/src/lib/sms/isendpro-sms/isendpro-sms.test.provider.spec.ts +++ b/packages/providers/src/lib/sms/isendpro-sms/isendpro-sms.test.provider.spec.ts @@ -16,6 +16,9 @@ const mockSMSMessage = { beforeEach(() => { vi.clearAllMocks(); + // The provider builds its client through `createProviderHttpClient`, so point the + // instance's `post` back at the module-level mock these tests already assert on. + vi.mocked(axios.create).mockReturnValue({ post: axios.post } as never); }); test('should trigger iSendPro API correctly', async () => { diff --git a/packages/providers/src/lib/sms/kannel/kannel.provider.ts b/packages/providers/src/lib/sms/kannel/kannel.provider.ts index 64b9ddc3533..8a4862c443d 100644 --- a/packages/providers/src/lib/sms/kannel/kannel.provider.ts +++ b/packages/providers/src/lib/sms/kannel/kannel.provider.ts @@ -1,8 +1,9 @@ import { isOutboundSsrfProtectionEnabled, SmsProviderIdEnum } from '@novu/shared'; import { safeOutboundRequest } from '@novu/shared/utils/safe-outbound-http'; import { ChannelTypeEnum, ISendMessageSuccessResponse, ISmsOptions, ISmsProvider } from '@novu/stateless'; -import axios, { AxiosInstance } from 'axios'; +import { AxiosInstance } from 'axios'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { createProviderHttpClient } from '../../../utils/http'; import { resolveSafeProviderUrl } from '../../../utils/safe-provider-url'; import { WithPassthrough } from '../../../utils/types'; @@ -24,7 +25,7 @@ export class KannelSmsProvider extends BaseProvider implements ISmsProvider { ) { super(); this.apiBaseUrl = `http://${config.host}:${config.port}/cgi-bin`; - this.axiosInstance = axios.create(); + this.axiosInstance = createProviderHttpClient(); } async sendMessage( diff --git a/packages/providers/src/lib/sms/maqsam/maqsam.provider.ts b/packages/providers/src/lib/sms/maqsam/maqsam.provider.ts index d191ac0461c..6b505dc406c 100644 --- a/packages/providers/src/lib/sms/maqsam/maqsam.provider.ts +++ b/packages/providers/src/lib/sms/maqsam/maqsam.provider.ts @@ -1,9 +1,10 @@ import { SmsProviderIdEnum } from '@novu/shared'; import { ChannelTypeEnum, ISendMessageSuccessResponse, ISmsOptions, ISmsProvider } from '@novu/stateless'; -import axios, { AxiosInstance } from 'axios'; +import { AxiosInstance } from 'axios'; import { fromUnixTime } from 'date-fns'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { createProviderHttpClient } from '../../../utils/http'; import { WithPassthrough } from '../../../utils/types'; export class MaqsamSmsProvider extends BaseProvider implements ISmsProvider { @@ -20,7 +21,7 @@ export class MaqsamSmsProvider extends BaseProvider implements ISmsProvider { } ) { super(); - this.axiosInstance = axios.create({ + this.axiosInstance = createProviderHttpClient({ baseURL: 'https://api.maqsam.com/v2/sms', auth: { username: config.accessKeyId, diff --git a/packages/providers/src/lib/sms/mobishastra/mobishastra.provider.ts b/packages/providers/src/lib/sms/mobishastra/mobishastra.provider.ts index e79e89bb8c0..b9e52879f93 100644 --- a/packages/providers/src/lib/sms/mobishastra/mobishastra.provider.ts +++ b/packages/providers/src/lib/sms/mobishastra/mobishastra.provider.ts @@ -1,8 +1,9 @@ import { isOutboundSsrfProtectionEnabled, SmsProviderIdEnum } from '@novu/shared'; import { safeOutboundJsonRequest } from '@novu/shared/utils/safe-outbound-http'; import { ChannelTypeEnum, ISendMessageSuccessResponse, ISmsOptions, ISmsProvider } from '@novu/stateless'; -import axios, { AxiosInstance } from 'axios'; +import { AxiosInstance } from 'axios'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { createProviderHttpClient } from '../../../utils/http'; import { resolveSafeProviderUrl } from '../../../utils/safe-provider-url'; import { WithPassthrough } from '../../../utils/types'; @@ -23,7 +24,7 @@ export class MobishastraProvider extends BaseProvider implements ISmsProvider { } ) { super(); - this.axiosInstance = axios.create({ + this.axiosInstance = createProviderHttpClient({ baseURL: config.baseUrl, headers: { 'Content-Type': 'application/json', diff --git a/packages/providers/src/lib/sms/ruach-sms/ruach-sms.provider.spec.ts b/packages/providers/src/lib/sms/ruach-sms/ruach-sms.provider.spec.ts index 6b3da0ad712..8adbaf8de36 100644 --- a/packages/providers/src/lib/sms/ruach-sms/ruach-sms.provider.spec.ts +++ b/packages/providers/src/lib/sms/ruach-sms/ruach-sms.provider.spec.ts @@ -27,6 +27,9 @@ describe('RuachSmsProvider', () => { let provider: RuachSmsProvider; beforeEach(() => { + // The provider builds its client through `createProviderHttpClient`, so point the + // instance's `post` back at the module-level mock these tests already assert on. + vi.mocked(axios.create).mockReturnValue({ post: axios.post } as never); provider = new RuachSmsProvider(mockConfig); vi.clearAllMocks(); }); diff --git a/packages/providers/src/lib/sms/ruach-sms/ruach-sms.provider.ts b/packages/providers/src/lib/sms/ruach-sms/ruach-sms.provider.ts index a84438248ab..c86f2b3b43f 100644 --- a/packages/providers/src/lib/sms/ruach-sms/ruach-sms.provider.ts +++ b/packages/providers/src/lib/sms/ruach-sms/ruach-sms.provider.ts @@ -1,7 +1,7 @@ import { SmsProviderIdEnum } from '@novu/shared'; import { ChannelTypeEnum, ISendMessageSuccessResponse, ISmsOptions, ISmsProvider } from '@novu/stateless'; -import axios from 'axios'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { createProviderHttpClient } from '../../../utils/http'; import { WithPassthrough } from '../../../utils/types'; export class RuachSmsProvider extends BaseProvider implements ISmsProvider { @@ -9,6 +9,7 @@ export class RuachSmsProvider extends BaseProvider implements ISmsProvider { channelType = ChannelTypeEnum.SMS as ChannelTypeEnum.SMS; protected casing = CasingEnum.PASCAL_CASE; private readonly BASE_URL = 'https://app.notify.ng/api/v2/SendSMS'; + private readonly httpClient = createProviderHttpClient(); constructor( private config: { @@ -34,7 +35,7 @@ export class RuachSmsProvider extends BaseProvider implements ISmsProvider { Is_Flash: false, }); - const { data } = await axios.post(this.BASE_URL, payload.body, { + const { data } = await this.httpClient.post(this.BASE_URL, payload.body, { headers: { 'Content-Type': 'application/json', ...payload.headers, diff --git a/packages/providers/src/lib/sms/sendchamp/sendchamp.provider.ts b/packages/providers/src/lib/sms/sendchamp/sendchamp.provider.ts index bb637192af3..0eabf9a3712 100644 --- a/packages/providers/src/lib/sms/sendchamp/sendchamp.provider.ts +++ b/packages/providers/src/lib/sms/sendchamp/sendchamp.provider.ts @@ -1,7 +1,8 @@ import { SmsProviderIdEnum } from '@novu/shared'; import { ChannelTypeEnum, ISendMessageSuccessResponse, ISmsOptions, ISmsProvider } from '@novu/stateless'; -import axios, { AxiosInstance } from 'axios'; +import { AxiosInstance } from 'axios'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { createProviderHttpClient } from '../../../utils/http'; import { WithPassthrough } from '../../../utils/types'; export class SendchampSmsProvider extends BaseProvider implements ISmsProvider { @@ -18,7 +19,7 @@ export class SendchampSmsProvider extends BaseProvider implements ISmsProvider { } ) { super(); - this.axiosInstance = axios.create({ + this.axiosInstance = createProviderHttpClient({ baseURL: this.BASE_URL, headers: { 'Content-Type': 'application/json', diff --git a/packages/providers/src/lib/sms/simpletexting/simpletexting.provider.ts b/packages/providers/src/lib/sms/simpletexting/simpletexting.provider.ts index f817a3d007f..87bcf3b52d6 100644 --- a/packages/providers/src/lib/sms/simpletexting/simpletexting.provider.ts +++ b/packages/providers/src/lib/sms/simpletexting/simpletexting.provider.ts @@ -1,14 +1,15 @@ import { SmsProviderIdEnum } from '@novu/shared'; import { ChannelTypeEnum, ISendMessageSuccessResponse, ISmsOptions, ISmsProvider } from '@novu/stateless'; -import axios from 'axios'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { createProviderHttpClient } from '../../../utils/http'; import { WithPassthrough } from '../../../utils/types'; export class SimpletextingSmsProvider extends BaseProvider implements ISmsProvider { id = SmsProviderIdEnum.Simpletexting; channelType = ChannelTypeEnum.SMS as ChannelTypeEnum.SMS; protected casing = CasingEnum.CAMEL_CASE; + private readonly httpClient = createProviderHttpClient(); constructor( private config: { @@ -29,7 +30,7 @@ export class SimpletextingSmsProvider extends BaseProvider implements ISmsProvid mode: 'SINGLE_SMS_STRICTLY', text: options.content, }); - const response = await axios.create().post('https://api-app2.simpletexting.com/v2/api/messages', data.body, { + const response = await this.httpClient.post('https://api-app2.simpletexting.com/v2/api/messages', data.body, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.config.apiKey}`, diff --git a/packages/providers/src/lib/sms/sinch/sinch.provider.spec.ts b/packages/providers/src/lib/sms/sinch/sinch.provider.spec.ts index 25d70fa2ce6..9a9e280941b 100644 --- a/packages/providers/src/lib/sms/sinch/sinch.provider.spec.ts +++ b/packages/providers/src/lib/sms/sinch/sinch.provider.spec.ts @@ -15,6 +15,9 @@ describe('SinchSmsProvider', () => { let provider: SinchSmsProvider; beforeEach(() => { + // The provider builds its client through `createProviderHttpClient`, so point the + // instance's `post` back at the module-level mock these tests already assert on. + vi.mocked(axios.create).mockReturnValue({ post: axios.post } as never); provider = new SinchSmsProvider(mockConfig); vi.clearAllMocks(); }); diff --git a/packages/providers/src/lib/sms/sinch/sinch.provider.ts b/packages/providers/src/lib/sms/sinch/sinch.provider.ts index aa6f6d7a083..24e0e4b9ef0 100644 --- a/packages/providers/src/lib/sms/sinch/sinch.provider.ts +++ b/packages/providers/src/lib/sms/sinch/sinch.provider.ts @@ -1,14 +1,15 @@ import { assertAllowedSinchSmsRegion, SmsProviderIdEnum } from '@novu/shared'; import { ChannelTypeEnum, ISendMessageSuccessResponse, ISmsOptions, ISmsProvider } from '@novu/stateless'; -import axios from 'axios'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { createProviderHttpClient } from '../../../utils/http'; import { WithPassthrough } from '../../../utils/types'; export class SinchSmsProvider extends BaseProvider implements ISmsProvider { id = SmsProviderIdEnum.Sinch; protected casing = CasingEnum.CAMEL_CASE; channelType = ChannelTypeEnum.SMS as ChannelTypeEnum.SMS; + private readonly httpClient = createProviderHttpClient(); private readonly region: ReturnType; constructor( @@ -35,7 +36,7 @@ export class SinchSmsProvider extends BaseProvider implements ISmsProvider { body: options.content, }).body; - const response = await axios.post(url, payload, { + const response = await this.httpClient.post(url, payload, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.config.apiToken}`, diff --git a/packages/providers/src/lib/sms/sms-central/sms-central.provider.ts b/packages/providers/src/lib/sms/sms-central/sms-central.provider.ts index 746f474b737..a17c84feb30 100644 --- a/packages/providers/src/lib/sms/sms-central/sms-central.provider.ts +++ b/packages/providers/src/lib/sms/sms-central/sms-central.provider.ts @@ -1,8 +1,8 @@ import { isOutboundSsrfProtectionEnabled, SmsProviderIdEnum } from '@novu/shared'; import { safeOutboundJsonRequest } from '@novu/shared/utils/safe-outbound-http'; import { ChannelTypeEnum, ISendMessageSuccessResponse, ISmsOptions, ISmsProvider } from '@novu/stateless'; -import axios from 'axios'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { createProviderHttpClient } from '../../../utils/http'; import { resolveSafeProviderUrl } from '../../../utils/safe-provider-url'; import { WithPassthrough } from '../../../utils/types'; @@ -11,6 +11,7 @@ export class SmsCentralSmsProvider extends BaseProvider implements ISmsProvider id = SmsProviderIdEnum.SmsCentral; protected casing = CasingEnum.CONSTANT_CASE; channelType = ChannelTypeEnum.SMS as ChannelTypeEnum.SMS; + private readonly httpClient = createProviderHttpClient(); constructor( private config: { @@ -47,7 +48,7 @@ export class SmsCentralSmsProvider extends BaseProvider implements ISmsProvider throw new Error(`SMS Central request failed with status ${response.statusCode}`); } } else { - await axios.create().post(url, data); + await this.httpClient.post(url, data); } return { diff --git a/packages/providers/src/lib/sms/smsmode/smsmode.provider.ts b/packages/providers/src/lib/sms/smsmode/smsmode.provider.ts index ad0dad9ea73..67cab87d75c 100644 --- a/packages/providers/src/lib/sms/smsmode/smsmode.provider.ts +++ b/packages/providers/src/lib/sms/smsmode/smsmode.provider.ts @@ -1,7 +1,7 @@ import { SmsProviderIdEnum } from '@novu/shared'; import { ChannelTypeEnum, ISendMessageSuccessResponse, ISmsOptions, ISmsProvider } from '@novu/stateless'; -import axios from 'axios'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { createProviderHttpClient } from '../../../utils/http'; import { WithPassthrough } from '../../../utils/types'; export interface ISmsmodeApiResponse { @@ -58,6 +58,7 @@ export class SmsmodeSmsProvider extends BaseProvider implements ISmsProvider { channelType = ChannelTypeEnum.SMS as ChannelTypeEnum.SMS; public readonly BASE_URL = 'https://rest.smsmode.com/sms/v1'; protected casing: CasingEnum = CasingEnum.CAMEL_CASE; + private readonly httpClient = createProviderHttpClient(); constructor( private config: { @@ -82,7 +83,7 @@ export class SmsmodeSmsProvider extends BaseProvider implements ISmsProvider { }, }); - const response = await axios.create().post(`${this.BASE_URL}/messages`, sms.body, { + const response = await this.httpClient.post(`${this.BASE_URL}/messages`, sms.body, { headers: { 'X-Api-Key': this.config.apiKey, 'Content-Type': 'application/json', diff --git a/packages/providers/src/lib/sms/smsmode/smsmode.test.provider.spec.ts b/packages/providers/src/lib/sms/smsmode/smsmode.test.provider.spec.ts index 22cb6f11a32..81b538cb948 100644 --- a/packages/providers/src/lib/sms/smsmode/smsmode.test.provider.spec.ts +++ b/packages/providers/src/lib/sms/smsmode/smsmode.test.provider.spec.ts @@ -54,36 +54,36 @@ afterEach(() => { describe('sendMessage method', () => { test('should call smsmode API sms endpoint once with POST method', async () => { - const provider = new SmsmodeSmsProvider(mockConfig); - const { mockPost: fakePost } = axiosSpy({ data: '0', }); + const provider = new SmsmodeSmsProvider(mockConfig); + await provider.sendMessage(mockNovuMessage); expect(fakePost).toHaveBeenCalled(); }); test('should call smsmode API endpoint with right URL', async () => { - const provider = new SmsmodeSmsProvider(mockConfig); - const { mockPost: fakePost } = axiosSpy({ data: '0', }); + const provider = new SmsmodeSmsProvider(mockConfig); + await provider.sendMessage(mockNovuMessage); expect(fakePost.mock.calls[0][0]).toEqual('https://rest.smsmode.com/sms/v1/messages'); }); test('should call smsmode API using config apiKey', async () => { - const provider = new SmsmodeSmsProvider(mockConfig); - const { mockPost: fakePost } = axiosSpy({ data: '0', }); + const provider = new SmsmodeSmsProvider(mockConfig); + await provider.sendMessage(mockNovuMessage); expect(fakePost.mock.calls[0][2]).toMatchObject({ @@ -94,12 +94,12 @@ describe('sendMessage method', () => { }); test('should send message with provided config from', async () => { - const provider = new SmsmodeSmsProvider(mockConfig); - const { mockPost: fakePost } = axiosSpy({ data: '0', }); + const provider = new SmsmodeSmsProvider(mockConfig); + const { from, ...mockNovuMessageWithoutFrom } = mockNovuMessage; await provider.sendMessage(mockNovuMessageWithoutFrom); @@ -111,12 +111,12 @@ describe('sendMessage method', () => { }); test('should send message with provided option from overriding config from', async () => { - const provider = new SmsmodeSmsProvider(mockConfig); - const { mockPost: fakePost } = axiosSpy({ data: '0', }); + const provider = new SmsmodeSmsProvider(mockConfig); + await provider.sendMessage(mockNovuMessage); const requestBody = fakePost.mock.calls[0][1]; @@ -125,12 +125,12 @@ describe('sendMessage method', () => { }); test('should send message with provided option to', async () => { - const provider = new SmsmodeSmsProvider(mockConfig); - const { mockPost: fakePost } = axiosSpy({ data: '0', }); + const provider = new SmsmodeSmsProvider(mockConfig); + await provider.sendMessage(mockNovuMessage); const requestBody = fakePost.mock.calls[0][1]; @@ -139,12 +139,12 @@ describe('sendMessage method', () => { }); test('should send message with provided option content', async () => { - const provider = new SmsmodeSmsProvider(mockConfig); - const { mockPost: fakePost } = axiosSpy({ data: '0', }); + const provider = new SmsmodeSmsProvider(mockConfig); + await provider.sendMessage(mockNovuMessage); const requestBody = fakePost.mock.calls[0][1]; @@ -153,12 +153,12 @@ describe('sendMessage method', () => { }); test('should send message with provided option content with _passthrough', async () => { - const provider = new SmsmodeSmsProvider(mockConfig); - const { mockPost: fakePost } = axiosSpy({ data: '0', }); + const provider = new SmsmodeSmsProvider(mockConfig); + await provider.sendMessage(mockNovuMessage, { _passthrough: { body: { @@ -175,12 +175,12 @@ describe('sendMessage method', () => { }); test('should return id returned in request response', async () => { - const provider = new SmsmodeSmsProvider(mockConfig); - axiosSpy({ data: mockSmsmodeApiResponse, }); + const provider = new SmsmodeSmsProvider(mockConfig); + const result = await provider.sendMessage(mockNovuMessage); expect(result).toMatchObject({ @@ -189,12 +189,12 @@ describe('sendMessage method', () => { }); test('should return date returned in request response', async () => { - const provider = new SmsmodeSmsProvider(mockConfig); - axiosSpy({ data: mockSmsmodeApiResponse, }); + const provider = new SmsmodeSmsProvider(mockConfig); + const result = await provider.sendMessage(mockNovuMessage); expect(result).toMatchObject({ diff --git a/packages/providers/src/lib/sms/termii/termii.provider.ts b/packages/providers/src/lib/sms/termii/termii.provider.ts index d024bd8c600..256d88f494c 100644 --- a/packages/providers/src/lib/sms/termii/termii.provider.ts +++ b/packages/providers/src/lib/sms/termii/termii.provider.ts @@ -8,6 +8,7 @@ import { SmsEventStatusEnum, } from '@novu/stateless'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { providerFetch } from '../../../utils/http'; import { WithPassthrough } from '../../../utils/types'; import { MessageChannel, SmsJsonResponse, SmsParams } from './sms'; @@ -56,7 +57,7 @@ export class TermiiSmsProvider extends BaseProvider implements ISmsProvider { body: JSON.stringify(params.body), } as RequestInit; - const response = await fetch(TermiiSmsProvider.BASE_URL, opts); + const response = await providerFetch(TermiiSmsProvider.BASE_URL, opts); const body = (await response.json()) as SmsJsonResponse; return { diff --git a/packages/providers/src/lib/sms/unifonic/unifonic.provider.ts b/packages/providers/src/lib/sms/unifonic/unifonic.provider.ts index 8d0ed530772..c7ebc6cf7bb 100644 --- a/packages/providers/src/lib/sms/unifonic/unifonic.provider.ts +++ b/packages/providers/src/lib/sms/unifonic/unifonic.provider.ts @@ -1,8 +1,8 @@ import { SmsProviderIdEnum } from '@novu/shared'; import { ChannelTypeEnum, ISendMessageSuccessResponse, ISmsOptions, ISmsProvider } from '@novu/stateless'; -import axios from 'axios'; import qs from 'qs'; import { BaseProvider, CasingEnum } from '../../../base.provider'; +import { createProviderHttpClient } from '../../../utils/http'; import { WithPassthrough } from '../../../utils/types'; interface IUnifonicConfig { @@ -14,6 +14,7 @@ export class UnifonicSmsProvider extends BaseProvider implements ISmsProvider { id = SmsProviderIdEnum.Unifonic; channelType = ChannelTypeEnum.SMS as ChannelTypeEnum.SMS; protected casing = CasingEnum.CAMEL_CASE; + private readonly httpClient = createProviderHttpClient(); constructor(private config: IUnifonicConfig) { super(); @@ -32,11 +33,15 @@ export class UnifonicSmsProvider extends BaseProvider implements ISmsProvider { baseEncode: true, }); - const response = await axios.post('https://el.cloud.unifonic.com/rest/SMS/messages', qs.stringify(payload.body), { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - }); + const response = await this.httpClient.post( + 'https://el.cloud.unifonic.com/rest/SMS/messages', + qs.stringify(payload.body), + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + } + ); if (response.data?.data?.MessageID) { return { diff --git a/packages/providers/src/utils/http/index.ts b/packages/providers/src/utils/http/index.ts new file mode 100644 index 00000000000..ada452a1be6 --- /dev/null +++ b/packages/providers/src/utils/http/index.ts @@ -0,0 +1,7 @@ +export { type ProviderFetchOptions, providerFetch } from './provider-fetch'; +export { createProviderHttpClient, type ProviderHttpClientOptions } from './provider-http.client'; +export { + DEFAULT_PROVIDER_HTTP_TIMEOUT_MS, + PROVIDER_HTTP_TIMEOUT_MS, + PROVIDER_HTTP_TIMEOUT_MS_ENV_VAR, +} from './provider-http.constants'; diff --git a/packages/providers/src/utils/http/provider-fetch.spec.ts b/packages/providers/src/utils/http/provider-fetch.spec.ts new file mode 100644 index 00000000000..e4f9e705710 --- /dev/null +++ b/packages/providers/src/utils/http/provider-fetch.spec.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { providerFetch } from './provider-fetch'; + +const URL_UNDER_TEST = 'https://provider.example.com/send'; + +describe('providerFetch', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('attaches an abort signal even when no init is supplied', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response('{}')); + global.fetch = fetchMock; + + await providerFetch(URL_UNDER_TEST); + + expect(fetchMock.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal); + }); + + test('forwards caller init untouched, including non-standard options', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response('{}')); + global.fetch = fetchMock; + + const agent = { marker: 'proxy-agent' }; + + await providerFetch(URL_UNDER_TEST, { + method: 'POST', + headers: { 'api-key': 'secret' }, + body: '{"a":1}', + agent, + } as RequestInit); + + const init = fetchMock.mock.calls[0][1]; + + expect(init).toMatchObject({ + method: 'POST', + headers: { 'api-key': 'secret' }, + body: '{"a":1}', + agent, + }); + }); + + test('composes a caller supplied signal rather than replacing it', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response('{}')); + global.fetch = fetchMock; + + const controller = new AbortController(); + + await providerFetch(URL_UNDER_TEST, { signal: controller.signal }); + + const { signal } = fetchMock.mock.calls[0][1]; + + expect(signal).not.toBe(controller.signal); + expect(signal.aborted).toBe(false); + + controller.abort(new Error('caller cancelled')); + + expect(signal.aborted).toBe(true); + }); + + test('aborts a request that outlives the timeout', async () => { + global.fetch = vi.fn( + (_input, init: RequestInit = {}) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => reject((init.signal as AbortSignal).reason), { once: true }); + }) + ) as typeof fetch; + + await expect(providerFetch(URL_UNDER_TEST, {}, { timeoutMs: 20 })).rejects.toMatchObject({ + name: 'TimeoutError', + }); + }); + + test('returns a response that arrives inside the timeout', async () => { + global.fetch = vi.fn().mockResolvedValue(new Response('{"id":"message-id"}')); + + const response = await providerFetch(URL_UNDER_TEST, {}, { timeoutMs: 5_000 }); + + await expect(response.json()).resolves.toEqual({ id: 'message-id' }); + }); + + test('does not reject on a non-2xx response, matching fetch semantics', async () => { + global.fetch = vi.fn().mockResolvedValue(new Response('nope', { status: 500 })); + + const response = await providerFetch(URL_UNDER_TEST); + + expect(response.status).toBe(500); + }); + + test('never sends a request twice', async () => { + const fetchMock = vi.fn().mockRejectedValue(Object.assign(new Error('refused'), { code: 'ECONNREFUSED' })); + global.fetch = fetchMock; + + await expect(providerFetch(URL_UNDER_TEST)).rejects.toThrow('refused'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/providers/src/utils/http/provider-fetch.ts b/packages/providers/src/utils/http/provider-fetch.ts new file mode 100644 index 00000000000..7c59bef75b9 --- /dev/null +++ b/packages/providers/src/utils/http/provider-fetch.ts @@ -0,0 +1,27 @@ +import { PROVIDER_HTTP_TIMEOUT_MS } from './provider-http.constants'; + +export interface ProviderFetchOptions { + /** Overrides {@link PROVIDER_HTTP_TIMEOUT_MS} for providers that need a different cap. */ + timeoutMs?: number; +} + +/** + * The `fetch` equivalent of {@link createProviderHttpClient}, for the handful of + * providers whose transport is `fetch` rather than axios. + * + * `init` is forwarded untouched apart from `signal`, so non-standard options that + * individual providers rely on survive. A caller-supplied `signal` is composed with + * the timeout signal rather than replaced. + * + * Engines are Node 22, which has `AbortSignal.any` and `AbortSignal.timeout`. + */ +export const providerFetch = async ( + input: RequestInfo | URL, + init: RequestInit = {}, + options: ProviderFetchOptions = {} +): Promise => { + const timeoutSignal = AbortSignal.timeout(options.timeoutMs ?? PROVIDER_HTTP_TIMEOUT_MS); + const signal = init.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal; + + return fetch(input, { ...init, signal }); +}; diff --git a/packages/providers/src/utils/http/provider-http.client.spec.ts b/packages/providers/src/utils/http/provider-http.client.spec.ts new file mode 100644 index 00000000000..067c7ded4ff --- /dev/null +++ b/packages/providers/src/utils/http/provider-http.client.spec.ts @@ -0,0 +1,76 @@ +import nock from 'nock'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { createProviderHttpClient } from './provider-http.client'; +import { PROVIDER_HTTP_TIMEOUT_MS } from './provider-http.constants'; + +const BASE_URL = 'https://provider.example.com'; + +describe('createProviderHttpClient', () => { + beforeEach(() => { + nock.cleanAll(); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + test('applies the default timeout when none is supplied', () => { + const client = createProviderHttpClient({ baseURL: BASE_URL }); + + expect(client.defaults.timeout).toBe(PROVIDER_HTTP_TIMEOUT_MS); + }); + + test('honours a per-provider timeoutMs override', () => { + const client = createProviderHttpClient({ baseURL: BASE_URL, timeoutMs: 5_000 }); + + expect(client.defaults.timeout).toBe(5_000); + }); + + test('does not forward timeoutMs to axios', () => { + const client = createProviderHttpClient({ + baseURL: BASE_URL, + timeoutMs: 5_000, + }); + + expect(client.defaults).not.toHaveProperty('timeoutMs'); + }); + + test('preserves caller supplied config', () => { + const client = createProviderHttpClient({ + baseURL: BASE_URL, + headers: { 'api-key': 'secret' }, + }); + + expect(client.defaults.baseURL).toBe(BASE_URL); + expect(client.defaults.headers['api-key']).toBe('secret'); + }); + + test('aborts a request that outlives the timeout', async () => { + nock(BASE_URL).post('/send').delay(200).reply(200, { id: 'never-arrives' }); + + const client = createProviderHttpClient({ baseURL: BASE_URL, timeoutMs: 50 }); + + await expect(client.post('/send', {})).rejects.toMatchObject({ code: 'ECONNABORTED' }); + }); + + test('lets a response inside the timeout through untouched', async () => { + nock(BASE_URL).post('/send').reply(200, { id: 'message-id' }); + + const client = createProviderHttpClient({ baseURL: BASE_URL, timeoutMs: 5_000 }); + + const response = await client.post('/send', {}); + + expect(response.data).toEqual({ id: 'message-id' }); + }); + + test('never sends a request twice', async () => { + const scope = nock(BASE_URL).post('/send').reply(503, {}); + + const client = createProviderHttpClient({ baseURL: BASE_URL }); + + await expect(client.post('/send', {})).rejects.toThrow(); + + expect(scope.isDone()).toBe(true); + expect(nock.pendingMocks()).toHaveLength(0); + }); +}); diff --git a/packages/providers/src/utils/http/provider-http.client.ts b/packages/providers/src/utils/http/provider-http.client.ts new file mode 100644 index 00000000000..da1ba5d9ee1 --- /dev/null +++ b/packages/providers/src/utils/http/provider-http.client.ts @@ -0,0 +1,23 @@ +import axios, { AxiosInstance, CreateAxiosDefaults } from 'axios'; +import { PROVIDER_HTTP_TIMEOUT_MS } from './provider-http.constants'; + +export interface ProviderHttpClientOptions extends CreateAxiosDefaults { + /** Overrides {@link PROVIDER_HTTP_TIMEOUT_MS} for providers that need a different cap. */ + timeoutMs?: number; +} + +/** + * Creates the axios instance every provider should use for outbound calls. + * + * The only behavior it adds over `axios.create` is a mandatory request timeout. + * It deliberately does not retry: a provider send is not idempotent, so a second + * attempt risks delivering the same message twice. + */ +export const createProviderHttpClient = (options: ProviderHttpClientOptions = {}): AxiosInstance => { + const { timeoutMs, ...axiosConfig } = options; + + return axios.create({ + ...axiosConfig, + timeout: timeoutMs ?? axiosConfig.timeout ?? PROVIDER_HTTP_TIMEOUT_MS, + }); +}; diff --git a/packages/providers/src/utils/http/provider-http.constants.spec.ts b/packages/providers/src/utils/http/provider-http.constants.spec.ts new file mode 100644 index 00000000000..c926c72d75e --- /dev/null +++ b/packages/providers/src/utils/http/provider-http.constants.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from 'vitest'; +import { + DEFAULT_PROVIDER_HTTP_TIMEOUT_MS, + PROVIDER_HTTP_TIMEOUT_MS, + PROVIDER_HTTP_TIMEOUT_MS_ENV_VAR, + resolveProviderHttpTimeoutMs, +} from './provider-http.constants'; + +describe('provider HTTP timeout', () => { + test('defaults to two minutes', () => { + expect(DEFAULT_PROVIDER_HTTP_TIMEOUT_MS).toBe(120_000); + expect(PROVIDER_HTTP_TIMEOUT_MS).toBe(DEFAULT_PROVIDER_HTTP_TIMEOUT_MS); + }); + + test(`honours ${PROVIDER_HTTP_TIMEOUT_MS_ENV_VAR}`, () => { + expect(resolveProviderHttpTimeoutMs({ [PROVIDER_HTTP_TIMEOUT_MS_ENV_VAR]: '45000' })).toBe(45_000); + }); + + test.each([ + ['not a number', 'abc'], + ['zero', '0'], + ['negative', '-1'], + ['a fraction', '1.5'], + ['above the Node timer limit', '2147483648'], + ['empty', ''], + ['unset', undefined], + ])('falls back to the default when the override is %s', (_label, value) => { + expect(resolveProviderHttpTimeoutMs({ [PROVIDER_HTTP_TIMEOUT_MS_ENV_VAR]: value })).toBe( + DEFAULT_PROVIDER_HTTP_TIMEOUT_MS + ); + }); + + test('does not throw when no environment is available', () => { + expect(resolveProviderHttpTimeoutMs(undefined)).toBe(DEFAULT_PROVIDER_HTTP_TIMEOUT_MS); + }); +}); diff --git a/packages/providers/src/utils/http/provider-http.constants.ts b/packages/providers/src/utils/http/provider-http.constants.ts new file mode 100644 index 00000000000..9d524a60d35 --- /dev/null +++ b/packages/providers/src/utils/http/provider-http.constants.ts @@ -0,0 +1,50 @@ +/** + * Upper bound on a single outbound provider HTTP request. + * + * Axios treats an omitted `timeout` as `0`, meaning "wait forever", which lets an + * unresponsive provider API hold a worker slot indefinitely. The queue layers do not + * rescue us here: BullMQ renews the job lock and the SQS consumer extends message + * visibility for as long as the handler is awaiting, so a hung request is never + * reclaimed on its own. + * + * 120s sits far above any plausible provider latency, so it cannot fail a send that + * would otherwise have succeeded, while still bounding the hang. + */ +export const DEFAULT_PROVIDER_HTTP_TIMEOUT_MS = 120_000; + +export const PROVIDER_HTTP_TIMEOUT_MS_ENV_VAR = 'NOVU_PROVIDER_HTTP_TIMEOUT_MS'; + +/** + * `AbortSignal.timeout` accepts up to 2^32-1, but Node's timer is a signed 32-bit + * integer. Values above this overflow to 1ms, which would fail every fetch provider + * immediately rather than hang. + */ +const MAX_PROVIDER_HTTP_TIMEOUT_MS = 2_147_483_647; + +/** + * Exported for testing; production code should read {@link PROVIDER_HTTP_TIMEOUT_MS}, + * which is resolved once at module load and therefore cannot be reconfigured at runtime. + */ +export const resolveProviderHttpTimeoutMs = (env: Record | undefined): number => { + const raw = env?.[PROVIDER_HTTP_TIMEOUT_MS_ENV_VAR]; + + if (!raw) { + return DEFAULT_PROVIDER_HTTP_TIMEOUT_MS; + } + + const parsed = Number(raw); + + if (!Number.isInteger(parsed) || parsed <= 0 || parsed > MAX_PROVIDER_HTTP_TIMEOUT_MS) { + return DEFAULT_PROVIDER_HTTP_TIMEOUT_MS; + } + + return parsed; +}; + +/** + * `@novu/providers` is published and bundled for non-Node consumers, so `process` + * cannot be assumed to exist. + */ +export const PROVIDER_HTTP_TIMEOUT_MS = resolveProviderHttpTimeoutMs( + typeof process === 'undefined' ? undefined : process.env +); diff --git a/packages/shared/src/consts/providers/mcp-servers.spec.ts b/packages/shared/src/consts/providers/mcp-servers.spec.ts index 0eda23da4de..c40d4e132c3 100644 --- a/packages/shared/src/consts/providers/mcp-servers.spec.ts +++ b/packages/shared/src/consts/providers/mcp-servers.spec.ts @@ -77,6 +77,20 @@ describe('MCP_SERVERS catalog', () => { } }); + // Both advertise a `registration_endpoint` but gate the redirect URI behind an + // allowlist, so a DCR client Novu registers never completes authorize. Pinned + // here because a `registration_endpoint` probe alone would flip them back. + it('keeps allowlist-gated MCPs off dcr', () => { + const allowlistGatedIds = ['new-relic', 'canva']; + + for (const id of allowlistGatedIds) { + const entry = MCP_SERVERS.find((server) => server.id === id); + + expect(entry).toBeDefined(); + expect(entry?.oauth?.mode).toBe(McpConnectionAuthModeEnum.ProviderManaged); + } + }); + it('uses unique catalog ids and urls', () => { const ids = MCP_SERVERS.map((entry) => entry.id); const urls = MCP_SERVERS.map((entry) => entry.url); diff --git a/packages/shared/src/consts/providers/mcp-servers.ts b/packages/shared/src/consts/providers/mcp-servers.ts index e6a0fa86196..6e6bc079456 100644 --- a/packages/shared/src/consts/providers/mcp-servers.ts +++ b/packages/shared/src/consts/providers/mcp-servers.ts @@ -326,7 +326,7 @@ export const MCP_SERVERS: McpServer[] = [ url: 'https://mcp.newrelic.com/mcp/', category: 'code', popular: true, - oauth: { mode: McpConnectionAuthModeEnum.Dcr }, + oauth: { mode: McpConnectionAuthModeEnum.ProviderManaged }, }, { id: 'pagerduty', @@ -409,7 +409,7 @@ export const MCP_SERVERS: McpServer[] = [ url: 'https://mcp.canva.com/mcp', category: 'design', popular: false, - oauth: { mode: McpConnectionAuthModeEnum.Dcr }, + oauth: { mode: McpConnectionAuthModeEnum.ProviderManaged }, }, { id: 'cloudflare', diff --git a/packages/shared/src/dto/integration/construct-integration.interface.ts b/packages/shared/src/dto/integration/construct-integration.interface.ts index 8b3cc2c02ff..46f2ab7f3d7 100644 --- a/packages/shared/src/dto/integration/construct-integration.interface.ts +++ b/packages/shared/src/dto/integration/construct-integration.interface.ts @@ -11,10 +11,12 @@ export interface IConstructIntegrationDto { credentials?: ICredentialsDto; active?: boolean; check?: boolean; + /** @deprecated Use `rules` (JSONLogic) instead. */ conditions?: { isNegated?: boolean; type?: BuilderFieldType; value?: BuilderGroupValues; children?: FilterParts[]; }[]; + rules?: Record | null; } diff --git a/packages/shared/src/entities/integration/integration.interface.ts b/packages/shared/src/entities/integration/integration.interface.ts index 98573315a76..39ffd8b9b56 100644 --- a/packages/shared/src/entities/integration/integration.interface.ts +++ b/packages/shared/src/entities/integration/integration.interface.ts @@ -1,13 +1,26 @@ import { + BuilderFieldType, + BuilderGroupValues, ChannelTypeEnum, EnvironmentId, + FilterParts, IntegrationKindEnum, - IPreviousStepFilterPart, OrganizationId, } from '../../types'; import { IConfigurations } from './configuration.interface'; import { ICredentials } from './credential.interface'; +/** + * Structurally identical to `IMessageFilter`, redeclared here to keep the integration + * entity free of a cyclic import on the notification-template entity. + */ +export interface IIntegrationFilter { + isNegated?: boolean; + type?: BuilderFieldType; + value: BuilderGroupValues; + children: FilterParts[]; +} + export interface IIntegration { _id: string; @@ -42,7 +55,10 @@ export interface IIntegration { deletedBy: string; - conditions?: IPreviousStepFilterPart[]; + /** @deprecated Use `rules` (JSONLogic) instead. */ + conditions?: IIntegrationFilter[]; + + rules?: Record | null; connected?: boolean; } diff --git a/playground/web-chat/README.md b/playground/web-chat/README.md index 2c64404241c..fe4bf3895fc 100644 --- a/playground/web-chat/README.md +++ b/playground/web-chat/README.md @@ -1,16 +1,25 @@ # useWebChat playground -Minimal Next.js app that shows how to wire headless `useWebChat` from `@novu/react` to your own UI. +Minimal Next.js app that wires headless `useWebChat` from `@novu/react` to an assistant-ui Thread (`ExternalStoreRuntime`). ## Where to look | File | Role | | --- | --- | -| [`src/components/web-chat.tsx`](src/components/web-chat.tsx) | **The example.** Calls `useWebChat` and passes results into UI. | -| `src/components/chat-panel.tsx` | Composer + thread wiring (playground UI). | +| [`src/components/web-chat.tsx`](src/components/web-chat.tsx) | **The example.** Calls `useWebChat`; optional `onEvent` banner for `run-error`. | +| [`src/components/chat-panel.tsx`](src/components/chat-panel.tsx) | Recovery UI: `isRecovering` status + `catchUpError` with `refetch` retry (separate from send `error`). | +| `src/components/assistant-ui/web-chat-runtime.tsx` | ExternalStoreRuntime wiring (`onNew`, `onRespondToToolApproval`). | +| `src/lib/agent-message-to-thread-message.ts` | AgentMessage → ThreadMessageLike converter. User rows use `idempotencyKey ?? id` so assistant-ui identity stays stable when Novu swaps `opt_*` → `msg_*`; actual Novu `message.id` lives in `metadata.custom.novuMessageId` for retry. No unit test here — this package has no test runner (only `dev` / `build` / `start`); identity mapping is covered in `packages/js/src/web-chat/web-chat.test.ts`. | +| `src/components/assistant-ui/novu-parts.tsx` | Novu data UI (card, MCP, file) + approval tool fallback. | +| `src/components/assistant-ui/elements/thread.aui.tsx` | Official assistant-ui registry Thread (turn-anchor, grouped parts). | +| `src/components/assistant-ui/thread.tsx` | Novu adapter: slot overrides, pagination scroll preservation, typing. | | `src/app/playground.tsx` | Provider + session switching (playground shell). | -Copy the pattern in `web-chat.tsx`. Treat everything under the sidebar / debug panel as playground tooling, not part of the integration recipe. +Copy the hook wiring in `web-chat.tsx`. Treat the sidebar / debug panel as playground tooling. + +**Public recovery:** After reconnect, `isRecovering` shows a non-blocking “Syncing missed messages…” banner. If catch-up fails, `catchUpError` appears with a **Reload conversation** button that calls `refetch()` — this is separate from the session `error` banner. + +**MCP connect:** Pending MCP cards open `authorizeUrlWithAutoApprove` when the server provides it (auto-approve OAuth path), otherwise `authorizeUrl`. No SDK action exists for connect; the browser link is the supported path. ## Run locally @@ -26,14 +35,3 @@ pnpm --filter web-chat dev ``` Open http://localhost:4012. - -## Hook surface (minimal) - -| Call | Purpose | -| --- | --- | -| `useWebChat({ agentId })` | send + optimistic user messages | -| `sendMessage(text)` | user turn | -| `respondToAction(...)` | tool approval cards | -| `messages`, `pendingActions` | render thread | - -See `@novu/react` docs for the full result shape. diff --git a/playground/web-chat/components.json b/playground/web-chat/components.json new file mode 100644 index 00000000000..8d886db5756 --- /dev/null +++ b/playground/web-chat/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/playground/web-chat/next.config.mjs b/playground/web-chat/next.config.mjs index c670f8a3de5..80ca56be033 100644 --- a/playground/web-chat/next.config.mjs +++ b/playground/web-chat/next.config.mjs @@ -9,7 +9,7 @@ const novuJs = path.join(monorepoRoot, 'packages/js'); /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, - transpilePackages: ['@novu/react', '@novu/js'], + transpilePackages: ['@novu/react', '@novu/js', '@assistant-ui/react'], // Turbopack uses the monorepo root as project root for this workspace app, // so playground-local node_modules links are invisible without aliases. // Absolute alias targets are treated as relative (`./Users/...`) — use repo-relative paths. diff --git a/playground/web-chat/package.json b/playground/web-chat/package.json index 86c2338c53a..c52082b240a 100644 --- a/playground/web-chat/package.json +++ b/playground/web-chat/package.json @@ -8,17 +8,29 @@ "start": "next start -p 4012" }, "dependencies": { + "@assistant-ui/react": "^0.15.16", + "@assistant-ui/react-markdown": "^0.14.12", + "@base-ui/react": "^1.7.0", "@novu/react": "workspace:*", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.34.0", "next": "^16.2.11", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", - "remark-gfm": "^4.0.1" + "remark-gfm": "^4.0.1", + "shadcn": "^4.19.0", + "tailwind-merge": "^3.6.0", + "tw-animate-css": "^1.4.0", + "tw-shimmer": "^0.4.12" }, "devDependencies": { + "@tailwindcss/postcss": "^4.3.3", "@types/node": "^22.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", + "tailwindcss": "^4.3.3", "typescript": "5.6.2" } } diff --git a/playground/web-chat/postcss.config.mjs b/playground/web-chat/postcss.config.mjs new file mode 100644 index 00000000000..a34a3d560dc --- /dev/null +++ b/playground/web-chat/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; diff --git a/playground/web-chat/src/app/globals.css b/playground/web-chat/src/app/globals.css index d23152699cd..d3416bc49ec 100644 --- a/playground/web-chat/src/app/globals.css +++ b/playground/web-chat/src/app/globals.css @@ -1,3 +1,10 @@ +@import 'tailwindcss'; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; +@import "tw-shimmer"; + +@custom-variant dark (&:is(.dark *)); + /* ------------------------------------------------------------------ */ /* Web Chat playground — single coherent theme */ /* Novu dashboard DNA: quiet neutrals, one red accent, open canvas. */ @@ -19,10 +26,12 @@ --ink: #0e121b; --ink-2: #525866; --ink-3: #717784; - --muted: #99a0ae; + --ink-muted: #99a0ae; + --muted: oklch(0.97 0 0); /* brand */ - --accent: #dd2450; + --brand: #dd2450; + --accent: oklch(0.97 0 0); --accent-hover: #c41e46; --accent-soft: rgba(221, 36, 80, 0.07); --accent-line: rgba(221, 36, 80, 0.18); @@ -46,7 +55,7 @@ --radius-xl: 22px; --radius-lg: 16px; - --radius: 12px; + --radius: 0.625rem; --radius-sm: 8px; --shadow-float: @@ -61,6 +70,35 @@ --font-sans: var(--font-geist), ui-sans-serif, system-ui, sans-serif; --font-mono: var(--font-geist-mono), ui-monospace, 'SF Mono', Menlo, monospace; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); } * { @@ -93,6 +131,14 @@ button, input, textarea { font: inherit; +} + +button:not([data-slot='button']):not([data-slot='tooltip-trigger']) { + color: inherit; +} + +input, +textarea { color: inherit; } @@ -125,6 +171,10 @@ a:focus-visible { border-radius: 4px; } +textarea.aui-composer-input:focus-visible { + outline: none; +} + /* ------------------------------------------------------------------ */ /* Shell */ /* ------------------------------------------------------------------ */ @@ -189,153 +239,24 @@ a:focus-visible { padding: 0 8px 12px; } -.sidebar-section { - padding: 8px 8px 14px; -} - -.sidebar-section + .sidebar-section { - border-top: 1px solid var(--stroke-soft); - padding-top: 14px; -} - -.sidebar-section-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; -} - -.sidebar-section-title { - margin: 0 0 8px; - padding: 0 4px; - font-size: 10px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.07em; - color: var(--muted); -} - -.sidebar-section-head .sidebar-section-title { - margin-bottom: 8px; -} - .hint { margin: 0; padding: 0 4px; font-size: 11px; line-height: 1.5; - color: var(--muted); + color: var(--ink-muted); } .hint-error { color: var(--danger); } -/* conversation list */ - -.conv-list { - list-style: none; - margin: 0; - padding: 0; - display: grid; - gap: 1px; -} - -.conv-item { - width: 100%; +.thread-list-status { display: flex; align-items: center; - gap: 9px; - min-height: 40px; - padding: 6px 8px; - border: 0; - border-radius: var(--radius-sm); - background: transparent; - text-align: left; - transition: background 140ms var(--ease-out); -} - -.conv-item:hover { - background: rgba(14, 18, 27, 0.04); -} - -.conv-item[data-active='true'] { - background: #ffffff; - box-shadow: var(--shadow-card), inset 0 0 0 1px var(--stroke-soft); -} - -.conv-glyph { - display: grid; - place-items: center; - flex-shrink: 0; - width: 26px; - height: 26px; - border-radius: 7px; - border: 1px solid var(--stroke-soft); - background: var(--surface); - color: var(--muted); -} - -.conv-item[data-active='true'] .conv-glyph { - color: var(--accent); - border-color: var(--accent-line); - background: var(--accent-soft); -} - -.conv-body { - min-width: 0; - display: grid; - gap: 1px; -} - -.conv-title { - font-size: 12px; - line-height: 16px; - color: var(--ink-2); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.conv-item[data-active='true'] .conv-title { - color: var(--ink); - font-weight: 500; -} - -.conv-sub { - font-size: 10px; - color: var(--muted); -} - -/* resume form */ - -.resume-form { - display: grid; - gap: 7px; -} - -.text-input { - width: 100%; - min-width: 0; - min-height: 34px; - padding: 7px 10px; - border: 1px solid var(--stroke); - border-radius: var(--radius-sm); - background: var(--surface); - font-size: 12px; - color: var(--ink); - box-shadow: var(--shadow-card); - transition: border-color 140ms var(--ease-out), box-shadow 140ms var(--ease-out); -} - -.text-input::placeholder { - color: var(--muted); -} - -.text-input:focus { - outline: none; - border-color: var(--accent-line); - box-shadow: 0 0 0 3px var(--accent-soft); + justify-content: space-between; + gap: 8px; + padding: 8px 8px 0; } /* buttons */ @@ -387,7 +308,7 @@ a:focus-visible { min-height: 26px; padding: 4px 8px; background: transparent; - color: var(--muted); + color: var(--ink-muted); font-size: 11px; } @@ -412,7 +333,7 @@ a:focus-visible { margin-bottom: 4px; font-size: 10px; font-weight: 500; - color: var(--muted); + color: var(--ink-muted); } .meta-row dd { @@ -449,7 +370,7 @@ a:focus-visible { .copy-value .copy-icon { flex-shrink: 0; - color: var(--muted); + color: var(--ink-muted); opacity: 0; transition: opacity 140ms var(--ease-out); } @@ -464,7 +385,7 @@ a:focus-visible { } .copy-value .value-empty { - color: var(--muted); + color: var(--ink-muted); font-style: italic; } @@ -498,7 +419,7 @@ a:focus-visible { margin-top: 4px; font-family: var(--font-mono); font-size: 9px; - color: var(--muted); + color: var(--ink-muted); } /* ------------------------------------------------------------------ */ @@ -556,7 +477,7 @@ a:focus-visible { gap: 6px; font-size: 10px; font-weight: 500; - color: var(--muted); + color: var(--ink-muted); text-transform: capitalize; } @@ -622,7 +543,7 @@ a:focus-visible { font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; - color: var(--muted); + color: var(--ink-muted); } .session-stats { @@ -643,7 +564,7 @@ a:focus-visible { .session-stat dt { margin-bottom: 3px; font-size: 9px; - color: var(--muted); + color: var(--ink-muted); } .session-stat dd { @@ -660,7 +581,7 @@ a:focus-visible { margin: 8px 2px 0; font-size: 9.5px; line-height: 1.45; - color: var(--muted); + color: var(--ink-muted); } /* ------------------------------------------------------------------ */ @@ -673,10 +594,7 @@ a:focus-visible { flex-direction: column; min-width: 0; min-height: 0; - background: var(--pane); - border: 1px solid var(--stroke-soft); - border-radius: var(--radius-xl); - box-shadow: var(--shadow-pane); + background: var(--background); overflow: hidden; } @@ -701,216 +619,11 @@ a:focus-visible { overflow: hidden; max-width: 260px; font-size: 10px; - color: var(--muted); + color: var(--ink-muted); text-overflow: ellipsis; white-space: nowrap; } -.banner-error { - flex-shrink: 0; - width: min(100% - 48px, 680px); - margin: 0 auto 8px; - padding: 9px 12px; - border: 1px solid var(--danger-line); - border-radius: var(--radius-sm); - background: var(--danger-soft); - font-size: 12px; - color: #a52626; -} - -.thread { - flex: 1; - min-height: 0; - overflow-y: auto; - padding: 8px 24px 0; - display: flex; - flex-direction: column; - scroll-behavior: smooth; -} - -.thread-inner { - width: min(100%, 680px); - margin: 0 auto; - display: flex; - flex-direction: column; - gap: 20px; - flex: 1; - padding-bottom: 24px; -} - -/* older-history pager */ - -.thread-older { - display: flex; - justify-content: center; -} - -.thread-older-btn { - display: inline-flex; - align-items: center; - gap: 6px; - min-height: 28px; - padding: 4px 12px; - border: 1px solid var(--stroke-soft); - border-radius: 999px; - background: var(--surface); - color: var(--ink-3); - font-size: 11px; - box-shadow: var(--shadow-card); - transition: border-color 140ms var(--ease-out), color 140ms var(--ease-out); -} - -.thread-older-btn:hover:not(:disabled) { - color: var(--ink); - border-color: var(--stroke); -} - -/* empty state */ - -.thread-empty { - margin: auto; - max-width: 22rem; - text-align: center; - padding-bottom: 3rem; -} - -.thread-empty-glyph { - width: 44px; - height: 44px; - margin: 0 auto 14px; - border-radius: 14px; - background: var(--spark-gradient); - display: grid; - place-items: center; - color: #ffffff; - box-shadow: 0 6px 18px rgba(221, 36, 80, 0.22); -} - -.thread-empty-title { - margin: 0 0 5px; - font-size: 15px; - font-weight: 600; - letter-spacing: -0.015em; - color: var(--ink); -} - -.thread-empty-copy { - margin: 0; - font-size: 12px; - line-height: 1.55; - color: var(--muted); -} - -.thread-empty-copy code { - color: var(--ink-2); -} - -/* ------------------------------------------------------------------ */ -/* Messages */ -/* ------------------------------------------------------------------ */ - -.msg { - display: flex; - align-items: flex-start; - gap: 10px; - animation: msg-in 260ms var(--ease-out); -} - -@keyframes msg-in { - from { - opacity: 0; - transform: translateY(5px); - } -} - -.msg-avatar { - width: 26px; - height: 26px; - flex-shrink: 0; - margin-top: 1px; - border-radius: 9px; - display: grid; - place-items: center; - background: var(--spark-gradient); - color: #ffffff; - box-shadow: 0 2px 8px rgba(221, 36, 80, 0.18); -} - -.msg-content { - min-width: 0; - display: flex; - flex-direction: column; - gap: 6px; -} - -/* assistant: plain text on canvas — no bubble */ - -.msg-assistant .msg-content { - flex: 1; - padding-top: 3px; -} - -/* user: quiet neutral bubble, right aligned, no avatar */ - -.msg-user { - justify-content: flex-end; -} - -.msg-user .msg-content { - max-width: min(85%, 30rem); - align-items: flex-end; -} - -.msg-user .msg-body { - padding: 8px 13px 9px; - border-radius: 16px; - border-bottom-right-radius: 6px; - background: var(--surface-weak); - border: 1px solid var(--stroke-soft); - color: var(--ink); -} - -.msg-body { - display: grid; - gap: 8px; - font-size: 13.5px; - line-height: 1.6; -} - -.msg-meta { - display: flex; - align-items: baseline; - gap: 8px; - padding: 0 2px; - font-size: 10px; - color: var(--muted); - opacity: 0; - transition: opacity 160ms var(--ease-out); -} - -.msg:hover .msg-meta, -.msg[data-status='failed'] .msg-meta, -.msg[data-status='sending'] .msg-meta { - opacity: 1; -} - -.msg-status[data-status='failed'] { - color: var(--danger); -} - -.msg-status[data-status='sending'] { - color: var(--ink-3); -} - -.msg[data-status='sending'] .msg-body { - opacity: 0.6; -} - -.msg[data-status='failed'] .msg-body { - border-color: var(--danger-line); - background: var(--danger-soft); -} - /* message parts */ .part { @@ -940,7 +653,7 @@ a:focus-visible { } .md a { - color: var(--accent); + color: var(--brand); font-weight: 500; text-decoration: underline; text-decoration-color: var(--accent-line); @@ -949,7 +662,7 @@ a:focus-visible { } .md a:hover { - text-decoration-color: var(--accent); + text-decoration-color: var(--brand); } .md code { @@ -1070,7 +783,7 @@ a:focus-visible { margin-left: 2px; border-radius: 2px; vertical-align: text-bottom; - background: var(--accent); + background: var(--brand); animation: blink 1s step-end infinite; } @@ -1106,7 +819,7 @@ a:focus-visible { font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; - color: var(--muted); + color: var(--ink-muted); } .part-badge { @@ -1116,7 +829,7 @@ a:focus-visible { border-radius: 999px; background: var(--surface); font-size: 9px; - color: var(--muted); + color: var(--ink-muted); } .part-badge[data-state='streaming'], @@ -1144,52 +857,13 @@ a:focus-visible { } .part-muted { - color: var(--muted); -} - -/* ------------------------------------------------------------------ */ -/* Typing — one shimmering status line, nothing else */ -/* ------------------------------------------------------------------ */ - -.typing-row { - display: flex; - align-items: center; - gap: 10px; - min-height: 26px; - animation: msg-in 260ms var(--ease-out); -} - -.typing-shimmer { - font-size: 12.5px; - font-weight: 500; - color: transparent; - background: linear-gradient(90deg, var(--muted) 0%, var(--muted) 35%, var(--ink) 50%, var(--muted) 65%, var(--muted) 100%); - background-size: 220% 100%; - -webkit-background-clip: text; - background-clip: text; - animation: shimmer 1.8s linear infinite; -} - -@keyframes shimmer { - from { - background-position: 130% 0; - } - to { - background-position: -90% 0; - } + color: var(--ink-muted); } /* ------------------------------------------------------------------ */ /* Action cards (approvals + connections) */ /* ------------------------------------------------------------------ */ -.message-actions { - display: grid; - gap: 10px; - padding-left: 36px; - animation: msg-in 300ms var(--ease-out); -} - .action-card { overflow: hidden; border: 1px solid var(--stroke); @@ -1227,7 +901,7 @@ a:focus-visible { .action-kicker { font-size: 10px; font-weight: 500; - color: var(--muted); + color: var(--ink-muted); } .action-title { @@ -1327,7 +1001,7 @@ a:focus-visible { .action-args-chevron { transition: transform 160ms var(--ease-out); - color: var(--muted); + color: var(--ink-muted); } .action-args[open] .action-args-chevron { @@ -1340,7 +1014,7 @@ a:focus-visible { border: 1px solid var(--stroke-soft); border-radius: 999px; font-size: 9.5px; - color: var(--muted); + color: var(--ink-muted); } .action-args-body { @@ -1369,7 +1043,7 @@ a:focus-visible { font-family: var(--font-mono); font-size: 10.5px; line-height: 17px; - color: var(--muted); + color: var(--ink-muted); text-overflow: ellipsis; } @@ -1406,7 +1080,7 @@ a:focus-visible { margin: 0; padding: 0 14px 12px; font-size: 11px; - color: var(--muted); + color: var(--ink-muted); } .action-failure { @@ -1435,7 +1109,7 @@ a:focus-visible { margin-right: auto; overflow: hidden; font-size: 9.5px; - color: var(--muted); + color: var(--ink-muted); text-overflow: ellipsis; white-space: nowrap; } @@ -1510,162 +1184,6 @@ a:focus-visible { background: var(--danger); } -/* ------------------------------------------------------------------ */ -/* Chat footer: dock + composer float over a soft fade */ -/* ------------------------------------------------------------------ */ - -.chat-foot { - flex-shrink: 0; - display: grid; - gap: 10px; - padding: 0 24px 18px; - background: linear-gradient(to top, var(--pane) 78%, transparent); -} - -.approval-dock { - display: flex; - align-items: center; - gap: 9px; - width: min(100%, 680px); - margin: 0 auto; - padding: 8px 9px 8px 13px; - border: 1px solid var(--warn-line); - border-radius: var(--radius); - background: var(--warn-soft); - animation: msg-in 240ms var(--ease-out); -} - -.approval-dock-glyph { - display: grid; - place-items: center; - flex-shrink: 0; - color: var(--warn); -} - -.approval-dock-copy { - flex: 1; - min-width: 0; - overflow: hidden; - font-size: 11.5px; - color: var(--ink-2); - text-overflow: ellipsis; - white-space: nowrap; -} - -.approval-dock-copy strong { - font-weight: 600; - color: var(--ink); -} - -.approval-dock-copy code { - color: var(--warn); -} - -.approval-dock-btn { - flex-shrink: 0; - min-height: 28px; - padding: 4px 12px; - border: 0; - border-radius: var(--radius-sm); - background: var(--ink); - color: #ffffff; - font-size: 11px; - font-weight: 550; - transition: background 140ms var(--ease-out); -} - -.approval-dock-btn:hover { - background: #23293a; -} - -/* composer */ - -.composer { - width: min(100%, 680px); - margin: 0 auto; -} - -.composer-box { - display: flex; - align-items: flex-end; - gap: 8px; - padding: 9px 9px 9px 16px; - border: 1px solid var(--stroke); - border-radius: var(--radius-lg); - background: var(--surface); - box-shadow: var(--shadow-float); - transition: border-color 160ms var(--ease-out), box-shadow 160ms var(--ease-out); -} - -.composer-box:focus-within { - border-color: #c9cfd9; - box-shadow: - 0 1px 2px rgba(14, 18, 27, 0.04), - 0 12px 32px rgba(14, 18, 27, 0.09); -} - -.composer-box textarea { - flex: 1; - min-width: 0; - max-height: 10rem; - padding: 5px 0; - border: 0; - background: transparent; - resize: none; - font-size: 13.5px; - line-height: 1.5; - color: var(--ink); -} - -.composer-box textarea:focus { - outline: none; -} - -.composer-box textarea::placeholder { - color: var(--muted); -} - -.composer-send { - display: grid; - place-items: center; - flex-shrink: 0; - width: 34px; - height: 34px; - border: 0; - border-radius: 11px; - background: var(--ink); - color: #ffffff; - transition: background 140ms var(--ease-out), transform 100ms var(--ease-out); -} - -.composer-send:hover:not(:disabled) { - background: #23293a; -} - -.composer-send:active:not(:disabled) { - transform: scale(0.94); -} - -.composer-hints { - display: flex; - justify-content: space-between; - gap: 12px; - margin-top: 7px; - padding: 0 6px; - font-size: 10px; - color: var(--muted); -} - -.composer-hints kbd { - padding: 0 4px; - border: 1px solid var(--stroke-soft); - border-radius: 4px; - background: var(--surface-weak); - font-family: var(--font-mono); - font-size: 9px; - color: var(--ink-3); -} - /* ------------------------------------------------------------------ */ /* Spinner */ /* ------------------------------------------------------------------ */ @@ -1680,7 +1198,6 @@ a:focus-visible { animation: spin 700ms linear infinite; } -.composer-send .spinner, .action-btn-primary .spinner { border-color: rgba(255, 255, 255, 0.3); border-top-color: #ffffff; @@ -1742,7 +1259,7 @@ a:focus-visible { padding: 1px 5px; border-radius: 999px; background: var(--accent-soft); - color: var(--accent); + color: var(--brand); font-family: var(--font-mono); font-size: 9px; text-align: center; @@ -1785,7 +1302,7 @@ a:focus-visible { border: 1px solid transparent; border-radius: 999px; background: transparent; - color: var(--muted); + color: var(--ink-muted); font-size: 10px; font-weight: 500; text-transform: capitalize; @@ -1798,7 +1315,7 @@ a:focus-visible { } .filter.active { - color: var(--accent); + color: var(--brand); border-color: var(--accent-line); background: var(--accent-soft); } @@ -1820,7 +1337,7 @@ a:focus-visible { .debug-empty { padding: 2rem 1.25rem; - color: var(--muted); + color: var(--ink-muted); font-size: 11px; text-align: center; line-height: 1.5; @@ -1850,7 +1367,7 @@ a:focus-visible { flex-shrink: 0; font-family: var(--font-mono); font-size: 9px; - color: var(--muted); + color: var(--ink-muted); font-variant-numeric: tabular-nums; } @@ -1936,12 +1453,6 @@ a:focus-visible { border-radius: var(--radius-lg); } - .thread, - .chat-foot { - padding-left: 14px; - padding-right: 14px; - } - .chat-topbar { padding: 10px 14px; } @@ -1956,10 +1467,6 @@ a:focus-visible { bottom: 16px; } - .message-actions { - padding-left: 0; - } - .session-details { width: 180px; } @@ -1968,10 +1475,6 @@ a:focus-visible { width: min(300px, calc(100vw - 24px)); margin-left: calc(180px - min(300px, calc(100vw - 24px))); } - - .composer-hints > code { - display: none; - } } @media (prefers-reduced-motion: reduce) { @@ -1983,9 +1486,120 @@ a:focus-visible { animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } +} + +.aui-thread { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +@theme inline { + --font-heading: var(--font-sans); + --font-sans: var(--font-sans); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-foreground: var(--foreground); + --color-background: var(--background); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); + @keyframes collapsible-down { + from { + height: 0; + } + to { + height: var(--radix-collapsible-content-height, var(--collapsible-panel-height, auto)); + } + } + @keyframes collapsible-up { + from { + height: var(--radix-collapsible-content-height, var(--collapsible-panel-height, auto)); + } + to { + height: 0; + } + } +} - .typing-shimmer { - color: var(--ink-3); - background: none; +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; } } + +@custom-variant data-open (&:where([data-state="open"], [data-open]:not([data-open="false"]))); + +@custom-variant data-closed (&:where([data-state="closed"], [data-closed]:not([data-closed="false"]))); \ No newline at end of file diff --git a/playground/web-chat/src/app/layout.tsx b/playground/web-chat/src/app/layout.tsx index ed97f5bcbc9..c33d7d653ff 100644 --- a/playground/web-chat/src/app/layout.tsx +++ b/playground/web-chat/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from 'next'; import { Geist, Geist_Mono } from 'next/font/google'; +import { TooltipProvider } from '@/components/ui/tooltip'; import './globals.css'; const geist = Geist({ @@ -20,7 +21,9 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - {children} + + {children} + ); } diff --git a/playground/web-chat/src/app/playground.tsx b/playground/web-chat/src/app/playground.tsx index 57fc31b5680..d697f0559c5 100644 --- a/playground/web-chat/src/app/playground.tsx +++ b/playground/web-chat/src/app/playground.tsx @@ -40,6 +40,12 @@ function PlaygroundApp() { key={session.sessionKey} conversationId={session.conversationId} onAssistantMessage={reloadConversations} + threadList={{ + items: conversations.items, + isLoading: conversations.isLoading, + onSwitchToThread: session.onSelectConversation, + onSwitchToNewThread: session.onNewChat, + }} sidebar={(chat) => ( )} diff --git a/playground/web-chat/src/components/approval-card.tsx b/playground/web-chat/src/components/approval-card.tsx deleted file mode 100644 index 066e8a53eb0..00000000000 --- a/playground/web-chat/src/components/approval-card.tsx +++ /dev/null @@ -1,191 +0,0 @@ -'use client'; - -import type { AgentMessage, AgentToolApprovalDecision, UseWebChatResult } from '@novu/react'; -import { useState } from 'react'; -import { CheckIcon, ChevronIcon, ShieldIcon, XIcon } from './icons'; - -export type RespondToAction = UseWebChatResult['respondToAction']; -type AgentApprovalPart = Extract; - -type Decision = AgentToolApprovalDecision; - -const STATE_META: Record = { - pending: { label: 'Needs review', tone: 'pending' }, - approved: { label: 'Approved', tone: 'ok' }, - denied: { label: 'Denied', tone: 'danger' }, -}; - -export function approvalDomId(approvalId: string): string { - return `approval-${approvalId}`; -} - -function sourceLabel(source: AgentApprovalPart['source']): string | undefined { - if (!source) return undefined; - - return source.type === 'mcp' ? `MCP · ${source.serverName}` : source.type; -} - -function ArgValue({ value }: { value: unknown }) { - if (value === null || typeof value !== 'object') { - return {typeof value === 'string' ? value : JSON.stringify(value)}; - } - - return
{JSON.stringify(value, null, 2)}
; -} - -function ArgList({ input }: { input: Record }) { - return ( -
- {Object.entries(input).map(([key, value]) => ( -
-
{key}
-
- -
-
- ))} -
- ); -} - -type ApprovalCardProps = { - part: AgentApprovalPart; - onRespond?: RespondToAction; -}; - -export function ApprovalCard({ part, onRespond }: ApprovalCardProps) { - const [busy, setBusy] = useState(null); - const [failure, setFailure] = useState(); - - const isPending = part.state === 'pending'; - const meta = STATE_META[part.state]; - const source = sourceLabel(part.source); - const argumentCount = Object.keys(part.input ?? {}).length; - const titleId = `${approvalDomId(part.approvalId)}-title`; - - async function respond(decision: Decision) { - if (!onRespond || busy) return; - - setBusy(decision); - setFailure(undefined); - - try { - const result = await onRespond({ actionId: part.approvalId, decision }); - if (result.error) { - setFailure(result.error.message); - } - } finally { - setBusy(null); - } - } - - const canApprove = Boolean(part.approveActionId); - const canDeny = Boolean(part.denyActionId); - const canTrustTool = Boolean(part.trustToolActionId); - const canTrustServer = Boolean(part.trustServerActionId) && part.source?.type === 'mcp'; - const serverName = part.source?.type === 'mcp' ? part.source.serverName : undefined; - - return ( -
-
- - - -
- {source ? `Tool approval · ${source}` : 'Tool approval'} -

- {part.toolName} -

-
- - {meta.label} - -
- - {argumentCount > 0 ? ( -
- - - Arguments - {argumentCount} - -
- -
-
- ) : ( -

This tool takes no arguments.

- )} - - {failure ? ( -

- {failure} -

- ) : null} - -
- - {part.approvalId} - - {isPending ? ( - <> - - - {canTrustTool ? ( - - ) : null} - {canTrustServer && serverName ? ( - - ) : null} - - ) : ( - - - {part.state === 'approved' ? : } - - {part.state === 'approved' ? 'You approved this tool call' : 'You denied this tool call'} - - )} -
-
- ); -} diff --git a/playground/web-chat/src/components/approval-dock.tsx b/playground/web-chat/src/components/approval-dock.tsx deleted file mode 100644 index 575174de358..00000000000 --- a/playground/web-chat/src/components/approval-dock.tsx +++ /dev/null @@ -1,44 +0,0 @@ -'use client'; - -import type { AgentPendingAction } from '@novu/react'; -import { approvalDomId } from './approval-card'; -import { ShieldIcon } from './icons'; - -export function ApprovalDock({ actions }: { actions: AgentPendingAction[] }) { - const first = actions[0]; - - if (!first) { - return null; - } - - const count = actions.length; - - function review() { - const elementId = - first.type === 'tool-approval' ? approvalDomId(first.approvalId) : `mcp-connection-${first.actionId}`; - document.getElementById(elementId)?.scrollIntoView({ - behavior: 'smooth', - block: 'center', - }); - } - - return ( -
- - - - - {count === 1 ? '1 action' : `${count} actions`} waiting on you - {count === 1 ? ( - <> - {': '} - {first.type === 'tool-approval' ? first.toolName : first.displayName} - - ) : null} - - -
- ); -} diff --git a/playground/web-chat/src/components/assistant-ui/elements/approval-card.tsx b/playground/web-chat/src/components/assistant-ui/elements/approval-card.tsx new file mode 100644 index 00000000000..5a70c601c71 --- /dev/null +++ b/playground/web-chat/src/components/assistant-ui/elements/approval-card.tsx @@ -0,0 +1,167 @@ +"use client"; + +import type { ComponentProps } from "react"; +import { Menu } from "@base-ui/react/menu"; +import { CheckIcon, ChevronDownIcon, Loader2Icon, TerminalIcon, XIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { field, floating, inkButton, paper } from "./surfaces"; + +export type ApprovalState = "request" | "running" | "done" | "denied"; + +export type AlwaysAllowOption = { + label: string; + onSelect: () => void; +}; + +export function ApprovalCard({ + state, + command, + title, + subtitle, + onAllowOnce, + alwaysAllowOptions = [], + onDeny, + className, + ...props +}: Omit< + ComponentProps<"div">, + | "children" + | "state" + | "command" + | "title" + | "subtitle" + | "onAllowOnce" + | "onDeny" +> & { + state: ApprovalState; + command: string; + title: string; + subtitle: string; + onAllowOnce?: () => void; + alwaysAllowOptions?: AlwaysAllowOption[]; + onDeny?: () => void; +}) { + return ( +
+
+ + + +
+

{title}

+

{subtitle}

+
+
+ +
+ {command} +
+ +
+ {state === "request" ? ( + <> + +
+ {alwaysAllowOptions.length === 1 ? ( + + ) : alwaysAllowOptions.length > 1 ? ( + + ) : null} + +
+ + ) : ( +
+ {state === "running" ? ( + <> + + Approved, running + + ) : state === "denied" ? ( + <> + + Denied + + ) : ( + <> + + Approved + + )} +
+ )} +
+
+ ); +} + +function AlwaysAllowMenu({ options }: { options: AlwaysAllowOption[] }) { + return ( + + + Always allow + + + + + + {options.map((option) => ( + + {option.label} + + ))} + + + + + ); +} diff --git a/playground/web-chat/src/components/assistant-ui/elements/connection-state.tsx b/playground/web-chat/src/components/assistant-ui/elements/connection-state.tsx new file mode 100644 index 00000000000..476714419c1 --- /dev/null +++ b/playground/web-chat/src/components/assistant-ui/elements/connection-state.tsx @@ -0,0 +1,86 @@ +"use client"; + +import type { ComponentProps } from "react"; +import { CheckIcon, CloudOffIcon, Loader2Icon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { mono, paper } from "./surfaces"; + +export type ConnectionPhase = "online" | "dropped" | "reconnecting" | "resumed"; + +export function ConnectionState({ + phase, + attempt, + resumedTokens, + onRetry, + className, + ...props +}: Omit< + ComponentProps<"div">, + "children" | "phase" | "attempt" | "resumedTokens" | "onRetry" +> & { + phase: ConnectionPhase; + attempt?: number; + resumedTokens?: number; + onRetry?: () => void; +}) { + if (phase === "online") return null; + + return ( +
+ {phase === "dropped" && ( + <> + + + Connection lost. The run kept going on the server. + + + + )} + + {phase === "reconnecting" && ( + <> + + Reconnecting + {attempt !== undefined && ( + + attempt {attempt} + + )} + + )} + + {phase === "resumed" && ( + <> + + + Picked the stream back up. + + {resumedTokens !== undefined && ( + + +{resumedTokens} tokens + + )} + + )} +
+ ); +} diff --git a/playground/web-chat/src/components/assistant-ui/elements/day-separator.tsx b/playground/web-chat/src/components/assistant-ui/elements/day-separator.tsx new file mode 100644 index 00000000000..19124450958 --- /dev/null +++ b/playground/web-chat/src/components/assistant-ui/elements/day-separator.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { useAuiState } from "@assistant-ui/react"; +import type { FC, ReactNode } from "react"; + +function asDate(value: Date | string | number | undefined): Date | null { + if (value == null) return null; + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +function dayKey(date: Date): string { + return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; +} + +function dayLabel(date: Date): string { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const day = new Date(date); + day.setHours(0, 0, 0, 0); + const diffDays = Math.round((today.getTime() - day.getTime()) / 86_400_000); + if (diffDays === 0) return "Today"; + if (diffDays === 1) return "Yesterday"; + return date.toLocaleDateString(undefined, { + weekday: "long", + month: "short", + day: "numeric", + }); +} + +function timeLabel(date: Date): string { + return date.toLocaleTimeString(undefined, { + hour: "numeric", + minute: "2-digit", + }); +} + +export const MessageChronology: FC<{ children: ReactNode }> = ({ + children, +}) => { + const role = useAuiState((s) => s.message.role); + const createdAt = useAuiState((s) => s.message.createdAt); + const index = useAuiState((s) => s.message.index); + const prevCreatedAt = useAuiState( + (s) => s.thread.messages[index - 1]?.createdAt, + ); + + const date = asDate(createdAt); + const prev = asDate(prevCreatedAt); + const showDay = date != null && (index === 0 || !prev || dayKey(date) !== dayKey(prev)); + + return ( +
+ {showDay && date ? ( +
+ + {dayLabel(date)} + +
+ ) : null} + {children} + {date ? ( + + ) : null} +
+ ); +}; diff --git a/playground/web-chat/src/components/assistant-ui/elements/error-state.tsx b/playground/web-chat/src/components/assistant-ui/elements/error-state.tsx new file mode 100644 index 00000000000..83c3452f259 --- /dev/null +++ b/playground/web-chat/src/components/assistant-ui/elements/error-state.tsx @@ -0,0 +1,79 @@ +"use client"; + +import type { ComponentProps } from "react"; +import { CircleAlertIcon, RefreshCwIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { ShimmerLabel } from "./surfaces"; + +export interface ErrorStateProps extends Omit< + ComponentProps<"div">, + "children" | "role" +> { + title: string; + detail: string; + retrying: boolean; + /** Omit when nothing can be retried; the button is then not rendered. */ + onRetry?: () => void; +} + +export function ErrorState({ + title, + detail, + retrying, + onRetry, + className, + ...props +}: ErrorStateProps) { + if (retrying) { + return ( +
+ + + Retrying + +
+ ); + } + + return ( +
+ +
+

{title}

+

+ {detail} +

+
+ {onRetry ? ( + + ) : null} +
+ ); +} diff --git a/playground/web-chat/src/components/assistant-ui/elements/file.tsx b/playground/web-chat/src/components/assistant-ui/elements/file.tsx new file mode 100644 index 00000000000..cdc1180ac1e --- /dev/null +++ b/playground/web-chat/src/components/assistant-ui/elements/file.tsx @@ -0,0 +1,265 @@ +"use client"; + +import { memo, type FC } from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { + FileIcon, + FileTextIcon, + ImageIcon, + MusicIcon, + VideoIcon, + BracesIcon, + DownloadIcon, +} from "lucide-react"; +import type { FileMessagePartComponent } from "@assistant-ui/react"; +import { cn } from "@/lib/utils"; + +const fileVariants = cva( + "aui-file-root inline-flex items-center gap-3 rounded-lg transition-colors", + { + variants: { + variant: { + outline: "border-border hover:bg-muted/50 border", + ghost: "hover:bg-muted/50", + muted: "bg-muted/50 hover:bg-muted/70", + }, + size: { + sm: "px-2.5 py-1.5 text-xs", + default: "px-3 py-2 text-sm", + lg: "px-4 py-3 text-base", + }, + }, + defaultVariants: { + variant: "outline", + size: "default", + }, + }, +); + +function getMimeTypeIcon(mimeType: string): FC<{ className?: string }> { + const type = mimeType.toLowerCase(); + if (type.startsWith("image/")) { + return ImageIcon; + } + if (type === "application/pdf") { + return FileTextIcon; + } + if (type === "application/json") { + return BracesIcon; + } + if (type.startsWith("text/")) { + return FileTextIcon; + } + if (type.startsWith("audio/")) { + return MusicIcon; + } + if (type.startsWith("video/")) { + return VideoIcon; + } + return FileIcon; +} + +export type FileDataKind = "data-uri" | "url" | "base64" | "id"; + +function getFileDataKind( + data: string, + sourceType?: "url" | "id", +): FileDataKind { + if (sourceType === "url" && /^data:/i.test(data)) return "data-uri"; + if (sourceType) return sourceType; + if (/^data:/i.test(data)) return "data-uri"; + if (/^https?:\/\//i.test(data)) return "url"; + return "base64"; +} + +function getBase64Size(base64: string): number { + const commaIndex = base64.indexOf(","); + const base64Data = commaIndex >= 0 ? base64.slice(commaIndex + 1) : base64; + const padding = (base64Data.match(/=/g) || []).length; + return Math.floor((base64Data.length * 3) / 4) - padding; +} + +function formatFileSize(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export type FileRootProps = React.ComponentProps<"div"> & + VariantProps; + +function FileRoot({ + className, + variant, + size, + children, + ...props +}: FileRootProps) { + return ( +
+ {children} +
+ ); +} + +type FileIconDisplayProps = React.ComponentProps<"span"> & { + mimeType?: string; +}; + +function FileIconDisplay({ + mimeType, + className, + children, + ...props +}: FileIconDisplayProps) { + const IconComponent = mimeType ? getMimeTypeIcon(mimeType) : FileIcon; + + return ( + + {children ?? } + + ); +} + +function FileName({ + className, + children, + ...props +}: React.ComponentProps<"span">) { + return ( + + {children || "Unnamed file"} + + ); +} + +type FileSizeProps = React.ComponentProps<"span"> & { + bytes: number; +}; + +function FileSize({ bytes, className, ...props }: FileSizeProps) { + return ( + + {formatFileSize(bytes)} + + ); +} + +type FileDownloadProps = Omit, "href"> & { + data: string; + mimeType: string; + filename?: string; + sourceType?: "url" | "id"; +}; + +function FileDownload({ + data, + mimeType, + filename, + sourceType, + className, + children, + ...props +}: FileDownloadProps) { + if (typeof data !== "string") return null; + const kind = getFileDataKind(data, sourceType); + if (kind === "id") return null; + if (kind === "url" && !/^(https?:\/\/|blob:)/i.test(data)) return null; + const href = kind === "base64" ? `data:${mimeType};base64,${data}` : data; + + return ( + + {children || } + + ); +} + +const FileImpl: FileMessagePartComponent = ({ + filename, + data, + mimeType, + sourceType, +}) => { + const kind = getFileDataKind(data, sourceType); + const showSize = + typeof data === "string" && (kind === "base64" || kind === "data-uri"); + + return ( + + +
+ {filename} + {showSize && ( + + )} +
+ +
+ ); +}; + +const File = memo(FileImpl) as unknown as FileMessagePartComponent & { + Root: typeof FileRoot; + Icon: typeof FileIconDisplay; + Name: typeof FileName; + Size: typeof FileSize; + Download: typeof FileDownload; +}; + +File.displayName = "File"; +File.Root = FileRoot; +File.Icon = FileIconDisplay; +File.Name = FileName; +File.Size = FileSize; +File.Download = FileDownload; + +export { + File, + FileRoot, + FileIconDisplay, + FileName, + FileSize, + FileDownload, + fileVariants, + getMimeTypeIcon, + getFileDataKind, + getBase64Size, + formatFileSize, +}; diff --git a/playground/web-chat/src/components/assistant-ui/elements/markdown-text.tsx b/playground/web-chat/src/components/assistant-ui/elements/markdown-text.tsx new file mode 100644 index 00000000000..7560ee51bd8 --- /dev/null +++ b/playground/web-chat/src/components/assistant-ui/elements/markdown-text.tsx @@ -0,0 +1,266 @@ +"use client"; + +import "@assistant-ui/react-markdown/styles/dot.css"; + +import { + type CodeHeaderProps, + MarkdownTextPrimitive, + unstable_memoizeMarkdownComponents as memoizeMarkdownComponents, + useIsMarkdownCodeBlock, +} from "@assistant-ui/react-markdown"; +import remarkGfm from "remark-gfm"; +import { type FC, memo, useMemo, useRef } from "react"; +import type { TextMessagePartProps } from "@assistant-ui/react"; +import { CheckIcon, CopyIcon } from "lucide-react"; + +import { TooltipIconButton } from "@/components/assistant-ui/elements/tooltip-icon-button"; +import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; +import { cn } from "@/lib/utils"; + +type MarkdownTextProps = Partial & { + components?: Parameters[0]; +}; + +const useShallowStable = | undefined>( + value: T, +): T => { + const ref = useRef(value); + if (value !== ref.current) { + const prev = ref.current; + const stable = + value !== undefined && + prev !== undefined && + Object.keys(prev).length === Object.keys(value).length && + Object.keys(value).every((key) => prev[key] === value[key]); + if (!stable) ref.current = value; + } + return ref.current; +}; + +const MarkdownTextImpl: FC = ({ components }) => { + const stableComponents = useShallowStable(components); + const markdownComponents = useMemo(() => { + if (!stableComponents) return defaultComponents; + return { + ...defaultComponents, + ...memoizeMarkdownComponents(stableComponents), + }; + }, [stableComponents]); + + return ( + + ); +}; + +export const MarkdownText = memo(MarkdownTextImpl); + +const CodeHeader: FC = ({ language, code }) => { + const { isCopied, copyToClipboard } = useCopyToClipboard(); + const onCopy = () => { + if (!code || isCopied) return; + copyToClipboard(code); + }; + + return ( +
+ + {language} + + + {!isCopied && ( + + )} + {isCopied && ( + + )} + +
+ ); +}; + +const defaultComponents = memoizeMarkdownComponents({ + h1: ({ className, ...props }) => ( +

+ ), + h2: ({ className, ...props }) => ( +

+ ), + h3: ({ className, ...props }) => ( +

+ ), + h4: ({ className, ...props }) => ( +

+ ), + h5: ({ className, ...props }) => ( +

+ ), + h6: ({ className, ...props }) => ( +
+ ), + p: ({ className, ...props }) => ( +

+ ), + a: ({ className, ...props }) => ( + + ), + blockquote: ({ className, ...props }) => ( +

+ ), + ul: ({ className, ...props }) => ( +
    li]:mt-1", + className, + )} + {...props} + /> + ), + ol: ({ className, ...props }) => ( +
      li]:mt-1", + className, + )} + {...props} + /> + ), + hr: ({ className, ...props }) => ( +
      + ), + table: ({ className, ...props }) => ( + + ), + th: ({ className, ...props }) => ( + td:first-child]:rounded-es-lg [&:last-child>td:last-child]:rounded-ee-lg", + className, + )} + {...props} + /> + ), + li: ({ className, ...props }) => ( +
    1. + ), + strong: ({ className, ...props }) => ( + + ), + sup: ({ className, ...props }) => ( + a]:text-xs [&>a]:no-underline", className)} + {...props} + /> + ), + pre: ({ className, ...props }) => ( +
      +  ),
      +  code: function Code({ className, ...props }) {
      +    const isCodeBlock = useIsMarkdownCodeBlock();
      +    return (
      +      
      +    );
      +  },
      +  CodeHeader,
      +});
      diff --git a/playground/web-chat/src/components/assistant-ui/elements/novu-approval-card.tsx b/playground/web-chat/src/components/assistant-ui/elements/novu-approval-card.tsx
      new file mode 100644
      index 00000000000..a638cb61652
      --- /dev/null
      +++ b/playground/web-chat/src/components/assistant-ui/elements/novu-approval-card.tsx
      @@ -0,0 +1,76 @@
      +"use client";
      +
      +import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
      +import { ApprovalCard, type AlwaysAllowOption, type ApprovalState } from "./approval-card";
      +
      +function commandPreview(args: unknown, argsText: string | undefined, toolName: string): string {
      +  if (argsText?.trim()) return argsText.trim();
      +  if (args && typeof args === "object" && Object.keys(args as object).length > 0) {
      +    try {
      +      return JSON.stringify(args, null, 2);
      +    } catch {
      +      return toolName;
      +    }
      +  }
      +  return toolName;
      +}
      +
      +function approvalState(
      +  approved: boolean | undefined,
      +  resolution: "cancelled" | "expired" | undefined,
      +): ApprovalState {
      +  if (resolution === "cancelled" || resolution === "expired" || approved === false) {
      +    return "denied";
      +  }
      +  if (approved === undefined) return "request";
      +  return "done";
      +}
      +
      +export const NovuApprovalCard: ToolCallMessagePartComponent = ({
      +  toolName,
      +  args,
      +  argsText,
      +  approval,
      +  respondToApproval,
      +}) => {
      +  const options = approval?.options ?? [];
      +
      +  const respond = (optionId: string, approved: boolean) => {
      +    respondToApproval?.({ optionId, approved });
      +  };
      +
      +  let onAllowOnce: (() => void) | undefined;
      +  let onDeny: (() => void) | undefined;
      +  const alwaysAllowOptions: AlwaysAllowOption[] = [];
      +
      +  for (const option of options) {
      +    switch (option.kind) {
      +      case "allow-once":
      +        onAllowOnce = () => respond(option.id, true);
      +        break;
      +      case "allow-always":
      +        alwaysAllowOptions.push({
      +          label: option.label ?? "Always allow",
      +          onSelect: () => respond(option.id, true),
      +        });
      +        break;
      +      case "reject-once":
      +        onDeny = () => respond(option.id, false);
      +        break;
      +      default:
      +        break;
      +    }
      +  }
      +
      +  return (
      +    
      +  );
      +};
      diff --git a/playground/web-chat/src/components/assistant-ui/elements/reasoning.aui.tsx b/playground/web-chat/src/components/assistant-ui/elements/reasoning.aui.tsx
      new file mode 100644
      index 00000000000..57f8c7f5624
      --- /dev/null
      +++ b/playground/web-chat/src/components/assistant-ui/elements/reasoning.aui.tsx
      @@ -0,0 +1,120 @@
      +"use client";
      +
      +import { memo, useCallback, useRef } from "react";
      +import {
      +  useScrollLock,
      +  useAuiState,
      +  type ReasoningMessagePartComponent,
      +  type ReasoningGroupComponent,
      +} from "@assistant-ui/react";
      +import { MarkdownText } from "@/components/assistant-ui/elements/markdown-text";
      +import {
      +  ANIMATION_DURATION,
      +  ReasoningRoot as ReasoningRootBase,
      +  ReasoningTrigger,
      +  ReasoningContent,
      +  ReasoningText,
      +  ReasoningFade,
      +  reasoningVariants,
      +  type ReasoningRootProps,
      +} from "./reasoning";
      +
      +export type { ReasoningRootProps } from "./reasoning";
      +
      +/** `ReasoningRoot` with the thread viewport scroll locked during disclosure animations. */
      +function ReasoningRoot({
      +  ref,
      +  onAnimationStart,
      +  ...props
      +}: ReasoningRootProps) {
      +  const collapsibleRef = useRef(null);
      +  const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION);
      +
      +  const handleAnimationStart = useCallback(() => {
      +    lockScroll();
      +    onAnimationStart?.();
      +  }, [lockScroll, onAnimationStart]);
      +
      +  const composedRef = useCallback(
      +    (node: HTMLDivElement | null) => {
      +      collapsibleRef.current = node;
      +      if (typeof ref === "function") {
      +        ref(node);
      +      } else if (ref) {
      +        ref.current = node;
      +      }
      +    },
      +    [ref],
      +  );
      +
      +  return (
      +    
      +  );
      +}
      +
      +const ReasoningImpl: ReasoningMessagePartComponent = () => ;
      +
      +const ReasoningGroupImpl: ReasoningGroupComponent = ({
      +  children,
      +  startIndex,
      +  endIndex,
      +}) => {
      +  const isReasoningStreaming = useAuiState((s) => {
      +    if (s.message.status?.type !== "running") return false;
      +    for (let index = startIndex; index <= endIndex; index++) {
      +      if (s.message.parts[index]?.status.type === "running") return true;
      +    }
      +    return false;
      +  });
      +
      +  return (
      +    
      +      
      +      
      +        {children}
      +      
      +    
      +  );
      +};
      +
      +const Reasoning = memo(
      +  ReasoningImpl,
      +) as unknown as ReasoningMessagePartComponent & {
      +  Root: typeof ReasoningRoot;
      +  Trigger: typeof ReasoningTrigger;
      +  Content: typeof ReasoningContent;
      +  Text: typeof ReasoningText;
      +  Fade: typeof ReasoningFade;
      +};
      +
      +Reasoning.displayName = "Reasoning";
      +Reasoning.Root = ReasoningRoot;
      +Reasoning.Trigger = ReasoningTrigger;
      +Reasoning.Content = ReasoningContent;
      +Reasoning.Text = ReasoningText;
      +Reasoning.Fade = ReasoningFade;
      +
      +/**
      + * @deprecated This wrapper targets the legacy `components.ReasoningGroup`
      + * prop on ``. Use ``
      + * with a `groupBy` returning `"group-reasoning"` and compose `ReasoningRoot`
      + * / `ReasoningTrigger` / `ReasoningContent` / `ReasoningText` directly.
      + * See `thread.aui.tsx` for an example.
      + */
      +const ReasoningGroup = memo(ReasoningGroupImpl);
      +ReasoningGroup.displayName = "ReasoningGroup";
      +
      +export {
      +  Reasoning,
      +  ReasoningGroup,
      +  ReasoningRoot,
      +  ReasoningTrigger,
      +  ReasoningContent,
      +  ReasoningText,
      +  ReasoningFade,
      +  reasoningVariants,
      +};
      diff --git a/playground/web-chat/src/components/assistant-ui/elements/reasoning.tsx b/playground/web-chat/src/components/assistant-ui/elements/reasoning.tsx
      new file mode 100644
      index 00000000000..4ce166a6df2
      --- /dev/null
      +++ b/playground/web-chat/src/components/assistant-ui/elements/reasoning.tsx
      @@ -0,0 +1,329 @@
      +"use client";
      +
      +import {
      +  createContext,
      +  useCallback,
      +  useContext,
      +  useEffect,
      +  useLayoutEffect,
      +  useRef,
      +  useState,
      +} from "react";
      +import { cva, type VariantProps } from "class-variance-authority";
      +import { BrainIcon, ChevronDownIcon } from "lucide-react";
      +import {
      +  Collapsible,
      +  CollapsibleContent,
      +  CollapsibleTrigger,
      +} from "@/components/ui/collapsible";
      +import { cn } from "@/lib/utils";
      +
      +export const ANIMATION_DURATION = 200;
      +
      +const ReasoningPreviewContext = createContext(false);
      +
      +const reasoningVariants = cva("aui-reasoning-root mb-4 w-full", {
      +  variants: {
      +    variant: {
      +      outline: "rounded-lg border px-3 py-2",
      +      ghost: "",
      +      muted: "bg-muted/50 rounded-lg px-3 py-2",
      +    },
      +  },
      +  defaultVariants: {
      +    variant: "outline",
      +  },
      +});
      +
      +export type ReasoningRootProps = Omit<
      +  React.ComponentProps,
      +  "open" | "onOpenChange"
      +> &
      +  VariantProps & {
      +    open?: boolean;
      +    onOpenChange?: (open: boolean) => void;
      +    defaultOpen?: boolean;
      +    /**
      +     * Whether the reasoning is currently streaming. While `true` the
      +     * disclosure is held open with a bottom-pinned live preview; when
      +     * streaming ends it returns to `defaultOpen`, and the first manual
      +     * toggle takes over the open/close state permanently. The live preview
      +     * keeps following the newest tokens while the disclosure is open during
      +     * streaming, even after a manual toggle, and pauses while the reader is
      +     * scrolled up.
      +     */
      +    streaming?: boolean;
      +    /** Called right before the disclosure animates, on toggle and on streaming transitions. */
      +    onAnimationStart?: () => void;
      +  };
      +
      +function ReasoningRoot({
      +  className,
      +  variant,
      +  open: controlledOpen,
      +  onOpenChange: controlledOnOpenChange,
      +  defaultOpen = false,
      +  streaming,
      +  onAnimationStart,
      +  children,
      +  ...props
      +}: ReasoningRootProps) {
      +  const initialOpenRef = useRef(defaultOpen);
      +  const [userOpen, setUserOpen] = useState(null);
      +
      +  const isControlled = controlledOpen !== undefined;
      +  const isOpen = isControlled
      +    ? controlledOpen
      +    : (userOpen ?? (streaming || initialOpenRef.current));
      +  const isPreview = streaming === true && isOpen;
      +
      +  const prevStreamingRef = useRef(streaming);
      +  useLayoutEffect(() => {
      +    if (prevStreamingRef.current === streaming) return;
      +    prevStreamingRef.current = streaming;
      +    // A streaming transition only animates the panel when the resting state
      +    // is collapsed; with `defaultOpen` the disclosure stays open across it.
      +    if (!isControlled && userOpen === null && !initialOpenRef.current) {
      +      onAnimationStart?.();
      +    }
      +  }, [streaming, isControlled, userOpen, onAnimationStart]);
      +
      +  const handleOpenChange = useCallback(
      +    (open: boolean) => {
      +      onAnimationStart?.();
      +      if (!isControlled) {
      +        setUserOpen(open);
      +      }
      +      controlledOnOpenChange?.(open);
      +    },
      +    [onAnimationStart, isControlled, controlledOnOpenChange],
      +  );
      +
      +  return (
      +    
      +      
      +        {children}
      +      
      +    
      +  );
      +}
      +
      +function ReasoningFade({
      +  side = "bottom",
      +  className,
      +  ...props
      +}: React.ComponentProps<"div"> & { side?: "top" | "bottom" }) {
      +  if (side === "top") {
      +    return (
      +      
      + ); + } + + return ( +
      + ); +} + +function ReasoningTrigger({ + active, + duration, + className, + ...props +}: React.ComponentProps & { + active?: boolean; + duration?: number; +}) { + const durationText = duration ? ` (${duration}s)` : ""; + + return ( + + + + Reasoning{durationText} + + + + ); +} + +function ReasoningContent({ + className, + children, + ...props +}: React.ComponentProps) { + const isPreview = useContext(ReasoningPreviewContext); + + return ( + + + {children} + {isPreview ? : null} + + ); +} + +function ReasoningText({ + className, + children, + ...props +}: React.ComponentProps<"div">) { + const isPreview = useContext(ReasoningPreviewContext); + const scrollRef = useRef(null); + const contentRef = useRef(null); + + useEffect(() => { + if (!isPreview) return; + const scrollEl = scrollRef.current; + const contentEl = contentRef.current; + if (!scrollEl || !contentEl) return; + + let pinned = true; + let lastScrollTop = scrollEl.scrollTop; + let lastScrollHeight = scrollEl.scrollHeight; + const isAtBottom = () => + Math.abs( + scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight, + ) <= 1 || scrollEl.scrollHeight <= scrollEl.clientHeight; + + const pin = () => { + if (!pinned) return; + scrollEl.scrollTop = scrollEl.scrollHeight; + }; + // A pin's own scroll event can arrive after new content grew the scroll + // height and read as "not at bottom"; only an upward move at unchanged + // scroll height is user intent. + const onScroll = () => { + if (isAtBottom()) { + pinned = true; + } else if ( + scrollEl.scrollTop < lastScrollTop && + scrollEl.scrollHeight === lastScrollHeight + ) { + pinned = false; + } + lastScrollTop = scrollEl.scrollTop; + lastScrollHeight = scrollEl.scrollHeight; + }; + + pin(); + scrollEl.addEventListener("scroll", onScroll); + const observer = new ResizeObserver(pin); + observer.observe(contentEl); + return () => { + scrollEl.removeEventListener("scroll", onScroll); + observer.disconnect(); + }; + }, [isPreview]); + + return ( +
      +
      + {children} +
      +
      + ); +} + +export { + ReasoningRoot, + ReasoningTrigger, + ReasoningContent, + ReasoningText, + ReasoningFade, + reasoningVariants, +}; diff --git a/playground/web-chat/src/components/assistant-ui/elements/surfaces.tsx b/playground/web-chat/src/components/assistant-ui/elements/surfaces.tsx new file mode 100644 index 00000000000..5ac55f56625 --- /dev/null +++ b/playground/web-chat/src/components/assistant-ui/elements/surfaces.tsx @@ -0,0 +1,110 @@ +"use client"; + +import type { ComponentProps } from "react"; +import { useLayoutEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; + +export const paper = "bg-background border border-border/60 dark:bg-popover"; + +export const floating = "bg-background border border-border/60 dark:bg-popover"; + +export const field = "bg-foreground/[0.04] dark:bg-foreground/[0.06]"; + +export const fieldInteractive = + "bg-foreground/[0.04] transition-colors hover:bg-foreground/[0.07] dark:bg-foreground/[0.06] dark:hover:bg-foreground/[0.09]"; + +export const pressable = + "transition-transform duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.96] motion-reduce:transition-none"; + +export const ghostButton = + "flex items-center justify-center rounded-full text-foreground/45 outline-none transition-[background-color,color,scale] duration-150 hover:bg-foreground/[0.06] hover:text-foreground/90 active:scale-[0.96] focus-visible:ring-1 focus-visible:ring-foreground/20 motion-reduce:transition-none dark:hover:bg-foreground/[0.09]"; + +export const inkButton = + "bg-foreground text-background !text-background transition-[opacity,scale] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] hover:opacity-90 active:scale-[0.96] motion-reduce:transition-none"; + +export const iconSwap = + "[grid-area:1/1] transition-[opacity,scale,filter] duration-200 ease-[cubic-bezier(0.2,0,0,1)] motion-reduce:transition-none"; + +export const iconSwapIn = "scale-100 opacity-100 blur-none"; + +export const iconSwapOut = "scale-[0.25] opacity-0 blur-[4px]"; + +export const labelSwap = + "col-start-1 row-start-1 flex w-max items-center gap-1.5 leading-none transition-[opacity,filter] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none"; + +export const labelSwapIn = "opacity-100 blur-none"; + +export const labelSwapOut = + "pointer-events-none select-none opacity-0 blur-[2px]"; + +export const collapsePanel = + "h-(--collapsible-panel-height) overflow-hidden transition-[height] duration-200 ease-[cubic-bezier(0.32,0.72,0,1)] data-[ending-style]:h-0 data-[starting-style]:h-0 motion-reduce:transition-none"; + +export const live = "text-blue-500 dark:text-blue-400"; + +export const mono = "font-mono text-[11px] tracking-tight"; + +export function ShimmerLabel({ + active = true, + className, + ...props +}: ComponentProps<"span"> & { active?: boolean }) { + return ( + + ); +} + +export const codeScroll = "overflow-x-auto"; + +export const codeSurface = "w-max min-w-full"; + +export function SwapLabel({ + active, + children, + className, +}: { + active: 0 | 1; + children: [React.ReactNode, React.ReactNode]; + className?: string; +}) { + const layers = [useRef(null), useRef(null)]; + const [width, setWidth] = useState(null); + + useLayoutEffect(() => { + const target = layers[active]?.current; + if (!target) return undefined; + const measure = () => + setWidth(Math.ceil(target.getBoundingClientRect().width)); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(target); + return () => observer.disconnect(); + }, [active]); + + return ( + + {children.map((layer, index) => ( + + {layer} + + ))} + + ); +} diff --git a/playground/web-chat/src/components/assistant-ui/elements/thinking-indicator.tsx b/playground/web-chat/src/components/assistant-ui/elements/thinking-indicator.tsx new file mode 100644 index 00000000000..26b42fd5923 --- /dev/null +++ b/playground/web-chat/src/components/assistant-ui/elements/thinking-indicator.tsx @@ -0,0 +1,42 @@ +"use client"; + +import type { ComponentProps } from "react"; +import { cn } from "@/lib/utils"; +import { mono, ShimmerLabel } from "./surfaces"; + +export function ThinkingIndicator({ + label, + elapsed, + className, + ...props +}: Omit, "children" | "label" | "elapsed"> & { + label: string; + elapsed?: string; +}) { + return ( +
      + + + {label} + + {elapsed !== undefined && ( + + {elapsed} + + )} +
      + ); +} diff --git a/playground/web-chat/src/components/assistant-ui/elements/thread-list.aui.tsx b/playground/web-chat/src/components/assistant-ui/elements/thread-list.aui.tsx new file mode 100644 index 00000000000..9a8e44170e1 --- /dev/null +++ b/playground/web-chat/src/components/assistant-ui/elements/thread-list.aui.tsx @@ -0,0 +1,240 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; +import { + AuiIf, + ThreadListItemPrimitive, + ThreadListPrimitive, + useAuiState, +} from "@assistant-ui/react"; +import { Loader2Icon, PlusIcon } from "lucide-react"; +import { + forwardRef, + Fragment, + useMemo, + type ComponentPropsWithoutRef, + type FC, +} from "react"; + +export const ThreadList: FC = () => { + return ( + + + + + ); +}; + +export const ThreadListRoot: FC< + ComponentPropsWithoutRef +> = ({ className, ...props }) => { + return ( + + ); +}; + +export const ThreadListItems: FC> = ({ + className, + ...props +}) => { + return ( +
      + s.threads.isLoading}> + + + !s.threads.isLoading}> + + +
      + ); +}; + +const DAY_IN_MS = 86_400_000; + +const dateGroupLabel = ( + date: Date | undefined, + startOfToday: number, +): string => { + if (!date || date.getTime() >= startOfToday) return "Today"; + if (date.getTime() >= startOfToday - DAY_IN_MS) return "Yesterday"; + return "Earlier"; +}; + +type ThreadListGroup = { label: string; indices: number[] }; + +const ThreadListItemGroups: FC = () => { + const threadIds = useAuiState((s) => s.threads.threadIds); + const threadItems = useAuiState((s) => s.threads.threadItems); + + const { indices, groups } = useMemo(() => { + const itemsById = new Map(threadItems.map((item) => [item.id, item])); + const dates = threadIds.map((id) => { + const item = itemsById.get(id); + if (item?.lastMessageAt) return item.lastMessageAt; + const lastActivityAt = ( + item?.custom as { lastActivityAt?: string } | undefined + )?.lastActivityAt; + return lastActivityAt ? new Date(lastActivityAt) : undefined; + }); + const indices = threadIds.map((_, index) => index); + if (!indices.some((index) => dates[index])) { + return { indices, groups: null }; + } + + const now = new Date(); + const startOfToday = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + ).getTime(); + const time = (index: number) => + dates[index]?.getTime() ?? Number.MAX_SAFE_INTEGER; + const sorted = [...indices].sort((a, b) => time(b) - time(a)); + + const result: ThreadListGroup[] = []; + for (const index of sorted) { + const label = dateGroupLabel(dates[index], startOfToday); + const lastGroup = result[result.length - 1]; + if (lastGroup?.label === label) { + lastGroup.indices.push(index); + } else { + result.push({ label, indices: [index] }); + } + } + return { indices, groups: result }; + }, [threadIds, threadItems]); + + if (!groups) { + return indices.map((index) => ( + + )); + } + + return groups.map((group) => ( + +
      + {group.label} +
      + {group.indices.map((index) => ( + + ))} +
      + )); +}; + +export const ThreadListNew = forwardRef< + HTMLButtonElement, + ComponentPropsWithoutRef & { labelClassName?: string } +>(({ className, labelClassName, children, ...props }, ref) => { + const isNewThreadActive = useAuiState( + (s) => s.threads.newThreadId === s.threads.mainThreadId, + ); + + return ( + + + + ); +}); + +ThreadListNew.displayName = "ThreadListNew"; + +const ThreadListSkeleton: FC = () => { + return ( +
      + {Array.from({ length: 5 }, (_, i) => ( +
      + +
      + ))} +
      + ); +}; + +export const ThreadListItem: FC = () => { + const isRunning = useAuiState((s) => s.threadListItem.isRunning); + + return ( + + + {isRunning && ( + + )} + + + + {isRunning && Running} + + + ); +}; diff --git a/playground/web-chat/src/components/assistant-ui/elements/thread.aui.tsx b/playground/web-chat/src/components/assistant-ui/elements/thread.aui.tsx new file mode 100644 index 00000000000..becfb0d5ae6 --- /dev/null +++ b/playground/web-chat/src/components/assistant-ui/elements/thread.aui.tsx @@ -0,0 +1,518 @@ +"use client"; + +import { MessageChronology } from "@/components/assistant-ui/elements/day-separator"; +import { MarkdownText } from "@/components/assistant-ui/elements/markdown-text"; +import { + Reasoning, + ReasoningContent, + ReasoningRoot, + ReasoningText, + ReasoningTrigger, +} from "@/components/assistant-ui/elements/reasoning.aui"; +import { ToolFallback } from "@/components/assistant-ui/elements/tool-fallback.aui"; +import { + ToolGroupContent, + ToolGroupRoot, + ToolGroupTrigger, +} from "@/components/assistant-ui/elements/tool-group.aui"; +import { TooltipIconButton } from "@/components/assistant-ui/elements/tooltip-icon-button"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; +import { + ActionBarMorePrimitive, + ActionBarPrimitive, + AuiIf, + type AssistantState, + ComposerPrimitive, + ErrorPrimitive, + groupPartByType, + MessagePrimitive, + SuggestionPrimitive, + ThreadPrimitive, + type ToolCallMessagePartComponent, + useAuiState, +} from "@assistant-ui/react"; +import { + ArrowDownIcon, + ArrowUpIcon, + CheckIcon, + CopyIcon, + DownloadIcon, + LoaderCircleIcon, + MoreHorizontalIcon, +} from "lucide-react"; +import { + createContext, + useContext, + useEffect, + useRef, + useState, + type ComponentType, + type FC, + type PropsWithChildren, +} from "react"; + +export type ThreadGroupPart = MessagePrimitive.GroupedParts.GroupPart; + +export const defaultThreadPartGroupBy = groupPartByType({ + reasoning: ["group-chainOfThought", "group-reasoning"], + "tool-call": ["group-chainOfThought", "group-tool"], + "standalone-tool-call": [], +}); + +/** + * Optional component overrides for the thread. `AssistantMessage` and + * `Welcome` replace whole sections; the remaining slots override how the + * assistant message renders tool calls and part groups. Tool UIs registered + * by name (toolkit `render`, `useAssistantDataUI`) take precedence over + * `ToolFallback`. + */ +export type ThreadComponents = { + AssistantMessage?: ComponentType | undefined; + UserMessage?: ComponentType | undefined; + Welcome?: ComponentType | undefined; + Indicator?: ComponentType | undefined; + /** Rendered directly above the composer, inside the sticky footer. */ + Banner?: ComponentType | undefined; + ToolFallback?: ToolCallMessagePartComponent | undefined; + ToolGroup?: + | ComponentType> + | undefined; + ReasoningGroup?: + | ComponentType> + | undefined; + groupBy?: typeof defaultThreadPartGroupBy; +}; + +export type ThreadProps = { + components?: ThreadComponents | undefined; + autoFocus?: boolean | undefined; +}; + +const EMPTY_COMPONENTS: ThreadComponents = {}; + +const ThreadComponentsContext = + createContext(EMPTY_COMPONENTS); + +// Startup exposes a loading placeholder thread; treat it as a new chat so +// the composer mounts centered. Loads after startup keep the docked layout. +const isNewChatView = (s: AssistantState) => + s.thread.messages.length === 0 && + (!s.thread.isLoading || s.threads.isLoading); + +// A switched thread that is still fetching its history: skeleton, not welcome. +const isHistoryLoadingView = (s: AssistantState) => + s.thread.messages.length === 0 && + s.thread.isLoading && + !s.thread.isDisabled && + !s.threads.isLoading; + +const ThreadHistorySkeleton: FC = () => ( +
      + Loading conversation + +
      + + + +
      + +
      + + +
      +
      +); + +export const Thread: FC = ({ + components = EMPTY_COMPONENTS, + autoFocus = true, +}) => { + const isEmpty = useAuiState(isNewChatView); + + return ( + + + + ); +}; + +const ThreadRoot: FC<{ isEmpty: boolean; autoFocus: boolean }> = ({ + isEmpty, + autoFocus, +}) => { + const { Welcome = ThreadWelcome, Banner } = useContext(ThreadComponentsContext); + + return ( + + +
      + + + + + + + +
      + + {() => } + +
      + + + + + {Banner ? : null} + + + + + +
      +
      +
      + ); +}; + +const ThreadMessage: FC = () => { + const { AssistantMessage: AssistantMessageComponent = AssistantMessage } = + useContext(ThreadComponentsContext); + const { UserMessage: UserMessageComponent = UserMessage } = + useContext(ThreadComponentsContext); + const role = useAuiState((s) => s.message.role); + + return ( + + {role === "user" ? : } + + ); +}; + +const ThreadViewportBottomStateSync: FC = () => { + const markerRef = useRef(null); + + useEffect(() => { + const viewport = markerRef.current?.closest( + '[data-slot="aui_thread-viewport"]', + ); + if (!viewport) return; + + let frame: number | undefined; + const syncIfAtBottom = () => { + cancelAnimationFrame(frame ?? 0); + frame = requestAnimationFrame(() => { + const bottomDistance = + viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight; + if (Math.abs(bottomDistance) <= 1) { + viewport.dispatchEvent(new Event("scroll")); + } + }); + }; + + const observer = new MutationObserver((mutations) => { + const reserveChanged = mutations.some((mutation) => { + const target = mutation.target; + if ( + target instanceof HTMLElement && + target.matches("[data-aui-top-anchor-reserve]") + ) { + return true; + } + + return [...mutation.addedNodes, ...mutation.removedNodes].some( + (node) => + node instanceof HTMLElement && + (node.matches("[data-aui-top-anchor-reserve]") || + node.querySelector("[data-aui-top-anchor-reserve]")), + ); + }); + + if (reserveChanged) syncIfAtBottom(); + }); + + observer.observe(viewport, { + attributes: true, + attributeFilter: ["style"], + childList: true, + subtree: true, + }); + + return () => { + observer.disconnect(); + cancelAnimationFrame(frame ?? 0); + }; + }, []); + + return
    2. + ), + td: ({ className, ...props }) => ( + + ), + tr: ({ className, ...props }) => ( +