diff --git a/.cspell.json b/.cspell.json index 7dc944778a6..2aafec01243 100644 --- a/.cspell.json +++ b/.cspell.json @@ -515,7 +515,10 @@ "Kamil", "Myśliwiec", "nestframework", - "ryver" + "ryver", + "idempotency", + "IDEMPOTENCY", + "Idempotency" ], "flagWords": [], "patterns": [ diff --git a/README.md b/README.md index 84edb476116..386d502c200 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Your contribution, no matter its size, holds immense value. We eagerly await to · Roadmap · - Twitter + X · Notifications Directory

@@ -141,7 +141,7 @@ await novu.trigger('', { ## Embeddable Notification Center -Using the Novu API and admin panel, you can easily add a real-time notification center to your web app without building it yourself. You can use our React / Vue / Angular component or an iframe embed. +Using the Novu API and admin panel, you can easily add a real-time notification center to your web app without building it yourself. You can use our [React](https://docs.novu.co/notification-center/client/react/get-started) / [Vue](https://docs.novu.co/notification-center/client/vue) / [Angular](https://docs.novu.co/notification-center/client/angular) components or an [iframe embed](https://docs.novu.co/notification-center/client/iframe).
notification-center-912bb96e009fb3a69bafec23bcde00b0 diff --git a/SECURITY.md b/SECURITY.md index b8340555d38..9b94c6ade88 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,14 +1,18 @@ # Security -Contact: security@novu.co +**Contact:** security@novu.co -Based on [https://supabase.com/.well-known/security.txt](https://supabase.com/.well-known/security.txt) +Safeguarding our Novu systems is a top concern for us. Nevertheless, despite our best efforts to fortify them, vulnerabilities may still be present. -We place a high priority on the security of our systems at Novu. However, no matter how hard we try to make our systems secure, vulnerabilities can still exist. +If you come across a vulnerability, please inform us promptly so we can promptly resolve it. We kindly request your assistance in enhancing the security of both our clients and our systems. -In the event that you discover a vulnerability, please let us know so we can address it as soon as possible. We would like to ask you to help us better protect our clients and our systems. +## Reporting a Vulnerability -## Out of scope vulnerabilities: +**In Scope Vulnerabilities:** + +- Any security issues that might put at risk the confidentiality, integrity, or accessibility of our systems or data. + +**Out of Scope Vulnerabilities:** - Clickjacking on pages with no sensitive actions. @@ -18,41 +22,42 @@ In the event that you discover a vulnerability, please let us know so we can add - Any activity that could lead to the disruption of our service (DoS). -- Content spoofing and text injection issues without showing an attack vector/without being able to modify HTML/CSS. +- Content spoofing and text injection issues without showing an attack vector or the ability to modify HTML/CSS. -- Email spoofing +- Email spoofing. -- Missing DNSSEC, CAA, CSP headers +- Missing DNSSEC, CAA, CSP headers. -- Lack of Secure or HTTP only flag on non-sensitive cookies +- Lack of Secure or HTTP-only flags on non-sensitive cookies. -- Deadlinks +- Deadlinks. -## Please do the following: +**Reporting Instructions:** -- E-mail your findings to [security@novu.co](mailto:security@novu.co). +1. Email your findings to **security@novu.co**. -- Do not run automated scanners on our infrastructure or dashboard. If you wish to do this, contact us and we will set up a sandbox for you. +2. Automated scanning tools should not be used on our infrastructure or dashboard. If you have a need for this, please reach out to us, and we'll assist you in setting up a secure sandbox environment. -- Do not take advantage of the vulnerability or problem you have discovered, for example by downloading more data than necessary to demonstrate the vulnerability or deleting or modifying other people's data, +3. Please do not exploit the vulnerability or issue you've found, such as downloading excessive data or tampering with others' data. -- Do not reveal the problem to others until it has been resolved, +4. Please keep the issue confidential until we've fixed it. -- Do not use attacks on physical security, social engineering, distributed denial of service, spam or applications of third parties, +5. Do not use attacks on physical security, social engineering, distributed denial of service, spam, or third-party applications. -- Do provide sufficient information to reproduce the problem, so we will be able to resolve it as quickly as possible. Usually, the IP address or the URL of the affected system and a description of the vulnerability will be sufficient, but complex vulnerabilities may require further explanation. +6. Please share enough details for us to understand and fix the issue as fast as we can. Typically, providing the IP address or the URL of the affected system along with a description of the problem should be enough, though more intricate issues might need additional clarification. -## What we promise: +## What *We* Promise -- We will respond to your report within 3 business days with our evaluation of the report and an expected resolution date, +1. We'll get back to you within 3 business days with our assessment of the report and an estimated date when we expect to resolve it. -- If you have followed the instructions above, we will not take any legal action against you in regard to the report, +2. We will not take any legal action against you related to the report, if you have adhered to the reporting instructions above. -- We will handle your report with strict confidentiality, and not pass on your personal details to third parties without your permission, +3. We'll treat your report with utmost confidentiality and won't share your personal information with third parties without your consent. -- We will keep you informed of the progress towards resolving the problem, +4. We'll be keeping you updated of the progress toward fixing the issue. -- In the public information concerning the problem reported, we will give your name as the discoverer of the problem (unless you desire otherwise), and +5. We'll credit you as the discoverer of the issue (unless you request otherwise), in public disclosures of the reported issue. -- We strive to resolve all problems as quickly as possible, and we would like to play an active role in the ultimate publication on the problem after it is resolved. +6. We aim to resolve all issues promptly and are eager to actively contribute to the ultimate publication on the problem, once the problem has been resolved. +We truly value your contributions in strengthening our security. diff --git a/apps/api/e2e/idempotency.e2e.ts b/apps/api/e2e/idempotency.e2e.ts new file mode 100644 index 00000000000..ced5688bcec --- /dev/null +++ b/apps/api/e2e/idempotency.e2e.ts @@ -0,0 +1,307 @@ +import { UserSession } from '@novu/testing'; +import { CacheService } from '@novu/application-generic'; +import { expect } from 'chai'; +describe('Idempotency Test', async () => { + let session: UserSession; + const path = '/v1/testing/idempotency'; + const HEADER_KEYS = { + IDEMPOTENCY_KEY: 'idempotency-key', + RETRY_AFTER: 'retry-after', + IDEMPOTENCY_REPLAY: 'idempotency-replay', + LINK: 'link', + }; + const DOCS_LINK = 'docs.novu.co/idempotency'; + + let cacheService: CacheService | null = null; + + describe('when enabled', () => { + before(async () => { + session = new UserSession(); + await session.initialize(); + cacheService = session.testServer?.getService(CacheService); + process.env.IS_API_IDEMPOTENCY_ENABLED = 'true'; + }); + + it('should return cached same response for duplicate requests', async () => { + const key = `1`; + const { body, headers } = await session.testAgent + .post(path) + .set(HEADER_KEYS.IDEMPOTENCY_KEY, key) + .set('authorization', `ApiKey ${session.apiKey}`) + .send({ data: 201 }) + .expect(201); + const { body: bodyDupe, headers: headerDupe } = await session.testAgent + .post(path) + .set(HEADER_KEYS.IDEMPOTENCY_KEY, key) + .set('authorization', `ApiKey ${session.apiKey}`) + .send({ data: 201 }) + .expect(201); + expect(typeof body.data.number === 'number').to.be.true; + expect(body.data.number).to.equal(bodyDupe.data.number); + expect(headers[HEADER_KEYS.IDEMPOTENCY_KEY]).to.eq(key); + expect(headerDupe[HEADER_KEYS.IDEMPOTENCY_KEY]).to.eq(key); + expect(headerDupe[HEADER_KEYS.IDEMPOTENCY_REPLAY]).to.eq('true'); + }); + it('should return cached and use correct cache key when apiKey is used', async () => { + const key = `2`; + const { body, headers } = await session.testAgent + .post(path) + .set(HEADER_KEYS.IDEMPOTENCY_KEY, key) + .set('authorization', `ApiKey ${session.apiKey}`) + .send({ data: 201 }) + .expect(201); + const cacheKey = `test-${session.organization._id}-${key}`; + session.testServer?.getHttpServer(); + // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain + const cacheVal = JSON.stringify(JSON.parse(await cacheService?.get(cacheKey)!).data); + expect(JSON.stringify(body)).to.eq(cacheVal); + const { body: bodyDupe, headers: headerDupe } = await session.testAgent + .post(path) + .set(HEADER_KEYS.IDEMPOTENCY_KEY, key) + .set('authorization', `ApiKey ${session.apiKey}`) + .send({ data: 201 }) + .expect(201); + expect(typeof body.data.number === 'number').to.be.true; + expect(body.data.number).to.equal(bodyDupe.data.number); + expect(headers[HEADER_KEYS.IDEMPOTENCY_KEY]).to.eq(key); + expect(headerDupe[HEADER_KEYS.IDEMPOTENCY_KEY]).to.eq(key); + expect(headerDupe[HEADER_KEYS.IDEMPOTENCY_REPLAY]).to.eq('true'); + }); + it('should return cached and use correct cache key when authToken and apiKey combination is used', async () => { + const key = `3`; + const { body, headers } = await session.testAgent + .post(path) + .set(HEADER_KEYS.IDEMPOTENCY_KEY, key) + .set('authorization', session.token) + .send({ data: 201 }) + .expect(201); + const cacheKey = `test-${session.organization._id}-${key}`; + session.testServer?.getHttpServer(); + // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain + const cacheVal = JSON.stringify(JSON.parse(await cacheService?.get(cacheKey)!).data); + expect(JSON.stringify(body)).to.eq(cacheVal); + const { body: bodyDupe, headers: headerDupe } = await session.testAgent + .post(path) + .set(HEADER_KEYS.IDEMPOTENCY_KEY, key) + .set('authorization', `ApiKey ${session.apiKey}`) + .send({ data: 201 }) + .expect(201); + expect(typeof body.data.number === 'number').to.be.true; + expect(body.data.number).to.equal(bodyDupe.data.number); + expect(headers[HEADER_KEYS.IDEMPOTENCY_KEY]).to.eq(key); + expect(headerDupe[HEADER_KEYS.IDEMPOTENCY_KEY]).to.eq(key); + expect(headerDupe[HEADER_KEYS.IDEMPOTENCY_REPLAY]).to.eq('true'); + }); + it('should return conflict when concurrent requests are made', async () => { + const key = `4`; + const [{ headers, body, status }, { headers: headerDupe, body: bodyDupe, status: statusDupe }] = + await Promise.all([ + session.testAgent.post(path).set(HEADER_KEYS.IDEMPOTENCY_KEY, key).send({ data: 250 }), + session.testAgent.post(path).set(HEADER_KEYS.IDEMPOTENCY_KEY, key).send({ data: 250 }), + ]); + const oneSuccess = status === 201 || statusDupe === 201; + const oneConflict = status === 409 || statusDupe === 409; + const conflictBody = status === 201 ? bodyDupe : body; + const retryHeader = headers[HEADER_KEYS.RETRY_AFTER] || headerDupe[HEADER_KEYS.RETRY_AFTER]; + expect(oneSuccess).to.be.true; + expect(oneConflict).to.be.true; + expect(headers[HEADER_KEYS.IDEMPOTENCY_KEY]).to.eq(key); + expect(headerDupe[HEADER_KEYS.IDEMPOTENCY_KEY]).to.eq(key); + expect(headerDupe[HEADER_KEYS.LINK]).to.eq(DOCS_LINK); + expect(retryHeader).to.eq(`1`); + expect(JSON.stringify(conflictBody)).to.eq( + JSON.stringify({ + message: `Request with key "${key}" is currently being processed. Please retry after 1 second`, + error: 'Conflict', + statusCode: 409, + }) + ); + }); + it('should return conflict when different body is sent for same key', async () => { + const key = '5'; + const { headers, body, status } = await session.testAgent + .post(path) + .set(HEADER_KEYS.IDEMPOTENCY_KEY, key) + .set('authorization', `ApiKey ${session.apiKey}`) + .send({ data: 250 }); + const { + headers: headerDupe, + body: bodyDupe, + status: statusDupe, + } = await session.testAgent.post(path).set(HEADER_KEYS.IDEMPOTENCY_KEY, key).send({ data: 251 }); + + const oneSuccess = status === 201 || statusDupe === 201; + const oneConflict = status === 422 || statusDupe === 422; + const conflictBody = status === 201 ? bodyDupe : body; + expect(oneSuccess).to.be.true; + expect(oneConflict).to.be.true; + expect(headers[HEADER_KEYS.IDEMPOTENCY_KEY]).to.eq(key); + expect(headerDupe[HEADER_KEYS.IDEMPOTENCY_KEY]).to.eq(key); + expect(headerDupe[HEADER_KEYS.LINK]).to.eq(DOCS_LINK); + expect(JSON.stringify(conflictBody)).to.eq( + JSON.stringify({ + message: `Request with key "${key}" is being reused for a different body`, + error: 'Unprocessable Entity', + statusCode: 422, + }) + ); + }); + it('should return non cached response for unique requests', async () => { + const key = '6'; + const key1 = '7'; + const { body, headers } = await session.testAgent + .post(path) + .set(HEADER_KEYS.IDEMPOTENCY_KEY, key) + .set('authorization', `ApiKey ${session.apiKey}`) + .send({ data: 201 }) + .expect(201); + + const { body: bodyDupe, headers: headerDupe } = await session.testAgent + .post(path) + .set(HEADER_KEYS.IDEMPOTENCY_KEY, key1) + .send({ data: 201 }) + .expect(201); + expect(typeof body.data.number === 'number').to.be.true; + expect(typeof bodyDupe.data.number === 'number').to.be.true; + expect(body.data.number).not.to.equal(bodyDupe.data.number); + expect(headers[HEADER_KEYS.IDEMPOTENCY_KEY]).to.eq(key); + expect(headerDupe[HEADER_KEYS.IDEMPOTENCY_KEY]).to.eq(key1); + }); + it('should return non cached response for GET requests', async () => { + const key = '8'; + const { body, headers } = await session.testAgent + .get(path) + .set(HEADER_KEYS.IDEMPOTENCY_KEY, key) + .set('authorization', `ApiKey ${session.apiKey}`) + .send({}) + .expect(200); + + const { body: bodyDupe } = await session.testAgent + .get(path) + .set(HEADER_KEYS.IDEMPOTENCY_KEY, key) + .set('authorization', `ApiKey ${session.apiKey}`) + .send({}) + .expect(200); + expect(typeof body.data.number === 'number').to.be.true; + expect(typeof bodyDupe.data.number === 'number').to.be.true; + expect(body.data.number).not.to.equal(bodyDupe.data.number); + expect(headers[HEADER_KEYS.IDEMPOTENCY_KEY]).to.eq(undefined); + }); + it('should return cached error response for duplicate requests', async () => { + const key = '9'; + const { body, headers } = await session.testAgent + .post(path) + .set(HEADER_KEYS.IDEMPOTENCY_KEY, key) + .set('authorization', `ApiKey ${session.apiKey}`) + .send({ data: 422 }) + .expect(422); + + const { body: bodyDupe, headers: headerDupe } = await session.testAgent + .post(path) + .set(HEADER_KEYS.IDEMPOTENCY_KEY, key) + .set('authorization', `ApiKey ${session.apiKey}`) + .send({ data: 422 }) + .expect(422); + expect(JSON.stringify(body)).to.equal(JSON.stringify(bodyDupe)); + + expect(headers[HEADER_KEYS.IDEMPOTENCY_KEY]).to.eq(key); + expect(headerDupe[HEADER_KEYS.IDEMPOTENCY_KEY]).to.eq(key); + }); + it('should return 400 when key bigger than allowed limit', async () => { + const key = Array.from({ length: 256 }) + .fill(0) + .map((i) => i) + .join(''); + const { body } = await session.testAgent + .post(path) + .set(HEADER_KEYS.IDEMPOTENCY_KEY, key) + .set('authorization', `ApiKey ${session.apiKey}`) + .send({ data: 250 }) + .expect(400); + expect(JSON.stringify(body)).to.eq( + JSON.stringify({ + message: `idempotencyKey "${key}" has exceeded the maximum allowed length of 255 characters`, + error: 'Bad Request', + statusCode: 400, + }) + ); + }); + }); + + describe('when disabled', () => { + before(async () => { + session = new UserSession(); + await session.initialize(); + process.env.IS_API_IDEMPOTENCY_ENABLED = 'false'; + }); + + it('should not return cached same response for duplicate requests', async () => { + const key = '10'; + const { body } = await session.testAgent + .post(path) + .set(HEADER_KEYS.IDEMPOTENCY_KEY, key) + .set('authorization', `ApiKey ${session.apiKey}`) + .send({ data: 201 }) + .expect(201); + + const { body: bodyDupe } = await session.testAgent + .post(path) + .set(HEADER_KEYS.IDEMPOTENCY_KEY, key) + .set('authorization', `ApiKey ${session.apiKey}`) + .send({ data: 201 }) + .expect(201); + expect(typeof body.data.number === 'number').to.be.true; + expect(body.data.number).not.to.equal(bodyDupe.data.number); + }); + it('should return non cached response for unique requests', async () => { + const key = '11'; + const key1 = '12'; + const { body } = await session.testAgent + .post(path) + .set(HEADER_KEYS.IDEMPOTENCY_KEY, key) + .set('authorization', `ApiKey ${session.apiKey}`) + .send({ data: 201 }) + .expect(201); + + const { body: bodyDupe } = await session.testAgent + .post(path) + .set(HEADER_KEYS.IDEMPOTENCY_KEY, key1) + .send({ data: 201 }) + .expect(201); + expect(typeof body.data.number === 'number').to.be.true; + expect(typeof bodyDupe.data.number === 'number').to.be.true; + expect(body.data.number).not.to.equal(bodyDupe.data.number); + }); + it('should return non cached response for GET requests', async () => { + const key = '13'; + const { body } = await session.testAgent.get(path).set(HEADER_KEYS.IDEMPOTENCY_KEY, key).send({}).expect(200); + + const { body: bodyDupe } = await session.testAgent + .get(path) + .set(HEADER_KEYS.IDEMPOTENCY_KEY, key) + .set('authorization', `ApiKey ${session.apiKey}`) + .send({}) + .expect(200); + expect(typeof body.data.number === 'number').to.be.true; + expect(typeof bodyDupe.data.number === 'number').to.be.true; + expect(body.data.number).not.to.equal(bodyDupe.data.number); + }); + it('should not return cached error response for duplicate requests', async () => { + const key = '14'; + const { body } = await session.testAgent + .post(path) + .set(HEADER_KEYS.IDEMPOTENCY_KEY, key) + .set('authorization', `ApiKey ${session.apiKey}`) + .send({ data: '500' }) + .expect(500); + + const { body: bodyDupe } = await session.testAgent + .post(path) + .set(HEADER_KEYS.IDEMPOTENCY_KEY, key) + .set('authorization', `ApiKey ${session.apiKey}`) + .send({ data: '500' }) + .expect(500); + expect(JSON.stringify(body)).not.to.equal(JSON.stringify(bodyDupe)); + }); + }); +}); diff --git a/apps/api/package.json b/apps/api/package.json index 312579eb9b9..b1caa9f7f7d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "@novu/api", - "version": "0.20.0", + "version": "0.21.0", "description": "description", "author": "", "private": "true", @@ -38,12 +38,12 @@ "@nestjs/platform-express": "^10.2.2", "@nestjs/swagger": "^7.1.8", "@nestjs/terminus": "^10.0.1", - "@novu/application-generic": "^0.20.0", - "@novu/dal": "^0.20.0", - "@novu/node": "^0.20.0", - "@novu/shared": "^0.20.0", - "@novu/stateless": "^0.20.0", - "@novu/testing": "^0.20.0", + "@novu/application-generic": "^0.21.0", + "@novu/dal": "^0.21.0", + "@novu/node": "^0.21.0", + "@novu/shared": "^0.21.0", + "@novu/stateless": "^0.21.0", + "@novu/testing": "^0.21.0", "@sendgrid/mail": "^7.6.0", "@sentry/hub": "^7.40.0", "@sentry/node": "^7.40.0", diff --git a/apps/api/src/.env.development b/apps/api/src/.env.development index 8af859ce673..410ba4c291a 100644 --- a/apps/api/src/.env.development +++ b/apps/api/src/.env.development @@ -64,3 +64,5 @@ NOVU_SMS_INTEGRATION_SENDER= INTERCOM_IDENTITY_VERIFICATION_SECRET_KEY= LAUNCH_DARKLY_SDK_KEY= + +IS_API_IDEMPOTENCY_ENABLED=false diff --git a/apps/api/src/.env.production b/apps/api/src/.env.production index 988ff2d4d9a..cb1a23d72a7 100644 --- a/apps/api/src/.env.production +++ b/apps/api/src/.env.production @@ -53,3 +53,5 @@ NOVU_SMS_INTEGRATION_SENDER= INTERCOM_IDENTITY_VERIFICATION_SECRET_KEY= LAUNCH_DARKLY_SDK_KEY= + +IS_API_IDEMPOTENCY_ENABLED=false diff --git a/apps/api/src/.env.test b/apps/api/src/.env.test index f8cc72206e5..070251b2084 100644 --- a/apps/api/src/.env.test +++ b/apps/api/src/.env.test @@ -89,3 +89,5 @@ MAX_NOVU_INTEGRATION_SMS_REQUESTS=20 NOVU_SMS_INTEGRATION_ACCOUNT_SID=test NOVU_SMS_INTEGRATION_TOKEN=test NOVU_SMS_INTEGRATION_SENDER=1234567890 + +IS_API_IDEMPOTENCY_ENABLED=true diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 00418026038..05373ac753e 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -31,6 +31,7 @@ import { TopicsModule } from './app/topics/topics.module'; import { InboundParseModule } from './app/inbound-parse/inbound-parse.module'; import { BlueprintModule } from './app/blueprint/blueprint.module'; import { TenantModule } from './app/tenant/tenant.module'; +import { IdempotencyInterceptor } from './app/shared/framework/idempotency.interceptor'; const enterpriseImports = (): Array | ForwardReference> => { const modules: Array | ForwardReference> = []; @@ -78,7 +79,12 @@ const enterpriseModules = enterpriseImports(); const modules = baseModules.concat(enterpriseModules); -const providers: Provider[] = []; +const providers: Provider[] = [ + { + provide: APP_INTERCEPTOR, + useClass: IdempotencyInterceptor, + }, +]; if (process.env.SENTRY_DSN) { modules.push(RavenModule); diff --git a/apps/api/src/app/blueprint/e2e/get-grouped-blueprints.e2e.ts b/apps/api/src/app/blueprint/e2e/get-grouped-blueprints.e2e.ts index cf619d79b55..67e4a5893f3 100644 --- a/apps/api/src/app/blueprint/e2e/get-grouped-blueprints.e2e.ts +++ b/apps/api/src/app/blueprint/e2e/get-grouped-blueprints.e2e.ts @@ -3,7 +3,14 @@ import * as sinon from 'sinon'; import { UserSession } from '@novu/testing'; import { NotificationTemplateRepository, EnvironmentRepository } from '@novu/dal'; -import { EmailBlockTypeEnum, FilterPartTypeEnum, INotificationTemplate, StepTypeEnum } from '@novu/shared'; +import { + EmailBlockTypeEnum, + FieldLogicalOperatorEnum, + FieldOperatorEnum, + FilterPartTypeEnum, + INotificationTemplate, + StepTypeEnum, +} from '@novu/shared'; import { buildGroupedBlueprintsKey, CacheService, @@ -177,13 +184,13 @@ export async function createTemplateFromBlueprint({ { isNegated: false, type: 'GROUP', - value: 'AND', + value: FieldLogicalOperatorEnum.AND, children: [ { on: FilterPartTypeEnum.SUBSCRIBER, field: 'firstName', value: 'test value', - operator: 'EQUAL', + operator: FieldOperatorEnum.EQUAL, }, ], }, diff --git a/apps/api/src/app/change/e2e/get-changes.e2e.ts b/apps/api/src/app/change/e2e/get-changes.e2e.ts index 5ac9ea173fa..7406192ba12 100644 --- a/apps/api/src/app/change/e2e/get-changes.e2e.ts +++ b/apps/api/src/app/change/e2e/get-changes.e2e.ts @@ -1,6 +1,12 @@ import { expect } from 'chai'; import { ChangeRepository } from '@novu/dal'; -import { EmailBlockTypeEnum, StepTypeEnum, FilterPartTypeEnum } from '@novu/shared'; +import { + EmailBlockTypeEnum, + StepTypeEnum, + FilterPartTypeEnum, + FieldLogicalOperatorEnum, + FieldOperatorEnum, +} from '@novu/shared'; import { UserSession } from '@novu/testing'; import { CreateWorkflowRequestDto, UpdateWorkflowRequestDto } from '../../workflows/dto'; @@ -32,13 +38,13 @@ describe('Get changes', () => { { isNegated: false, type: 'GROUP', - value: 'AND', + value: FieldLogicalOperatorEnum.AND, children: [ { on: FilterPartTypeEnum.SUBSCRIBER, field: 'firstName', value: 'test value', - operator: 'EQUAL', + operator: FieldOperatorEnum.EQUAL, }, ], }, diff --git a/apps/api/src/app/change/e2e/promote-changes.e2e.ts b/apps/api/src/app/change/e2e/promote-changes.e2e.ts index 05ac9b983ca..0c7f5b029b8 100644 --- a/apps/api/src/app/change/e2e/promote-changes.e2e.ts +++ b/apps/api/src/app/change/e2e/promote-changes.e2e.ts @@ -13,6 +13,8 @@ import { ChangeEntityTypeEnum, ChannelCTATypeEnum, EmailBlockTypeEnum, + FieldLogicalOperatorEnum, + FieldOperatorEnum, StepTypeEnum, FilterPartTypeEnum, TemplateVariableTypeEnum, @@ -69,13 +71,13 @@ describe('Promote changes', () => { { isNegated: false, type: 'GROUP', - value: 'AND', + value: FieldLogicalOperatorEnum.AND, children: [ { on: FilterPartTypeEnum.SUBSCRIBER, field: 'firstName', value: 'test value', - operator: 'EQUAL', + operator: FieldOperatorEnum.EQUAL, }, ], }, @@ -96,7 +98,7 @@ describe('Promote changes', () => { _parentId: notificationTemplateId, }); - expect(prodVersion._notificationGroupId).to.eq(prodGroup._id); + expect(prodVersion?._notificationGroupId).to.eq(prodGroup._id); }); it('should promote step variables default values', async () => { @@ -204,13 +206,13 @@ describe('Promote changes', () => { { isNegated: false, type: 'GROUP', - value: 'AND', + value: FieldLogicalOperatorEnum.AND, children: [ { on: FilterPartTypeEnum.SUBSCRIBER, field: 'firstName', value: 'test value', - operator: 'EQUAL', + operator: FieldOperatorEnum.EQUAL, }, ], }, @@ -242,7 +244,7 @@ describe('Promote changes', () => { _parentId: notificationTemplateId, } as any); - expect(prodVersion.steps.length).to.eq(0); + expect(prodVersion?.steps.length).to.eq(0); }); it('update active flag on notification template', async () => { @@ -274,7 +276,7 @@ describe('Promote changes', () => { _parentId: notificationTemplateId, }); - expect(prodVersion.active).to.eq(true); + expect(prodVersion?.active).to.eq(true); }); it('update existing message', async () => { @@ -295,13 +297,13 @@ describe('Promote changes', () => { { isNegated: false, type: 'GROUP', - value: 'AND', + value: FieldLogicalOperatorEnum.AND, children: [ { on: FilterPartTypeEnum.SUBSCRIBER, field: 'firstName', value: 'test value', - operator: 'EQUAL', + operator: FieldOperatorEnum.EQUAL, }, ], }, @@ -352,7 +354,7 @@ describe('Promote changes', () => { _parentId: step._templateId, }); - expect(prodVersion.name).to.eq('test'); + expect(prodVersion?.name).to.eq('test'); }); it('add one more message', async () => { @@ -373,13 +375,13 @@ describe('Promote changes', () => { { isNegated: false, type: 'GROUP', - value: 'AND', + value: FieldLogicalOperatorEnum.AND, children: [ { on: FilterPartTypeEnum.SUBSCRIBER, field: 'firstName', value: 'test value', - operator: 'EQUAL', + operator: FieldOperatorEnum.EQUAL, }, ], }, @@ -431,13 +433,13 @@ describe('Promote changes', () => { { isNegated: false, type: 'GROUP', - value: 'AND', + value: FieldLogicalOperatorEnum.AND, children: [ { on: FilterPartTypeEnum.SUBSCRIBER, field: 'secondName', value: 'test value', - operator: 'EQUAL', + operator: FieldOperatorEnum.EQUAL, }, ], }, @@ -479,13 +481,13 @@ describe('Promote changes', () => { { isNegated: false, type: 'GROUP', - value: 'AND', + value: FieldLogicalOperatorEnum.AND, children: [ { on: FilterPartTypeEnum.SUBSCRIBER, field: 'firstName', value: 'test value', - operator: 'EQUAL', + operator: FieldOperatorEnum.EQUAL, }, ], }, @@ -521,13 +523,13 @@ describe('Promote changes', () => { { isNegated: false, type: 'GROUP', - value: 'AND', + value: FieldLogicalOperatorEnum.AND, children: [ { on: FilterPartTypeEnum.SUBSCRIBER, field: 'firstName', value: 'test value', - operator: 'EQUAL', + operator: FieldOperatorEnum.EQUAL, }, ], }, @@ -632,13 +634,13 @@ describe('Promote changes', () => { { isNegated: false, type: 'GROUP', - value: 'AND', + value: FieldLogicalOperatorEnum.AND, children: [ { on: FilterPartTypeEnum.SUBSCRIBER, field: 'firstName', value: 'test value', - operator: 'EQUAL', + operator: FieldOperatorEnum.EQUAL, }, ], }, @@ -659,7 +661,7 @@ describe('Promote changes', () => { _parentId: notificationTemplateId, }); - expect(prodVersion.isBlueprint).to.equal(true); + expect(prodVersion?.isBlueprint).to.equal(true); }); it('should merge creation, and status changes to one change', async () => { @@ -724,9 +726,15 @@ describe('Promote changes', () => { }); }); - async function getProductionEnvironment() { - return await environmentRepository.findOne({ + async function getProductionEnvironment(): Promise { + const production = await environmentRepository.findOne({ _parentId: session.environment._id, }); + + if (!production) { + throw new Error('No production environment'); + } + + return production; } }); diff --git a/apps/api/src/app/events/e2e/send-message-push.e2e.ts b/apps/api/src/app/events/e2e/send-message-push.e2e.ts index e9cafbbea4b..18e5b0869b7 100644 --- a/apps/api/src/app/events/e2e/send-message-push.e2e.ts +++ b/apps/api/src/app/events/e2e/send-message-push.e2e.ts @@ -12,6 +12,8 @@ import { UserSession } from '@novu/testing'; const axiosInstance = axios.create(); +const ORIGINAL_IS_MULTI_PROVIDER_CONFIGURATION_ENABLED = process.env.IS_MULTI_PROVIDER_CONFIGURATION_ENABLED; + describe('Trigger event - Send Push Notification - /v1/events/trigger (POST)', () => { let session: UserSession; let template: NotificationTemplateEntity; @@ -39,7 +41,7 @@ describe('Trigger event - Send Push Notification - /v1/events/trigger (POST)', ( }); after(() => { - process.env.IS_MULTI_PROVIDER_CONFIGURATION_ENABLED = 'false'; + process.env.IS_MULTI_PROVIDER_CONFIGURATION_ENABLED = ORIGINAL_IS_MULTI_PROVIDER_CONFIGURATION_ENABLED; }); describe('Multiple providers active', () => { 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 8d1eb8a7d2b..66474841a14 100644 --- a/apps/api/src/app/events/e2e/trigger-event.e2e.ts +++ b/apps/api/src/app/events/e2e/trigger-event.e2e.ts @@ -19,13 +19,15 @@ import { UserSession, SubscribersService } from '@novu/testing'; import { ChannelTypeEnum, EmailBlockTypeEnum, + FieldLogicalOperatorEnum, + FieldOperatorEnum, + FilterPartTypeEnum, StepTypeEnum, IEmailBlock, ISubscribersDefine, TemplateVariableTypeEnum, EmailProviderIdEnum, SmsProviderIdEnum, - FilterPartTypeEnum, DigestUnitEnum, DelayTypeEnum, PreviousStepTypeEnum, @@ -60,6 +62,7 @@ describe(`Trigger event - ${eventTriggerPath} (POST)`, function () { describe(`Trigger Event - ${eventTriggerPath} (POST)`, function () { beforeEach(async () => { + process.env.LAUNCH_DARKLY_SDK_KEY = ''; session = new UserSession(); await session.initialize(); template = await session.createTemplate(); @@ -90,11 +93,11 @@ describe(`Trigger event - ${eventTriggerPath} (POST)`, function () { { isNegated: false, type: 'GROUP', - value: 'AND', + value: FieldLogicalOperatorEnum.AND, children: [ { on: FilterPartTypeEnum.PAYLOAD, - operator: 'IS_DEFINED', + operator: FieldOperatorEnum.IS_DEFINED, field: 'exclude', value: '', }, @@ -170,11 +173,11 @@ describe(`Trigger event - ${eventTriggerPath} (POST)`, function () { { isNegated: false, type: 'GROUP', - value: 'AND', + value: FieldLogicalOperatorEnum.AND, children: [ { on: FilterPartTypeEnum.PAYLOAD, - operator: 'IS_DEFINED', + operator: FieldOperatorEnum.IS_DEFINED, field: 'exclude', value: '', }, @@ -250,11 +253,11 @@ describe(`Trigger event - ${eventTriggerPath} (POST)`, function () { { isNegated: false, type: 'GROUP', - value: 'AND', + value: FieldLogicalOperatorEnum.AND, children: [ { on: FilterPartTypeEnum.PAYLOAD, - operator: 'IS_DEFINED', + operator: FieldOperatorEnum.IS_DEFINED, field: 'exclude', value: '', }, @@ -331,11 +334,11 @@ describe(`Trigger event - ${eventTriggerPath} (POST)`, function () { { isNegated: false, type: 'GROUP', - value: 'AND', + value: FieldLogicalOperatorEnum.AND, children: [ { on: FilterPartTypeEnum.PAYLOAD, - operator: 'IS_DEFINED', + operator: FieldOperatorEnum.IS_DEFINED, field: 'exclude', value: '', }, @@ -397,7 +400,7 @@ describe(`Trigger event - ${eventTriggerPath} (POST)`, function () { _environmentId: session.environment._id, conditions: [ { - children: [{ field: 'identifier', value: 'test', operator: 'EQUAL', on: 'tenant' }], + children: [{ field: 'identifier', value: 'test', operator: FieldOperatorEnum.EQUAL, on: 'tenant' }], }, ], active: true, @@ -436,10 +439,10 @@ describe(`Trigger event - ${eventTriggerPath} (POST)`, function () { _environmentId: session.environment._id, conditions: [ { - value: 'OR', + value: FieldLogicalOperatorEnum.OR, children: [ - { field: 'identifier', value: 'test3', operator: 'EQUAL', on: 'tenant' }, - { field: 'identifier', value: 'test2', operator: 'EQUAL', on: 'tenant' }, + { field: 'identifier', value: 'test3', operator: FieldOperatorEnum.EQUAL, on: 'tenant' }, + { field: 'identifier', value: 'test2', operator: FieldOperatorEnum.EQUAL, on: 'tenant' }, ], }, ], @@ -496,7 +499,7 @@ describe(`Trigger event - ${eventTriggerPath} (POST)`, function () { _environmentId: session.environment._id, conditions: [ { - children: [{ field: 'identifier', value: 'test1', operator: 'EQUAL', on: 'tenant' }], + children: [{ field: 'identifier', value: 'test1', operator: FieldOperatorEnum.EQUAL, on: 'tenant' }], }, ], active: true, @@ -1701,13 +1704,13 @@ describe(`Trigger event - ${eventTriggerPath} (POST)`, function () { type: 'GROUP', - value: 'AND', + value: FieldLogicalOperatorEnum.AND, children: [ { field: 'run', value: 'true', - operator: 'EQUAL', + operator: FieldOperatorEnum.EQUAL, on: FilterPartTypeEnum.PAYLOAD, }, ], @@ -1734,13 +1737,13 @@ describe(`Trigger event - ${eventTriggerPath} (POST)`, function () { type: 'GROUP', - value: 'AND', + value: FieldLogicalOperatorEnum.AND, children: [ { field: 'subscriberId', value: subscriber.subscriberId, - operator: 'NOT_EQUAL', + operator: FieldOperatorEnum.NOT_EQUAL, on: FilterPartTypeEnum.SUBSCRIBER, }, ], @@ -1799,12 +1802,12 @@ describe(`Trigger event - ${eventTriggerPath} (POST)`, function () { { isNegated: false, type: 'GROUP', - value: 'AND', + value: FieldLogicalOperatorEnum.AND, children: [ { field: 'isOnline', value: 'true', - operator: 'EQUAL', + operator: FieldOperatorEnum.EQUAL, on: FilterPartTypeEnum.WEBHOOK, webhookUrl: 'www.user.com/webhook', }, @@ -1901,12 +1904,12 @@ describe(`Trigger event - ${eventTriggerPath} (POST)`, function () { { isNegated: false, type: 'GROUP', - value: 'AND', + value: FieldLogicalOperatorEnum.AND, children: [ { field: 'isOnline', value: 'true', - operator: 'EQUAL', + operator: FieldOperatorEnum.EQUAL, on: FilterPartTypeEnum.WEBHOOK, webhookUrl: 'www.user.com/webhook', }, @@ -1966,12 +1969,12 @@ describe(`Trigger event - ${eventTriggerPath} (POST)`, function () { { isNegated: false, type: 'GROUP', - value: 'AND', + value: FieldLogicalOperatorEnum.AND, children: [ { field: 'isOnline', value: 'true', - operator: 'EQUAL', + operator: FieldOperatorEnum.EQUAL, on: FilterPartTypeEnum.WEBHOOK, webhookUrl: 'www.user.com/webhook', }, @@ -2228,7 +2231,7 @@ describe(`Trigger event - ${eventTriggerPath} (POST)`, function () { { isNegated: false, type: 'GROUP', - value: 'AND', + value: FieldLogicalOperatorEnum.AND, children: [ { on: FilterPartTypeEnum.PREVIOUS_STEP, @@ -2320,7 +2323,7 @@ describe(`Trigger event - ${eventTriggerPath} (POST)`, function () { { isNegated: false, type: 'GROUP', - value: 'AND', + value: FieldLogicalOperatorEnum.AND, children: [ { on: FilterPartTypeEnum.PREVIOUS_STEP, diff --git a/apps/api/src/app/integrations/dtos/credentials.dto.ts b/apps/api/src/app/integrations/dtos/credentials.dto.ts index 5701d25fbc2..eaed4cdc65f 100644 --- a/apps/api/src/app/integrations/dtos/credentials.dto.ts +++ b/apps/api/src/app/integrations/dtos/credentials.dto.ts @@ -136,4 +136,34 @@ export class CredentialsDto implements ICredentials { @IsString() @IsOptional() ipPoolName?: string; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + apiKeyRequestHeader?: string; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + secretKeyRequestHeader?: string; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + idPath?: string; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + datePath?: string; + + @ApiPropertyOptional() + @IsBoolean() + @IsOptional() + authenticateByToken?: boolean; + + @ApiPropertyOptional() + @IsString() + @IsOptional() + authenticationTokenKey?: string; } 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 8d99417548d..6fb6813fffa 100644 --- a/apps/api/src/app/integrations/e2e/create-integration.e2e.ts +++ b/apps/api/src/app/integrations/e2e/create-integration.e2e.ts @@ -4,6 +4,7 @@ import { ChannelTypeEnum, ChatProviderIdEnum, EmailProviderIdEnum, + FieldOperatorEnum, InAppProviderIdEnum, PushProviderIdEnum, SmsProviderIdEnum, @@ -106,7 +107,7 @@ describe('Create Integration - /integration (POST)', function () { check: false, conditions: [ { - children: [{ field: 'identifier', value: 'test', operator: 'EQUAL', on: 'tenant' }], + children: [{ field: 'identifier', value: 'test', operator: FieldOperatorEnum.EQUAL, on: 'tenant' }], }, ], }; diff --git a/apps/api/src/app/shared/framework/idempotency.interceptor.ts b/apps/api/src/app/shared/framework/idempotency.interceptor.ts new file mode 100644 index 00000000000..566824c0cd0 --- /dev/null +++ b/apps/api/src/app/shared/framework/idempotency.interceptor.ts @@ -0,0 +1,247 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, + Logger, + HttpException, + InternalServerErrorException, + ServiceUnavailableException, + UnprocessableEntityException, + BadRequestException, + ConflictException, +} from '@nestjs/common'; +import { CacheService } from '@novu/application-generic'; +import { Observable, of, throwError } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; +import { createHash } from 'crypto'; +import * as jwt from 'jsonwebtoken'; +import { IJwtPayload } from '@novu/shared'; + +const LOG_CONTEXT = 'IdempotencyInterceptor'; +const IDEMPOTENCY_CACHE_TTL = 60 * 60 * 24; //24h +const IDEMPOTENCY_PROGRESS_TTL = 60 * 5; //5min + +const HEADER_KEYS = { + IDEMPOTENCY_KEY: 'Idempotency-Key', + RETRY_AFTER: 'Retry-After', + IDEMPOTENCY_REPLAY: 'Idempotency-Replay', + LINK: 'Link', +}; + +const DOCS_LINK = 'docs.novu.co/idempotency'; + +enum ReqStatusEnum { + PROGRESS = 'in-progress', + SUCCESS = 'success', + ERROR = 'error', +} + +@Injectable() +export class IdempotencyInterceptor implements NestInterceptor { + constructor(private readonly cacheService: CacheService) {} + + async intercept(context: ExecutionContext, next: CallHandler): Promise> { + const request = context.switchToHttp().getRequest(); + const idempotencyKey = this.getIdempotencyKey(context); + const isEnabled = process.env.IS_API_IDEMPOTENCY_ENABLED == 'true'; + if (!isEnabled || !idempotencyKey || !['post', 'patch'].includes(request.method.toLowerCase())) { + return next.handle(); + } + if (idempotencyKey?.length > 255) { + return throwError( + () => + new BadRequestException( + `idempotencyKey "${idempotencyKey}" has exceeded the maximum allowed length of 255 characters` + ) + ); + } + const cacheKey = this.getCacheKey(context); + + try { + const bodyHash = this.hashRequestBody(request.body); + //if 1st time we are seeing the request, marks the request as in-progress if not, does nothing + const isNewReq = await this.setCache( + cacheKey, + { status: ReqStatusEnum.PROGRESS, bodyHash }, + IDEMPOTENCY_PROGRESS_TTL, + true + ); + // Check if the idempotency key is in the cache + if (isNewReq) { + return await this.handleNewRequest(context, next, bodyHash); + } else { + return await this.handlerDuplicateRequest(context, bodyHash); + } + } catch (err) { + Logger.warn( + `An error occurred while making idempotency check, key:${idempotencyKey}. error: ${err.message}`, + LOG_CONTEXT + ); + if (err instanceof HttpException) { + return throwError(() => err); + } + } + + //something unexpected happened, both cached response and handler did not execute as expected + return throwError(() => new ServiceUnavailableException()); + } + + private getIdempotencyKey(context: ExecutionContext): string | undefined { + const request = context.switchToHttp().getRequest(); + + return request.headers[HEADER_KEYS.IDEMPOTENCY_KEY.toLocaleLowerCase()]; + } + + private getReqUser(context: ExecutionContext): IJwtPayload | null { + const req = context.switchToHttp().getRequest(); + if (req?.user?.organizationId) { + return req.user; + } + if (req.headers?.authorization?.length) { + const token = req.headers.authorization.split(' ')[1]; + if (token) { + return jwt.decode(token); + } + } + + return null; + } + + private getCacheKey(context: ExecutionContext): string { + const { organizationId } = this.getReqUser(context) || {}; + const env = process.env.NODE_ENV; + + return `${env}-${organizationId}-${this.getIdempotencyKey(context)}`; + } + + async setCache( + key: string, + val: { status: ReqStatusEnum; bodyHash: string; data?: any; statusCode?: number }, + ttl: number, + ifNotExists?: boolean + ): Promise { + try { + if (ifNotExists) { + return await this.cacheService.setIfNotExist(key, JSON.stringify(val), { ttl }); + } + await this.cacheService.set(key, JSON.stringify(val), { ttl }); + } catch (err) { + Logger.warn(`An error occurred while setting idempotency cache, key:${key} error: ${err.message}`, LOG_CONTEXT); + } + + return null; + } + + private buildError(error: any): HttpException { + const statusCode = error.status || error.response?.statusCode || 500; + if (statusCode == 500 && !error.response) { + //some unhandled exception occurred + return new InternalServerErrorException(); + } + + return new HttpException(error.response || error.message, statusCode, error.response?.options); + } + + private setHeaders(response: any, headers: Record) { + Object.keys(headers).map((key) => { + if (headers[key]) { + response.set(key, headers[key]); + } + }); + } + + private hashRequestBody(body: object): string { + const hash = createHash('blake2s256'); + hash.update(Buffer.from(JSON.stringify(body))); + + return hash.digest('hex'); + } + + private async handlerDuplicateRequest(context: ExecutionContext, bodyHash: string): Promise> { + const cacheKey = this.getCacheKey(context); + const idempotencyKey = this.getIdempotencyKey(context)!; + const data = await this.cacheService.get(cacheKey); + this.setHeaders(context.switchToHttp().getResponse(), { [HEADER_KEYS.IDEMPOTENCY_KEY]: idempotencyKey }); + const parsed = JSON.parse(data); + if (parsed.status === ReqStatusEnum.PROGRESS) { + // api call is in progress, so client need to handle this case + Logger.error(`previous api call in progress rejecting the request. key:${idempotencyKey}`, LOG_CONTEXT); + this.setHeaders(context.switchToHttp().getResponse(), { + [HEADER_KEYS.RETRY_AFTER]: `1`, + [HEADER_KEYS.LINK]: DOCS_LINK, + }); + + throw new ConflictException( + `Request with key "${idempotencyKey}" is currently being processed. Please retry after 1 second` + ); + } + if (bodyHash !== parsed.bodyHash) { + //different body sent than before + Logger.error(`idempotency key is being reused for different bodies. key:${idempotencyKey}`, LOG_CONTEXT); + this.setHeaders(context.switchToHttp().getResponse(), { + [HEADER_KEYS.LINK]: DOCS_LINK, + }); + + throw new UnprocessableEntityException( + `Request with key "${idempotencyKey}" is being reused for a different body` + ); + } + this.setHeaders(context.switchToHttp().getResponse(), { [HEADER_KEYS.IDEMPOTENCY_REPLAY]: 'true' }); + + //already seen the request return cached response + if (parsed.status === ReqStatusEnum.ERROR) { + Logger.error(`returning cached error response. key:${idempotencyKey}`, LOG_CONTEXT); + + throw this.buildError(parsed.data); + } + + return of(parsed.data); + } + + private async handleNewRequest( + context: ExecutionContext, + next: CallHandler, + bodyHash: string + ): Promise> { + const cacheKey = this.getCacheKey(context); + const idempotencyKey = this.getIdempotencyKey(context)!; + + return next.handle().pipe( + map(async (response) => { + const httpResponse = context.switchToHttp().getResponse(); + const statusCode = httpResponse.statusCode; + + // Cache the success response and return it + await this.setCache( + cacheKey, + { status: ReqStatusEnum.SUCCESS, bodyHash, statusCode: statusCode, data: response }, + IDEMPOTENCY_CACHE_TTL + ); + Logger.verbose(`cached the success response for idempotency key:${idempotencyKey}`, LOG_CONTEXT); + this.setHeaders(httpResponse, { [HEADER_KEYS.IDEMPOTENCY_KEY]: idempotencyKey }); + + return response; + }), + catchError((err) => { + const httpException = this.buildError(err); + // Cache the error response and return it + const error = err instanceof HttpException ? err : httpException; + this.setCache( + cacheKey, + { + status: ReqStatusEnum.ERROR, + statusCode: httpException.getStatus(), + bodyHash, + data: error, + }, + IDEMPOTENCY_CACHE_TTL + ).catch(() => {}); + Logger.verbose(`cached the error response for idempotency key:${idempotencyKey}`, LOG_CONTEXT); + this.setHeaders(context.switchToHttp().getResponse(), { [HEADER_KEYS.IDEMPOTENCY_KEY]: idempotencyKey }); + + throw err; + }) + ); + } +} diff --git a/apps/api/src/app/shared/helpers/content.service.spec.ts b/apps/api/src/app/shared/helpers/content.service.spec.ts index 70f80455447..32984c527b1 100644 --- a/apps/api/src/app/shared/helpers/content.service.spec.ts +++ b/apps/api/src/app/shared/helpers/content.service.spec.ts @@ -3,6 +3,8 @@ import { DelayTypeEnum, DigestTypeEnum, DigestUnitEnum, + FieldLogicalOperatorEnum, + FieldOperatorEnum, FilterPartTypeEnum, StepTypeEnum, TriggerContextTypeEnum, @@ -292,13 +294,13 @@ describe('ContentService', function () { { isNegated: false, type: 'GROUP', - value: 'AND', + value: FieldLogicalOperatorEnum.AND, children: [ { on: FilterPartTypeEnum.PAYLOAD, field: 'counter', value: 'test value', - operator: 'EQUAL', + operator: FieldOperatorEnum.EQUAL, }, ], }, diff --git a/apps/api/src/app/testing/dtos/idempotency.dto.ts b/apps/api/src/app/testing/dtos/idempotency.dto.ts new file mode 100644 index 00000000000..6f885116da1 --- /dev/null +++ b/apps/api/src/app/testing/dtos/idempotency.dto.ts @@ -0,0 +1,3 @@ +export class IdempotencyBodyDto { + data: number; +} diff --git a/apps/api/src/app/testing/testing.controller.ts b/apps/api/src/app/testing/testing.controller.ts index f23c67f0e76..1939ca49148 100644 --- a/apps/api/src/app/testing/testing.controller.ts +++ b/apps/api/src/app/testing/testing.controller.ts @@ -1,12 +1,16 @@ -import { Body, Controller, NotFoundException, Post } from '@nestjs/common'; +import { Body, Controller, Get, HttpException, NotFoundException, Post, UseGuards } from '@nestjs/common'; import { DalService } from '@novu/dal'; import { IUserEntity } from '@novu/shared'; import { ISeedDataResponseDto, SeedDataBodyDto } from './dtos/seed-data.dto'; +import { IdempotencyBodyDto } from './dtos/idempotency.dto'; + import { SeedData } from './usecases/seed-data/seed-data.usecase'; import { SeedDataCommand } from './usecases/seed-data/seed-data.command'; import { CreateSession } from './usecases/create-session/create-session.usecase'; import { CreateSessionCommand } from './usecases/create-session/create-session.command'; import { ApiExcludeController } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/framework/auth.guard'; +import { ExternalApiAccessible } from '../auth/framework/external-api.decorator'; @Controller('/testing') @ApiExcludeController() @@ -47,4 +51,28 @@ export class TestingController { return await this.seedDataUsecase.execute(command); } + + @ExternalApiAccessible() + @UseGuards(JwtAuthGuard) + @Post('/idempotency') + async idempotency(@Body() body: IdempotencyBodyDto): Promise<{ number: number }> { + if (process.env.NODE_ENV !== 'test') throw new NotFoundException(); + + if (body.data > 300) { + throw new HttpException(`` + Math.random(), body.data); + } + if (body.data === 250) { + //for testing conflict + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + return { number: Math.random() }; + } + + @Get('/idempotency') + async idempotencyGet(): Promise<{ number: number }> { + if (process.env.NODE_ENV !== 'test') throw new NotFoundException(); + + return { number: Math.random() }; + } } diff --git a/apps/api/src/app/workflows/e2e/create-notification-templates.e2e.ts b/apps/api/src/app/workflows/e2e/create-notification-templates.e2e.ts index 0f404d73e90..68cb6190a25 100644 --- a/apps/api/src/app/workflows/e2e/create-notification-templates.e2e.ts +++ b/apps/api/src/app/workflows/e2e/create-notification-templates.e2e.ts @@ -4,6 +4,8 @@ import { ChannelCTATypeEnum, ChannelTypeEnum, EmailBlockTypeEnum, + FieldLogicalOperatorEnum, + FieldOperatorEnum, StepTypeEnum, INotificationTemplate, TriggerTypeEnum, @@ -80,13 +82,13 @@ describe('Create Workflow - /workflows (POST)', async () => { { isNegated: false, type: 'GROUP', - value: 'AND', + value: FieldLogicalOperatorEnum.AND, children: [ { on: FilterPartTypeEnum.SUBSCRIBER, field: 'firstName', value: 'test value', - operator: 'EQUAL', + operator: FieldOperatorEnum.EQUAL, }, ], }, @@ -504,13 +506,13 @@ export async function createTemplateFromBlueprint({ { isNegated: false, type: 'GROUP', - value: 'AND', + value: FieldLogicalOperatorEnum.AND, children: [ { on: FilterPartTypeEnum.SUBSCRIBER, field: 'firstName', value: 'test value', - operator: 'EQUAL', + operator: FieldOperatorEnum.EQUAL, }, ], }, diff --git a/apps/api/src/types/env.d.ts b/apps/api/src/types/env.d.ts index d5650e5ab3c..7f30bd31b6a 100644 --- a/apps/api/src/types/env.d.ts +++ b/apps/api/src/types/env.d.ts @@ -11,6 +11,7 @@ declare namespace NodeJS { NODE_ENV: 'test' | 'production' | 'dev' | 'ci' | 'local'; PORT: string; DISABLE_USER_REGISTRATION: 'true' | 'false'; + IS_API_IDEMPOTENCY_ENABLED: 'true' | 'false'; FRONT_BASE_URL: string; SENTRY_DSN: string; } diff --git a/apps/inbound-mail/package.json b/apps/inbound-mail/package.json index 6bed05dc73b..787391a1d27 100644 --- a/apps/inbound-mail/package.json +++ b/apps/inbound-mail/package.json @@ -1,6 +1,6 @@ { "name": "@novu/inbound-mail", - "version": "0.20.0", + "version": "0.21.0", "description": "", "author": "", "private": true, @@ -19,8 +19,8 @@ "test": "cross-env TS_NODE_COMPILER_OPTIONS='{\"strictNullChecks\": false}' TZ=UTC NODE_ENV=test E2E_RUNNER=true mocha --trace-warnings --timeout 10000 --require ts-node/register --exit --file e2e/setup.ts src/**/**/*.spec.ts" }, "dependencies": { - "@novu/application-generic": "^0.20.0", - "@novu/shared": "^0.20.0", + "@novu/application-generic": "^0.21.0", + "@novu/shared": "^0.21.0", "@sentry/node": "^7.12.1", "bluebird": "^2.9.30", "dotenv": "^8.6.0", @@ -39,7 +39,7 @@ "winston": "^3.9.0" }, "devDependencies": { - "@novu/testing": "^0.20.0", + "@novu/testing": "^0.21.0", "@types/chai": "^4.2.11", "@types/express": "^4.17.8", "@types/html-to-text": "^9.0.1", diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index bc8805a7a98..7572dff8472 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -15,6 +15,7 @@ COPY libs/dal ./libs/dal COPY libs/testing ./libs/testing COPY packages/client ./packages/client COPY libs/shared ./libs/shared +COPY libs/design-system ./libs/design-system COPY packages/notification-center ./packages/notification-center COPY packages/stateless ./packages/stateless COPY packages/node ./packages/node @@ -27,7 +28,7 @@ COPY pnpm-workspace.yaml . COPY pnpm-lock.yaml . RUN --mount=type=cache,id=pnpm-store-web,target=/root/.pnpm-store\ - pnpm install --reporter=silent + pnpm install --frozen-lockfile RUN pnpm add @babel/core -w diff --git a/apps/web/package.json b/apps/web/package.json index 4bbdb3e9208..c777753e62f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@novu/web", - "version": "0.20.0", + "version": "0.21.0", "private": true, "scripts": { "start": "cross-env PORT=4200 react-app-rewired start", @@ -49,9 +49,9 @@ "@mantine/notifications": "^5.7.1", "@mantine/prism": "^5.7.1", "@mantine/spotlight": "^5.7.1", - "@novu/design-system": "^0.20.0", - "@novu/notification-center": "^0.20.0", - "@novu/shared": "^0.20.0", + "@novu/design-system": "^0.21.0", + "@novu/notification-center": "^0.21.0", + "@novu/shared": "^0.21.0", "@segment/analytics-next": "^1.48.0", "@sentry/react": "^7.40.0", "@sentry/tracing": "^7.40.0", @@ -125,8 +125,8 @@ "@babel/preset-react": "^7.13.13", "@babel/preset-typescript": "^7.13.0", "@babel/runtime": "^7.20.13", - "@novu/dal": "^0.20.0", - "@novu/testing": "^0.20.0", + "@novu/dal": "^0.21.0", + "@novu/testing": "^0.21.0", "@storybook/addon-actions": "^7.4.2", "@storybook/addon-essentials": "^7.4.2", "@storybook/addon-links": "^7.4.2", @@ -147,10 +147,10 @@ "http-server": "^0.13.0", "less-loader": "4.1.0", "nodemon": "^3.0.1", - "react-scripts": "^5.0.1", "react-app-rewired": "^2.2.1", "react-error-overlay": "6.0.11", "react-joyride": "^2.5.3", + "react-scripts": "^5.0.1", "storybook": "^7.4.2", "typescript": "4.9.5", "webpack": "^5.74.0", @@ -185,4 +185,4 @@ } ] } -} \ No newline at end of file +} diff --git a/apps/web/public/static/images/providers/dark/generic-sms.svg b/apps/web/public/static/images/providers/dark/generic-sms.svg new file mode 100644 index 00000000000..87e11a06f5d --- /dev/null +++ b/apps/web/public/static/images/providers/dark/generic-sms.svg @@ -0,0 +1 @@ + diff --git a/apps/web/public/static/images/providers/dark/square/generic-sms.svg b/apps/web/public/static/images/providers/dark/square/generic-sms.svg new file mode 100644 index 00000000000..87e11a06f5d --- /dev/null +++ b/apps/web/public/static/images/providers/dark/square/generic-sms.svg @@ -0,0 +1 @@ + diff --git a/apps/web/public/static/images/providers/light/generic-sms.svg b/apps/web/public/static/images/providers/light/generic-sms.svg new file mode 100644 index 00000000000..f22ac13d527 --- /dev/null +++ b/apps/web/public/static/images/providers/light/generic-sms.svg @@ -0,0 +1 @@ + diff --git a/apps/web/public/static/images/providers/light/square/generic-sms.svg b/apps/web/public/static/images/providers/light/square/generic-sms.svg new file mode 100644 index 00000000000..f22ac13d527 --- /dev/null +++ b/apps/web/public/static/images/providers/light/square/generic-sms.svg @@ -0,0 +1 @@ + diff --git a/apps/web/src/components/conditions/Conditions.tsx b/apps/web/src/components/conditions/Conditions.tsx index 12486d47273..cc1e3167cb8 100644 --- a/apps/web/src/components/conditions/Conditions.tsx +++ b/apps/web/src/components/conditions/Conditions.tsx @@ -3,7 +3,7 @@ import styled from '@emotion/styled'; import { useMemo } from 'react'; import { Control, Controller, useFieldArray, useForm, useWatch } from 'react-hook-form'; -import { FILTER_TO_LABEL, FilterPartTypeEnum } from '@novu/shared'; +import { FILTER_TO_LABEL, FilterPartTypeEnum, FieldLogicalOperatorEnum, FieldOperatorEnum } from '@novu/shared'; import { Button, @@ -135,13 +135,13 @@ export function Conditions({ { return ( ; i - {operator !== 'IS_DEFINED' && ( + {operator !== FieldOperatorEnum.IS_DEFINED && ( { append({ - operator: 'EQUAL', + operator: FieldOperatorEnum.EQUAL, on: 'payload', }); }} @@ -332,28 +332,28 @@ function EqualityForm({ { return ( +