From 313269cf2ee257f75b7d1fbb4d7322dd3f31deb8 Mon Sep 17 00:00:00 2001 From: iamacook Date: Thu, 11 Jul 2024 17:06:54 +0200 Subject: [PATCH 01/37] Add migration for notifications --- migrations/00004_notifications/index.sql | 82 +++ .../__tests__/00004_notifications.spec.ts | 673 ++++++++++++++++++ 2 files changed, 755 insertions(+) create mode 100644 migrations/00004_notifications/index.sql create mode 100644 migrations/__tests__/00004_notifications.spec.ts diff --git a/migrations/00004_notifications/index.sql b/migrations/00004_notifications/index.sql new file mode 100644 index 0000000000..4a5ca1eaed --- /dev/null +++ b/migrations/00004_notifications/index.sql @@ -0,0 +1,82 @@ +DROP TABLE IF EXISTS notification_types, + notification_subscriptions, + notification_mediums, + notification_medium_configurations CASCADE; + +-------------------------------------------- +-- Notification types, e.g.INCOMING_TOKEN -- +-------------------------------------------- +CREATE TABLE notification_types( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL +); + +-- TODO: Confirm these types +INSERT INTO notification_types (name) VALUES + ('DELETED_MULTISIG_TRANSACTION'), + ('EXECUTED_MULTISIG_TRANSACTION'), + ('INCOMING_ETHER'), + ('INCOMING_TOKEN'), + ('MESSAGE_CREATED'), + ('MODULE_TRANSACTION'), + ('NEW_CONFIRMATION'), + ('MESSAGE_CONFIRMATION'), + ('OUTGOING_ETHER'), + ('OUTGOING_TOKEN'), + ('PENDING_MULTISIG_TRANSACTION'), + ('SAFE_CREATED'); + +---------------------------------------------------------------------- +-- Chain-specific Safe notification preferences for a given account -- +---------------------------------------------------------------------- +CREATE TABLE notification_subscriptions( + id SERIAL PRIMARY KEY, + account_id INT NOT NULL, + notification_type_id INT NOT NULL, + chain_id INT NOT NULL, + safe_address VARCHAR(42) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE, + FOREIGN KEY (notification_type_id) REFERENCES notification_types(id) ON DELETE CASCADE, + UNIQUE(account_id, chain_id, safe_address, notification_type_id) +); + +-- Update updated_at when a notification subscription is updated +CREATE OR REPLACE TRIGGER update_notification_subscriptions_updated_at + BEFORE UPDATE ON notification_subscriptions + FOR EACH ROW +EXECUTE FUNCTION update_updated_at_column(); + +--------------------------------------------------- +-- Notification mediums, e.g. PUSH_NOTIFICATIONS -- +--------------------------------------------------- +CREATE TABLE notification_mediums( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL +); + +-- Add PUSH_NOTIFICATIONS as a notification medium +INSERT INTO notification_mediums (name) VALUES + ('PUSH_NOTIFICATIONS'); + +---------------------------------------------------------------- +-- Configuration for a given notification subscription/medium -- +---------------------------------------------------------------- +CREATE TABLE notification_medium_configurations( + id SERIAL PRIMARY KEY, + notification_subscription_id INT NOT NULL, + notification_medium_id INT NOT NULL, + medium_token VARCHAR(255) NOT NULL, + enabled BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + FOREIGN KEY (notification_subscription_id) REFERENCES notification_subscriptions(id) ON DELETE CASCADE, + FOREIGN KEY (notification_medium_id) REFERENCES notification_mediums(id) ON DELETE CASCADE +); + +-- Update updated_at when a notification medium is updated +CREATE OR REPLACE TRIGGER update_notification_medium_configurations_updated_at + BEFORE UPDATE ON notification_medium_configurations + FOR EACH ROW +EXECUTE FUNCTION update_updated_at_column(); \ No newline at end of file diff --git a/migrations/__tests__/00004_notifications.spec.ts b/migrations/__tests__/00004_notifications.spec.ts new file mode 100644 index 0000000000..0bcc73337e --- /dev/null +++ b/migrations/__tests__/00004_notifications.spec.ts @@ -0,0 +1,673 @@ +import { TestDbFactory } from '@/__tests__/db.factory'; +import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; +import { faker } from '@faker-js/faker'; +import postgres from 'postgres'; + +type NotificationTypesRow = { + id: number; + name: string; +}; + +type NotificationSubscriptionsRow = { + id: number; + account_id: number; + notification_type_id: number; + chain_id: number; + safe_address: string; + created_at: Date; + updated_at: Date; +}; + +type NotificationMediumsRow = { + id: number; + name: string; +}; + +type NotificationMediumConfigurationsRow = { + id: number; + notification_subscription_id: number; + notification_medium_id: number; + enabled: boolean; + medium_token: string; + created_at: Date; + updated_at: Date; +}; + +describe('Migration 00004_notifications', () => { + let sql: postgres.Sql; + let migrator: PostgresDatabaseMigrator; + const testDbFactory = new TestDbFactory(); + + beforeAll(async () => { + sql = await testDbFactory.createTestDatabase(faker.string.uuid()); + migrator = new PostgresDatabaseMigrator(sql); + }); + + afterAll(async () => { + await testDbFactory.destroyTestDatabase(sql); + }); + + it('runs successfully', async () => { + const result = await migrator.test({ + migration: '00004_notifications', + after: async (sql: postgres.Sql) => { + return { + notification_types: { + columns: + await sql`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'notification_types'`, + rows: await sql< + Array + >`SELECT * FROM notification_types`, + }, + notification_subscriptions: { + columns: + await sql`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'notification_subscriptions'`, + rows: await sql< + Array + >`SELECT * FROM notification_subscriptions`, + }, + notification_mediums: { + columns: + await sql`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'notification_mediums'`, + rows: await sql< + Array + >`SELECT * FROM notification_mediums`, + }, + notification_medium_configurations: { + columns: + await sql`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'notification_medium_configurations'`, + rows: await sql< + Array + >`SELECT * FROM notification_medium_configurations`, + }, + }; + }, + }); + + expect(result.after).toStrictEqual({ + notification_subscriptions: { + columns: expect.arrayContaining([ + { column_name: 'id' }, + { column_name: 'account_id' }, + { column_name: 'chain_id' }, + { column_name: 'notification_type_id' }, + { column_name: 'safe_address' }, + { column_name: 'created_at' }, + { column_name: 'updated_at' }, + ]), + rows: [], + }, + notification_types: { + columns: expect.arrayContaining([ + { column_name: 'id' }, + { column_name: 'name' }, + ]), + rows: [ + { + id: expect.any(Number), + name: 'DELETED_MULTISIG_TRANSACTION', + }, + { + id: expect.any(Number), + name: 'EXECUTED_MULTISIG_TRANSACTION', + }, + { + id: expect.any(Number), + name: 'INCOMING_ETHER', + }, + { + id: expect.any(Number), + name: 'INCOMING_TOKEN', + }, + { + id: expect.any(Number), + name: 'MESSAGE_CREATED', + }, + { + id: expect.any(Number), + name: 'MODULE_TRANSACTION', + }, + { + id: expect.any(Number), + name: 'NEW_CONFIRMATION', + }, + { + id: expect.any(Number), + name: 'MESSAGE_CONFIRMATION', + }, + { + id: expect.any(Number), + name: 'OUTGOING_ETHER', + }, + { + id: expect.any(Number), + name: 'OUTGOING_TOKEN', + }, + { + id: expect.any(Number), + name: 'PENDING_MULTISIG_TRANSACTION', + }, + { + id: expect.any(Number), + name: 'SAFE_CREATED', + }, + ], + }, + notification_mediums: { + columns: expect.arrayContaining([ + { column_name: 'id' }, + { column_name: 'name' }, + ]), + rows: [ + { + id: expect.any(Number), + name: 'PUSH_NOTIFICATIONS', + }, + ], + }, + notification_medium_configurations: { + columns: expect.arrayContaining([ + { column_name: 'updated_at' }, + { column_name: 'created_at' }, + { column_name: 'notification_subscription_id' }, + { column_name: 'notification_medium_id' }, + { column_name: 'id' }, + { column_name: 'enabled' }, + { column_name: 'medium_token' }, + ]), + rows: [], + }, + }); + }); + + it('should upsert the row timestamps of notification_subscriptions on insertion/update', async () => { + const afterInsert = await migrator.test({ + migration: '00004_notifications', + after: async (sql: postgres.Sql) => { + await sql.begin(async (transaction) => { + // Create account + await transaction`INSERT INTO accounts (address) + VALUES ('0x69');`; + // Add notification subscription to account + await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) + VALUES (1, 1, 1, '0x420')`; + }); + + return { + columns: + await sql`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'notification_subscriptions'`, + rows: await sql< + Array + >`SELECT * FROM notification_subscriptions`, + }; + }, + }); + + expect(afterInsert.after).toStrictEqual({ + columns: expect.arrayContaining([ + { column_name: 'id' }, + { column_name: 'account_id' }, + { column_name: 'chain_id' }, + { column_name: 'notification_type_id' }, + { column_name: 'safe_address' }, + { column_name: 'created_at' }, + { column_name: 'updated_at' }, + ]), + rows: [ + { + id: 1, + chain_id: 1, + account_id: 1, + notification_type_id: 1, + safe_address: '0x420', + created_at: expect.any(Date), + updated_at: expect.any(Date), + }, + ], + }); + + const afterUpdate = await sql< + Array + >`UPDATE notification_subscriptions + SET safe_address = '0x69' + WHERE id = 1 RETURNING *`; + + expect(afterUpdate).toStrictEqual([ + { + id: 1, + chain_id: 1, + account_id: 1, + notification_type_id: 1, + safe_address: '0x69', + // created_at should have remained the same + created_at: afterInsert.after.rows[0].created_at, + updated_at: expect.any(Date), + }, + ]); + // updated_at should have updated + expect(afterInsert.after.rows[0].updated_at).not.toEqual( + afterUpdate[0].updated_at, + ); + }); + + it('should upsert the row timestamps of notification_medium_configurations on insertion/update', async () => { + const afterInsert = await migrator.test({ + migration: '00004_notifications', + after: async (sql: postgres.Sql) => { + await sql.begin(async (transaction) => { + // Create account + await transaction`INSERT INTO accounts (address) + VALUES ('0x69');`; + // Add notification subscription to account + await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) + VALUES (1, 1, 1, '0x420')`; + // Enable notification medium + await transaction`INSERT INTO notification_medium_configurations (notification_subscription_id, notification_medium_id, enabled, medium_token) + VALUES (1, 1, true, '69420')`; + }); + + return { + columns: + await sql`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'notification_medium_configurations'`, + rows: await sql< + Array + >`SELECT * FROM notification_medium_configurations`, + }; + }, + }); + + expect(afterInsert.after).toStrictEqual({ + columns: expect.arrayContaining([ + { column_name: 'id' }, + { column_name: 'notification_subscription_id' }, + { column_name: 'notification_medium_id' }, + { column_name: 'enabled' }, + { column_name: 'medium_token' }, + { column_name: 'created_at' }, + { column_name: 'updated_at' }, + ]), + rows: [ + { + id: 1, + notification_subscription_id: 1, + notification_medium_id: 1, + enabled: true, + medium_token: '69420', + created_at: expect.any(Date), + updated_at: expect.any(Date), + }, + ], + }); + + const afterUpdate = await sql< + Array + >`UPDATE notification_medium_configurations + SET medium_token = '1337' + WHERE id = 1 RETURNING *`; + + expect(afterUpdate).toStrictEqual([ + { + id: 1, + notification_subscription_id: 1, + notification_medium_id: 1, + enabled: true, + medium_token: '1337', + // created_at should have remained the same + created_at: afterInsert.after.rows[0].created_at, + updated_at: expect.any(Date), + }, + ]); + // updated_at should have updated + expect(afterInsert.after.rows[0].updated_at).not.toEqual( + afterUpdate[0].updated_at, + ); + }); + + it('should prevent duplicate subscriptions in notification_subscriptions', async () => { + await migrator.test({ + migration: '00004_notifications', + after: async (sql) => { + await sql.begin(async (transaction) => { + // Create account + await transaction`INSERT INTO accounts (address) + VALUES ('0x69');`; + // Add notification subscription to account + await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) + VALUES (1, 1, 1, '0x420')`; + }); + }, + }); + + // Try to add the same subscription again + await expect(sql< + Array + >`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) + VALUES (1, 1, 1, '0x420')`).rejects.toThrow( + 'duplicate key value violates unique constraint "notification_subscriptions_account_id_chain_id_safe_address_key"', + ); + }); + + it('should delete the subscription and configuration if the account is deleted', async () => { + const result = await migrator.test({ + migration: '00004_notifications', + after: async (sql) => { + await sql.begin(async (transaction) => { + // Create account + await transaction`INSERT INTO accounts (address) + VALUES ('0x69');`; + // Add notification subscription to account on chains 1, 2, 3 + await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) + VALUES (1, 1, 1, '0x420')`; + await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) + VALUES (1, 2, 1, '0x420')`; + await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) + VALUES (1, 3, 1, '0x420')`; + }); + + // Delete account + await sql`DELETE FROM accounts WHERE id = 1`; + + return { + notification_types: await sql< + Array + >`SELECT * FROM notification_types`, + notification_subscriptions: await sql< + Array + >`SELECT * FROM notification_subscriptions`, + notification_mediums: await sql< + Array + >`SELECT * FROM notification_mediums`, + notification_medium_configurations: await sql< + Array + >`SELECT * FROM notification_medium_configurations`, + }; + }, + }); + + expect(result.after).toStrictEqual({ + notification_types: [ + { + id: expect.any(Number), + name: 'DELETED_MULTISIG_TRANSACTION', + }, + { + id: expect.any(Number), + name: 'EXECUTED_MULTISIG_TRANSACTION', + }, + { + id: expect.any(Number), + name: 'INCOMING_ETHER', + }, + { + id: expect.any(Number), + name: 'INCOMING_TOKEN', + }, + { + id: expect.any(Number), + name: 'MESSAGE_CREATED', + }, + { + id: expect.any(Number), + name: 'MODULE_TRANSACTION', + }, + { + id: expect.any(Number), + name: 'NEW_CONFIRMATION', + }, + { + id: expect.any(Number), + name: 'MESSAGE_CONFIRMATION', + }, + { + id: expect.any(Number), + name: 'OUTGOING_ETHER', + }, + { + id: expect.any(Number), + name: 'OUTGOING_TOKEN', + }, + { + id: expect.any(Number), + name: 'PENDING_MULTISIG_TRANSACTION', + }, + { + id: expect.any(Number), + name: 'SAFE_CREATED', + }, + ], + // No subscriptions should exist + notification_subscriptions: [], + notification_mediums: [ + { + id: 1, + name: 'PUSH_NOTIFICATIONS', + }, + ], + notification_medium_configurations: [], + }); + }); + + it('should delete the subscription if the notification_type is deleted', async () => { + const result = await migrator.test({ + migration: '00004_notifications', + after: async (sql) => { + await sql.begin(async (transaction) => { + // Create account + await transaction`INSERT INTO accounts (address) + VALUES ('0x69');`; + // Add notification subscription to account on chains 1, 2, 3 + await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) + VALUES (1, 1, 1, '0x420')`; + await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) + VALUES (1, 2, 1, '0x420')`; + await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) + VALUES (1, 3, 1, '0x420')`; + }); + + // Delete DELETED_MULTISIG_TRANSACTION notification type + await sql`DELETE FROM notification_types WHERE name = 'DELETED_MULTISIG_TRANSACTION'`; + + return { + notification_types: await sql< + Array + >`SELECT * FROM notification_types`, + notification_subscriptions: await sql< + Array + >`SELECT * FROM notification_subscriptions`, + notification_mediums: await sql< + Array + >`SELECT * FROM notification_mediums`, + notification_medium_configurations: await sql< + Array + >`SELECT * FROM notification_medium_configurations`, + }; + }, + }); + + expect(result.after).toStrictEqual({ + notification_types: [ + // DELETED_MULTISIG_TRANSACTION is deleted + { + id: expect.any(Number), + name: 'EXECUTED_MULTISIG_TRANSACTION', + }, + { + id: expect.any(Number), + name: 'INCOMING_ETHER', + }, + { + id: expect.any(Number), + name: 'INCOMING_TOKEN', + }, + { + id: expect.any(Number), + name: 'MESSAGE_CREATED', + }, + { + id: expect.any(Number), + name: 'MODULE_TRANSACTION', + }, + { + id: expect.any(Number), + name: 'NEW_CONFIRMATION', + }, + { + id: expect.any(Number), + name: 'MESSAGE_CONFIRMATION', + }, + { + id: expect.any(Number), + name: 'OUTGOING_ETHER', + }, + { + id: expect.any(Number), + name: 'OUTGOING_TOKEN', + }, + { + id: expect.any(Number), + name: 'PENDING_MULTISIG_TRANSACTION', + }, + { + id: expect.any(Number), + name: 'SAFE_CREATED', + }, + ], + // No subscriptions should exist + notification_subscriptions: [], + notification_mediums: [ + { + id: 1, + name: 'PUSH_NOTIFICATIONS', + }, + ], + notification_medium_configurations: [], + }); + }); + + it('should delete the notification_medium_configuration if the notification_medium is deleted', async () => { + const result = await migrator.test({ + migration: '00004_notifications', + after: async (sql) => { + await sql.begin(async (transaction) => { + // Create account + await transaction`INSERT INTO accounts (address) + VALUES ('0x69');`; + // Add notification subscription to account on chains 1, 2, 3 + await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) + VALUES (1, 1, 1, '0x420')`; + await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) + VALUES (1, 2, 1, '0x420')`; + await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) + VALUES (1, 3, 1, '0x420')`; + + // Enable notification medium + await transaction`INSERT INTO notification_medium_configurations (notification_subscription_id, notification_medium_id, enabled, medium_token) + VALUES (1, 1, true, '69420')`; + }); + + // Delete PUSH_NOTIFICATIONS notification medium + await sql`DELETE FROM notification_mediums WHERE name = 'PUSH_NOTIFICATIONS'`; + + return { + notification_types: await sql< + Array + >`SELECT * FROM notification_types`, + notification_subscriptions: await sql< + Array + >`SELECT * FROM notification_subscriptions`, + notification_mediums: await sql< + Array + >`SELECT * FROM notification_mediums`, + notification_medium_configurations: await sql< + Array + >`SELECT * FROM notification_medium_configurations`, + }; + }, + }); + + expect(result.after).toStrictEqual({ + notification_types: [ + { + id: expect.any(Number), + name: 'DELETED_MULTISIG_TRANSACTION', + }, + { + id: expect.any(Number), + name: 'EXECUTED_MULTISIG_TRANSACTION', + }, + { + id: expect.any(Number), + name: 'INCOMING_ETHER', + }, + { + id: expect.any(Number), + name: 'INCOMING_TOKEN', + }, + { + id: expect.any(Number), + name: 'MESSAGE_CREATED', + }, + { + id: expect.any(Number), + name: 'MODULE_TRANSACTION', + }, + { + id: expect.any(Number), + name: 'NEW_CONFIRMATION', + }, + { + id: expect.any(Number), + name: 'MESSAGE_CONFIRMATION', + }, + { + id: expect.any(Number), + name: 'OUTGOING_ETHER', + }, + { + id: expect.any(Number), + name: 'OUTGOING_TOKEN', + }, + { + id: expect.any(Number), + name: 'PENDING_MULTISIG_TRANSACTION', + }, + { + id: expect.any(Number), + name: 'SAFE_CREATED', + }, + ], + notification_subscriptions: [ + { + id: 1, + chain_id: 1, + account_id: 1, + notification_type_id: 1, + safe_address: '0x420', + created_at: expect.any(Date), + updated_at: expect.any(Date), + }, + { + id: 2, + chain_id: 2, + account_id: 1, + notification_type_id: 1, + safe_address: '0x420', + created_at: expect.any(Date), + updated_at: expect.any(Date), + }, + { + id: 3, + chain_id: 3, + account_id: 1, + notification_type_id: 1, + safe_address: '0x420', + created_at: expect.any(Date), + updated_at: expect.any(Date), + }, + ], + notification_mediums: [], + // No configurations should exist + notification_medium_configurations: [], + }); + }); +}); From 82e755b69b2f8266688a3b499dea89ec5b9b6bd2 Mon Sep 17 00:00:00 2001 From: iamacook Date: Fri, 12 Jul 2024 09:19:31 +0200 Subject: [PATCH 02/37] Rename token column --- migrations/00004_notifications/index.sql | 2 +- migrations/__tests__/00004_notifications.spec.ts | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/migrations/00004_notifications/index.sql b/migrations/00004_notifications/index.sql index 4a5ca1eaed..ea837b41c7 100644 --- a/migrations/00004_notifications/index.sql +++ b/migrations/00004_notifications/index.sql @@ -67,7 +67,7 @@ CREATE TABLE notification_medium_configurations( id SERIAL PRIMARY KEY, notification_subscription_id INT NOT NULL, notification_medium_id INT NOT NULL, - medium_token VARCHAR(255) NOT NULL, + cloud_messaging_token VARCHAR(255) NOT NULL, enabled BOOLEAN DEFAULT FALSE, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), diff --git a/migrations/__tests__/00004_notifications.spec.ts b/migrations/__tests__/00004_notifications.spec.ts index 0bcc73337e..f3e2e18f26 100644 --- a/migrations/__tests__/00004_notifications.spec.ts +++ b/migrations/__tests__/00004_notifications.spec.ts @@ -28,7 +28,7 @@ type NotificationMediumConfigurationsRow = { notification_subscription_id: number; notification_medium_id: number; enabled: boolean; - medium_token: string; + cloud_messaging_token: string; created_at: Date; updated_at: Date; }; @@ -173,7 +173,7 @@ describe('Migration 00004_notifications', () => { { column_name: 'notification_medium_id' }, { column_name: 'id' }, { column_name: 'enabled' }, - { column_name: 'medium_token' }, + { column_name: 'cloud_messaging_token' }, ]), rows: [], }, @@ -262,7 +262,7 @@ describe('Migration 00004_notifications', () => { await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) VALUES (1, 1, 1, '0x420')`; // Enable notification medium - await transaction`INSERT INTO notification_medium_configurations (notification_subscription_id, notification_medium_id, enabled, medium_token) + await transaction`INSERT INTO notification_medium_configurations (notification_subscription_id, notification_medium_id, enabled, cloud_messaging_token) VALUES (1, 1, true, '69420')`; }); @@ -282,7 +282,7 @@ describe('Migration 00004_notifications', () => { { column_name: 'notification_subscription_id' }, { column_name: 'notification_medium_id' }, { column_name: 'enabled' }, - { column_name: 'medium_token' }, + { column_name: 'cloud_messaging_token' }, { column_name: 'created_at' }, { column_name: 'updated_at' }, ]), @@ -292,7 +292,7 @@ describe('Migration 00004_notifications', () => { notification_subscription_id: 1, notification_medium_id: 1, enabled: true, - medium_token: '69420', + cloud_messaging_token: '69420', created_at: expect.any(Date), updated_at: expect.any(Date), }, @@ -302,7 +302,7 @@ describe('Migration 00004_notifications', () => { const afterUpdate = await sql< Array >`UPDATE notification_medium_configurations - SET medium_token = '1337' + SET cloud_messaging_token = '1337' WHERE id = 1 RETURNING *`; expect(afterUpdate).toStrictEqual([ @@ -311,7 +311,7 @@ describe('Migration 00004_notifications', () => { notification_subscription_id: 1, notification_medium_id: 1, enabled: true, - medium_token: '1337', + cloud_messaging_token: '1337', // created_at should have remained the same created_at: afterInsert.after.rows[0].created_at, updated_at: expect.any(Date), @@ -561,7 +561,7 @@ describe('Migration 00004_notifications', () => { VALUES (1, 3, 1, '0x420')`; // Enable notification medium - await transaction`INSERT INTO notification_medium_configurations (notification_subscription_id, notification_medium_id, enabled, medium_token) + await transaction`INSERT INTO notification_medium_configurations (notification_subscription_id, notification_medium_id, enabled, cloud_messaging_token) VALUES (1, 1, true, '69420')`; }); From be971ab5dc67222c8021f7570d649f37fc4067d4 Mon Sep 17 00:00:00 2001 From: iamacook Date: Fri, 12 Jul 2024 09:26:36 +0200 Subject: [PATCH 03/37] Add `cloud_messaging_token` <> `PUSH_NOTIFICATIONS` medium constraints --- migrations/00004_notifications/index.sql | 34 +++++++++++- .../__tests__/00004_notifications.spec.ts | 52 +++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/migrations/00004_notifications/index.sql b/migrations/00004_notifications/index.sql index ea837b41c7..d0a03d1d7c 100644 --- a/migrations/00004_notifications/index.sql +++ b/migrations/00004_notifications/index.sql @@ -79,4 +79,36 @@ CREATE TABLE notification_medium_configurations( CREATE OR REPLACE TRIGGER update_notification_medium_configurations_updated_at BEFORE UPDATE ON notification_medium_configurations FOR EACH ROW -EXECUTE FUNCTION update_updated_at_column(); \ No newline at end of file +EXECUTE FUNCTION update_updated_at_column(); + +-- Require cloud_messaging_token to be set with PUSH_NOTIFICATIONS +CREATE OR REPLACE FUNCTION require_cloud_messaging_token_for_push_notifications() +RETURNS TRIGGER AS $$ +BEGIN + IF (NEW.id = (SELECT id FROM notification_mediums WHERE name = 'PUSH_NOTIFICATIONS') AND NEW.cloud_messaging_token IS NULL) THEN + RAISE EXCEPTION 'cloud_messaging_token is required for PUSH_NOTIFICATIONS of notification_mediums'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_require_cloud_messaging_token_for_push_notifications +BEFORE INSERT OR UPDATE ON notification_medium_configurations +FOR EACH ROW +EXECUTE FUNCTION require_cloud_messaging_token_for_push_notifications(); + +-- Only allow cloud_messaging_token to be set for notification_mediums.name PUSH_NOTIFICATIONS +CREATE OR REPLACE FUNCTION check_cloud_messaging_token_medium() +RETURNS TRIGGER AS $$ +BEGIN + IF (NEW.cloud_messaging_token IS NOT NULL AND NEW.notification_medium_id != (SELECT id FROM notification_mediums WHERE name = 'PUSH_NOTIFICATIONS')) THEN + RAISE EXCEPTION 'cloud_messaging_token can only be set for PUSH_NOTIFICATIONS of notification_mediums'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trigger_check_cloud_messaging_token_medium +BEFORE INSERT OR UPDATE ON notification_medium_configurations +FOR EACH ROW +EXECUTE FUNCTION check_cloud_messaging_token_medium(); \ No newline at end of file diff --git a/migrations/__tests__/00004_notifications.spec.ts b/migrations/__tests__/00004_notifications.spec.ts index f3e2e18f26..e635865700 100644 --- a/migrations/__tests__/00004_notifications.spec.ts +++ b/migrations/__tests__/00004_notifications.spec.ts @@ -544,6 +544,58 @@ describe('Migration 00004_notifications', () => { }); }); + it('should not allow a cloud_messaging_token to exist if the notification_medium is not PUSH_NOTIFICATION', async () => { + await migrator.test({ + migration: '00004_notifications', + after: async (sql) => { + await sql.begin(async (transaction) => { + // Add medium that does not support cloud_messaging_token + await transaction`INSERT INTO notification_mediums(name) + VALUES ('NOT_PUSH_NOTIFICATIONS')`; + // Create account + await transaction`INSERT INTO accounts (address) + VALUES ('0x69');`; + // Add notification subscription to account + await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) + VALUES (1, 1, 1, '0x420')`; + }); + }, + }); + + // Get NOT_PUSH_NOTIFICATIONS notification medium + const notPushNotifications = + await sql`SELECT * FROM notification_mediums WHERE name = 'NOT_PUSH_NOTIFICATIONS'`; + const notPushNotificationsId = notPushNotifications[0].id as string; + + // Try to enable a non-PUSH_NOTIFICATIONS medium with a cloud_messaging_token + await expect(sql`INSERT INTO notification_medium_configurations (notification_subscription_id, notification_medium_id, enabled, cloud_messaging_token) + VALUES (1, ${notPushNotificationsId}, true, '69420')`).rejects.toThrow( + 'cloud_messaging_token can only be set for PUSH_NOTIFICATIONS of notification_mediums', + ); + }); + + it('should require a cloud_messaging_token to be set when the notification_medium is PUSH_NOTIFICATION', async () => { + await migrator.test({ + migration: '00004_notifications', + after: async (sql) => { + await sql.begin(async (transaction) => { + // Create account + await transaction`INSERT INTO accounts (address) + VALUES ('0x69');`; + // Add notification subscription to account + await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) + VALUES (1, 1, 1, '0x420')`; + }); + }, + }); + + // Try to enable PUSH_NOTIFICATIONS without a cloud_messaging_token + await expect(sql`INSERT INTO notification_medium_configurations (notification_subscription_id, notification_medium_id, enabled) + VALUES (1, 1, true)`).rejects.toThrow( + 'cloud_messaging_token is required for PUSH_NOTIFICATIONS of notification_mediums', + ); + }); + it('should delete the notification_medium_configuration if the notification_medium is deleted', async () => { const result = await migrator.test({ migration: '00004_notifications', From 3fa4b2fe88ed6ac254bf05c6a9e6cee778420de3 Mon Sep 17 00:00:00 2001 From: iamacook Date: Fri, 12 Jul 2024 09:53:19 +0200 Subject: [PATCH 04/37] Cleanup test --- .../__tests__/00004_notifications.spec.ts | 104 +++++++++--------- 1 file changed, 52 insertions(+), 52 deletions(-) diff --git a/migrations/__tests__/00004_notifications.spec.ts b/migrations/__tests__/00004_notifications.spec.ts index e635865700..c111538f9f 100644 --- a/migrations/__tests__/00004_notifications.spec.ts +++ b/migrations/__tests__/00004_notifications.spec.ts @@ -347,6 +347,58 @@ describe('Migration 00004_notifications', () => { ); }); + it('should not allow a cloud_messaging_token to exist if the notification_medium is not PUSH_NOTIFICATION', async () => { + await migrator.test({ + migration: '00004_notifications', + after: async (sql) => { + await sql.begin(async (transaction) => { + // Add medium that does not support cloud_messaging_token + await transaction`INSERT INTO notification_mediums(name) + VALUES ('NOT_PUSH_NOTIFICATIONS')`; + // Create account + await transaction`INSERT INTO accounts (address) + VALUES ('0x69');`; + // Add notification subscription to account + await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) + VALUES (1, 1, 1, '0x420')`; + }); + }, + }); + + // Get NOT_PUSH_NOTIFICATIONS notification medium + const [notPushNotifications] = await sql< + Array + >`SELECT * FROM notification_mediums WHERE name = 'NOT_PUSH_NOTIFICATIONS'`; + + // Try to enable a non-PUSH_NOTIFICATIONS medium with a cloud_messaging_token + await expect(sql`INSERT INTO notification_medium_configurations (notification_subscription_id, notification_medium_id, enabled, cloud_messaging_token) + VALUES (1, ${notPushNotifications.id}, true, '69420')`).rejects.toThrow( + 'cloud_messaging_token can only be set for PUSH_NOTIFICATIONS of notification_mediums', + ); + }); + + it('should require a cloud_messaging_token to be set when the notification_medium is PUSH_NOTIFICATION', async () => { + await migrator.test({ + migration: '00004_notifications', + after: async (sql) => { + await sql.begin(async (transaction) => { + // Create account + await transaction`INSERT INTO accounts (address) + VALUES ('0x69');`; + // Add notification subscription to account + await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) + VALUES (1, 1, 1, '0x420')`; + }); + }, + }); + + // Try to enable PUSH_NOTIFICATIONS without a cloud_messaging_token + await expect(sql`INSERT INTO notification_medium_configurations (notification_subscription_id, notification_medium_id, enabled) + VALUES (1, 1, true)`).rejects.toThrow( + 'cloud_messaging_token is required for PUSH_NOTIFICATIONS of notification_mediums', + ); + }); + it('should delete the subscription and configuration if the account is deleted', async () => { const result = await migrator.test({ migration: '00004_notifications', @@ -544,58 +596,6 @@ describe('Migration 00004_notifications', () => { }); }); - it('should not allow a cloud_messaging_token to exist if the notification_medium is not PUSH_NOTIFICATION', async () => { - await migrator.test({ - migration: '00004_notifications', - after: async (sql) => { - await sql.begin(async (transaction) => { - // Add medium that does not support cloud_messaging_token - await transaction`INSERT INTO notification_mediums(name) - VALUES ('NOT_PUSH_NOTIFICATIONS')`; - // Create account - await transaction`INSERT INTO accounts (address) - VALUES ('0x69');`; - // Add notification subscription to account - await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) - VALUES (1, 1, 1, '0x420')`; - }); - }, - }); - - // Get NOT_PUSH_NOTIFICATIONS notification medium - const notPushNotifications = - await sql`SELECT * FROM notification_mediums WHERE name = 'NOT_PUSH_NOTIFICATIONS'`; - const notPushNotificationsId = notPushNotifications[0].id as string; - - // Try to enable a non-PUSH_NOTIFICATIONS medium with a cloud_messaging_token - await expect(sql`INSERT INTO notification_medium_configurations (notification_subscription_id, notification_medium_id, enabled, cloud_messaging_token) - VALUES (1, ${notPushNotificationsId}, true, '69420')`).rejects.toThrow( - 'cloud_messaging_token can only be set for PUSH_NOTIFICATIONS of notification_mediums', - ); - }); - - it('should require a cloud_messaging_token to be set when the notification_medium is PUSH_NOTIFICATION', async () => { - await migrator.test({ - migration: '00004_notifications', - after: async (sql) => { - await sql.begin(async (transaction) => { - // Create account - await transaction`INSERT INTO accounts (address) - VALUES ('0x69');`; - // Add notification subscription to account - await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) - VALUES (1, 1, 1, '0x420')`; - }); - }, - }); - - // Try to enable PUSH_NOTIFICATIONS without a cloud_messaging_token - await expect(sql`INSERT INTO notification_medium_configurations (notification_subscription_id, notification_medium_id, enabled) - VALUES (1, 1, true)`).rejects.toThrow( - 'cloud_messaging_token is required for PUSH_NOTIFICATIONS of notification_mediums', - ); - }); - it('should delete the notification_medium_configuration if the notification_medium is deleted', async () => { const result = await migrator.test({ migration: '00004_notifications', From 2a356cf197194fe4e07183e219685f1063df4e49 Mon Sep 17 00:00:00 2001 From: iamacook Date: Wed, 17 Jul 2024 14:46:53 +0200 Subject: [PATCH 05/37] Remove `enabled` column and triggers --- migrations/00004_notifications/index.sql | 35 +--------- .../__tests__/00004_notifications.spec.ts | 65 ++----------------- 2 files changed, 5 insertions(+), 95 deletions(-) diff --git a/migrations/00004_notifications/index.sql b/migrations/00004_notifications/index.sql index d0a03d1d7c..82a375e025 100644 --- a/migrations/00004_notifications/index.sql +++ b/migrations/00004_notifications/index.sql @@ -68,7 +68,6 @@ CREATE TABLE notification_medium_configurations( notification_subscription_id INT NOT NULL, notification_medium_id INT NOT NULL, cloud_messaging_token VARCHAR(255) NOT NULL, - enabled BOOLEAN DEFAULT FALSE, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), FOREIGN KEY (notification_subscription_id) REFERENCES notification_subscriptions(id) ON DELETE CASCADE, @@ -79,36 +78,4 @@ CREATE TABLE notification_medium_configurations( CREATE OR REPLACE TRIGGER update_notification_medium_configurations_updated_at BEFORE UPDATE ON notification_medium_configurations FOR EACH ROW -EXECUTE FUNCTION update_updated_at_column(); - --- Require cloud_messaging_token to be set with PUSH_NOTIFICATIONS -CREATE OR REPLACE FUNCTION require_cloud_messaging_token_for_push_notifications() -RETURNS TRIGGER AS $$ -BEGIN - IF (NEW.id = (SELECT id FROM notification_mediums WHERE name = 'PUSH_NOTIFICATIONS') AND NEW.cloud_messaging_token IS NULL) THEN - RAISE EXCEPTION 'cloud_messaging_token is required for PUSH_NOTIFICATIONS of notification_mediums'; - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER trigger_require_cloud_messaging_token_for_push_notifications -BEFORE INSERT OR UPDATE ON notification_medium_configurations -FOR EACH ROW -EXECUTE FUNCTION require_cloud_messaging_token_for_push_notifications(); - --- Only allow cloud_messaging_token to be set for notification_mediums.name PUSH_NOTIFICATIONS -CREATE OR REPLACE FUNCTION check_cloud_messaging_token_medium() -RETURNS TRIGGER AS $$ -BEGIN - IF (NEW.cloud_messaging_token IS NOT NULL AND NEW.notification_medium_id != (SELECT id FROM notification_mediums WHERE name = 'PUSH_NOTIFICATIONS')) THEN - RAISE EXCEPTION 'cloud_messaging_token can only be set for PUSH_NOTIFICATIONS of notification_mediums'; - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER trigger_check_cloud_messaging_token_medium -BEFORE INSERT OR UPDATE ON notification_medium_configurations -FOR EACH ROW -EXECUTE FUNCTION check_cloud_messaging_token_medium(); \ No newline at end of file +EXECUTE FUNCTION update_updated_at_column(); \ No newline at end of file diff --git a/migrations/__tests__/00004_notifications.spec.ts b/migrations/__tests__/00004_notifications.spec.ts index c111538f9f..e82c8e484a 100644 --- a/migrations/__tests__/00004_notifications.spec.ts +++ b/migrations/__tests__/00004_notifications.spec.ts @@ -27,7 +27,6 @@ type NotificationMediumConfigurationsRow = { id: number; notification_subscription_id: number; notification_medium_id: number; - enabled: boolean; cloud_messaging_token: string; created_at: Date; updated_at: Date; @@ -172,7 +171,6 @@ describe('Migration 00004_notifications', () => { { column_name: 'notification_subscription_id' }, { column_name: 'notification_medium_id' }, { column_name: 'id' }, - { column_name: 'enabled' }, { column_name: 'cloud_messaging_token' }, ]), rows: [], @@ -262,8 +260,8 @@ describe('Migration 00004_notifications', () => { await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) VALUES (1, 1, 1, '0x420')`; // Enable notification medium - await transaction`INSERT INTO notification_medium_configurations (notification_subscription_id, notification_medium_id, enabled, cloud_messaging_token) - VALUES (1, 1, true, '69420')`; + await transaction`INSERT INTO notification_medium_configurations (notification_subscription_id, notification_medium_id, cloud_messaging_token) + VALUES (1, 1, '69420')`; }); return { @@ -281,7 +279,6 @@ describe('Migration 00004_notifications', () => { { column_name: 'id' }, { column_name: 'notification_subscription_id' }, { column_name: 'notification_medium_id' }, - { column_name: 'enabled' }, { column_name: 'cloud_messaging_token' }, { column_name: 'created_at' }, { column_name: 'updated_at' }, @@ -291,7 +288,6 @@ describe('Migration 00004_notifications', () => { id: 1, notification_subscription_id: 1, notification_medium_id: 1, - enabled: true, cloud_messaging_token: '69420', created_at: expect.any(Date), updated_at: expect.any(Date), @@ -310,7 +306,6 @@ describe('Migration 00004_notifications', () => { id: 1, notification_subscription_id: 1, notification_medium_id: 1, - enabled: true, cloud_messaging_token: '1337', // created_at should have remained the same created_at: afterInsert.after.rows[0].created_at, @@ -347,58 +342,6 @@ describe('Migration 00004_notifications', () => { ); }); - it('should not allow a cloud_messaging_token to exist if the notification_medium is not PUSH_NOTIFICATION', async () => { - await migrator.test({ - migration: '00004_notifications', - after: async (sql) => { - await sql.begin(async (transaction) => { - // Add medium that does not support cloud_messaging_token - await transaction`INSERT INTO notification_mediums(name) - VALUES ('NOT_PUSH_NOTIFICATIONS')`; - // Create account - await transaction`INSERT INTO accounts (address) - VALUES ('0x69');`; - // Add notification subscription to account - await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) - VALUES (1, 1, 1, '0x420')`; - }); - }, - }); - - // Get NOT_PUSH_NOTIFICATIONS notification medium - const [notPushNotifications] = await sql< - Array - >`SELECT * FROM notification_mediums WHERE name = 'NOT_PUSH_NOTIFICATIONS'`; - - // Try to enable a non-PUSH_NOTIFICATIONS medium with a cloud_messaging_token - await expect(sql`INSERT INTO notification_medium_configurations (notification_subscription_id, notification_medium_id, enabled, cloud_messaging_token) - VALUES (1, ${notPushNotifications.id}, true, '69420')`).rejects.toThrow( - 'cloud_messaging_token can only be set for PUSH_NOTIFICATIONS of notification_mediums', - ); - }); - - it('should require a cloud_messaging_token to be set when the notification_medium is PUSH_NOTIFICATION', async () => { - await migrator.test({ - migration: '00004_notifications', - after: async (sql) => { - await sql.begin(async (transaction) => { - // Create account - await transaction`INSERT INTO accounts (address) - VALUES ('0x69');`; - // Add notification subscription to account - await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) - VALUES (1, 1, 1, '0x420')`; - }); - }, - }); - - // Try to enable PUSH_NOTIFICATIONS without a cloud_messaging_token - await expect(sql`INSERT INTO notification_medium_configurations (notification_subscription_id, notification_medium_id, enabled) - VALUES (1, 1, true)`).rejects.toThrow( - 'cloud_messaging_token is required for PUSH_NOTIFICATIONS of notification_mediums', - ); - }); - it('should delete the subscription and configuration if the account is deleted', async () => { const result = await migrator.test({ migration: '00004_notifications', @@ -613,8 +556,8 @@ describe('Migration 00004_notifications', () => { VALUES (1, 3, 1, '0x420')`; // Enable notification medium - await transaction`INSERT INTO notification_medium_configurations (notification_subscription_id, notification_medium_id, enabled, cloud_messaging_token) - VALUES (1, 1, true, '69420')`; + await transaction`INSERT INTO notification_medium_configurations (notification_subscription_id, notification_medium_id, cloud_messaging_token) + VALUES (1, 1, '69420')`; }); // Delete PUSH_NOTIFICATIONS notification medium From 6193c4fca6d40ed338f4a217b702b31b43c778c5 Mon Sep 17 00:00:00 2001 From: iamacook Date: Wed, 17 Jul 2024 14:58:53 +0200 Subject: [PATCH 06/37] Add `device_uuid` column --- migrations/00004_notifications/index.sql | 1 + migrations/__tests__/00004_notifications.spec.ts | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/migrations/00004_notifications/index.sql b/migrations/00004_notifications/index.sql index 82a375e025..7543db57a3 100644 --- a/migrations/00004_notifications/index.sql +++ b/migrations/00004_notifications/index.sql @@ -68,6 +68,7 @@ CREATE TABLE notification_medium_configurations( notification_subscription_id INT NOT NULL, notification_medium_id INT NOT NULL, cloud_messaging_token VARCHAR(255) NOT NULL, + device_uuid UUID DEFAULT gen_random_uuid(), created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), FOREIGN KEY (notification_subscription_id) REFERENCES notification_subscriptions(id) ON DELETE CASCADE, diff --git a/migrations/__tests__/00004_notifications.spec.ts b/migrations/__tests__/00004_notifications.spec.ts index e82c8e484a..68cd840d8f 100644 --- a/migrations/__tests__/00004_notifications.spec.ts +++ b/migrations/__tests__/00004_notifications.spec.ts @@ -27,6 +27,7 @@ type NotificationMediumConfigurationsRow = { id: number; notification_subscription_id: number; notification_medium_id: number; + device_uuid: `${string}-${string}-${string}-${string}-${string}`; cloud_messaging_token: string; created_at: Date; updated_at: Date; @@ -171,6 +172,7 @@ describe('Migration 00004_notifications', () => { { column_name: 'notification_subscription_id' }, { column_name: 'notification_medium_id' }, { column_name: 'id' }, + { column_name: 'device_uuid' }, { column_name: 'cloud_messaging_token' }, ]), rows: [], @@ -279,6 +281,7 @@ describe('Migration 00004_notifications', () => { { column_name: 'id' }, { column_name: 'notification_subscription_id' }, { column_name: 'notification_medium_id' }, + { column_name: 'device_uuid' }, { column_name: 'cloud_messaging_token' }, { column_name: 'created_at' }, { column_name: 'updated_at' }, @@ -288,6 +291,7 @@ describe('Migration 00004_notifications', () => { id: 1, notification_subscription_id: 1, notification_medium_id: 1, + device_uuid: expect.any(String), cloud_messaging_token: '69420', created_at: expect.any(Date), updated_at: expect.any(Date), @@ -306,6 +310,7 @@ describe('Migration 00004_notifications', () => { id: 1, notification_subscription_id: 1, notification_medium_id: 1, + device_uuid: expect.any(String), cloud_messaging_token: '1337', // created_at should have remained the same created_at: afterInsert.after.rows[0].created_at, From dc736e78c01ce7e4c767e21b357a0e6c4d494f8d Mon Sep 17 00:00:00 2001 From: iamacook Date: Wed, 17 Jul 2024 15:50:30 +0200 Subject: [PATCH 07/37] Rename `medium` to `channel` --- migrations/00004_notifications/index.sql | 26 ++--- .../__tests__/00004_notifications.spec.ts | 108 +++++++++--------- 2 files changed, 67 insertions(+), 67 deletions(-) diff --git a/migrations/00004_notifications/index.sql b/migrations/00004_notifications/index.sql index 7543db57a3..5dbf5ac441 100644 --- a/migrations/00004_notifications/index.sql +++ b/migrations/00004_notifications/index.sql @@ -1,7 +1,7 @@ DROP TABLE IF EXISTS notification_types, notification_subscriptions, - notification_mediums, - notification_medium_configurations CASCADE; + notification_channels, + notification_channel_configurations CASCADE; -------------------------------------------- -- Notification types, e.g.INCOMING_TOKEN -- @@ -49,34 +49,34 @@ CREATE OR REPLACE TRIGGER update_notification_subscriptions_updated_at EXECUTE FUNCTION update_updated_at_column(); --------------------------------------------------- --- Notification mediums, e.g. PUSH_NOTIFICATIONS -- +-- Notification channels, e.g. PUSH_NOTIFICATIONS -- --------------------------------------------------- -CREATE TABLE notification_mediums( +CREATE TABLE notification_channels( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL ); --- Add PUSH_NOTIFICATIONS as a notification medium -INSERT INTO notification_mediums (name) VALUES +-- Add PUSH_NOTIFICATIONS as a notification channel +INSERT INTO notification_channels (name) VALUES ('PUSH_NOTIFICATIONS'); ---------------------------------------------------------------- --- Configuration for a given notification subscription/medium -- +-- Configuration for a given notification subscription/channel -- ---------------------------------------------------------------- -CREATE TABLE notification_medium_configurations( +CREATE TABLE notification_channel_configurations( id SERIAL PRIMARY KEY, notification_subscription_id INT NOT NULL, - notification_medium_id INT NOT NULL, + notification_channel_id INT NOT NULL, cloud_messaging_token VARCHAR(255) NOT NULL, device_uuid UUID DEFAULT gen_random_uuid(), created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), FOREIGN KEY (notification_subscription_id) REFERENCES notification_subscriptions(id) ON DELETE CASCADE, - FOREIGN KEY (notification_medium_id) REFERENCES notification_mediums(id) ON DELETE CASCADE + FOREIGN KEY (notification_channel_id) REFERENCES notification_channels(id) ON DELETE CASCADE ); --- Update updated_at when a notification medium is updated -CREATE OR REPLACE TRIGGER update_notification_medium_configurations_updated_at - BEFORE UPDATE ON notification_medium_configurations +-- Update updated_at when a notification channel is updated +CREATE OR REPLACE TRIGGER update_notification_channel_configurations_updated_at + BEFORE UPDATE ON notification_channel_configurations FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); \ No newline at end of file diff --git a/migrations/__tests__/00004_notifications.spec.ts b/migrations/__tests__/00004_notifications.spec.ts index 68cd840d8f..23842a7033 100644 --- a/migrations/__tests__/00004_notifications.spec.ts +++ b/migrations/__tests__/00004_notifications.spec.ts @@ -18,15 +18,15 @@ type NotificationSubscriptionsRow = { updated_at: Date; }; -type NotificationMediumsRow = { +type NotificationChannelsRow = { id: number; name: string; }; -type NotificationMediumConfigurationsRow = { +type NotificationChannelConfigurationsRow = { id: number; notification_subscription_id: number; - notification_medium_id: number; + notification_channel_id: number; device_uuid: `${string}-${string}-${string}-${string}-${string}`; cloud_messaging_token: string; created_at: Date; @@ -66,19 +66,19 @@ describe('Migration 00004_notifications', () => { Array >`SELECT * FROM notification_subscriptions`, }, - notification_mediums: { + notification_channels: { columns: - await sql`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'notification_mediums'`, + await sql`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'notification_channels'`, rows: await sql< - Array - >`SELECT * FROM notification_mediums`, + Array + >`SELECT * FROM notification_channels`, }, - notification_medium_configurations: { + notification_channel_configurations: { columns: - await sql`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'notification_medium_configurations'`, + await sql`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'notification_channel_configurations'`, rows: await sql< - Array - >`SELECT * FROM notification_medium_configurations`, + Array + >`SELECT * FROM notification_channel_configurations`, }, }; }, @@ -153,7 +153,7 @@ describe('Migration 00004_notifications', () => { }, ], }, - notification_mediums: { + notification_channels: { columns: expect.arrayContaining([ { column_name: 'id' }, { column_name: 'name' }, @@ -165,12 +165,12 @@ describe('Migration 00004_notifications', () => { }, ], }, - notification_medium_configurations: { + notification_channel_configurations: { columns: expect.arrayContaining([ { column_name: 'updated_at' }, { column_name: 'created_at' }, { column_name: 'notification_subscription_id' }, - { column_name: 'notification_medium_id' }, + { column_name: 'notification_channel_id' }, { column_name: 'id' }, { column_name: 'device_uuid' }, { column_name: 'cloud_messaging_token' }, @@ -250,7 +250,7 @@ describe('Migration 00004_notifications', () => { ); }); - it('should upsert the row timestamps of notification_medium_configurations on insertion/update', async () => { + it('should upsert the row timestamps of notification_channel_configurations on insertion/update', async () => { const afterInsert = await migrator.test({ migration: '00004_notifications', after: async (sql: postgres.Sql) => { @@ -261,17 +261,17 @@ describe('Migration 00004_notifications', () => { // Add notification subscription to account await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) VALUES (1, 1, 1, '0x420')`; - // Enable notification medium - await transaction`INSERT INTO notification_medium_configurations (notification_subscription_id, notification_medium_id, cloud_messaging_token) + // Enable notification channel + await transaction`INSERT INTO notification_channel_configurations (notification_subscription_id, notification_channel_id, cloud_messaging_token) VALUES (1, 1, '69420')`; }); return { columns: - await sql`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'notification_medium_configurations'`, + await sql`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'notification_channel_configurations'`, rows: await sql< - Array - >`SELECT * FROM notification_medium_configurations`, + Array + >`SELECT * FROM notification_channel_configurations`, }; }, }); @@ -280,7 +280,7 @@ describe('Migration 00004_notifications', () => { columns: expect.arrayContaining([ { column_name: 'id' }, { column_name: 'notification_subscription_id' }, - { column_name: 'notification_medium_id' }, + { column_name: 'notification_channel_id' }, { column_name: 'device_uuid' }, { column_name: 'cloud_messaging_token' }, { column_name: 'created_at' }, @@ -290,7 +290,7 @@ describe('Migration 00004_notifications', () => { { id: 1, notification_subscription_id: 1, - notification_medium_id: 1, + notification_channel_id: 1, device_uuid: expect.any(String), cloud_messaging_token: '69420', created_at: expect.any(Date), @@ -300,8 +300,8 @@ describe('Migration 00004_notifications', () => { }); const afterUpdate = await sql< - Array - >`UPDATE notification_medium_configurations + Array + >`UPDATE notification_channel_configurations SET cloud_messaging_token = '1337' WHERE id = 1 RETURNING *`; @@ -309,7 +309,7 @@ describe('Migration 00004_notifications', () => { { id: 1, notification_subscription_id: 1, - notification_medium_id: 1, + notification_channel_id: 1, device_uuid: expect.any(String), cloud_messaging_token: '1337', // created_at should have remained the same @@ -374,12 +374,12 @@ describe('Migration 00004_notifications', () => { notification_subscriptions: await sql< Array >`SELECT * FROM notification_subscriptions`, - notification_mediums: await sql< - Array - >`SELECT * FROM notification_mediums`, - notification_medium_configurations: await sql< - Array - >`SELECT * FROM notification_medium_configurations`, + notification_channels: await sql< + Array + >`SELECT * FROM notification_channels`, + notification_channel_configurations: await sql< + Array + >`SELECT * FROM notification_channel_configurations`, }; }, }); @@ -437,13 +437,13 @@ describe('Migration 00004_notifications', () => { ], // No subscriptions should exist notification_subscriptions: [], - notification_mediums: [ + notification_channels: [ { id: 1, name: 'PUSH_NOTIFICATIONS', }, ], - notification_medium_configurations: [], + notification_channel_configurations: [], }); }); @@ -474,12 +474,12 @@ describe('Migration 00004_notifications', () => { notification_subscriptions: await sql< Array >`SELECT * FROM notification_subscriptions`, - notification_mediums: await sql< - Array - >`SELECT * FROM notification_mediums`, - notification_medium_configurations: await sql< - Array - >`SELECT * FROM notification_medium_configurations`, + notification_channels: await sql< + Array + >`SELECT * FROM notification_channels`, + notification_channel_configurations: await sql< + Array + >`SELECT * FROM notification_channel_configurations`, }; }, }); @@ -534,17 +534,17 @@ describe('Migration 00004_notifications', () => { ], // No subscriptions should exist notification_subscriptions: [], - notification_mediums: [ + notification_channels: [ { id: 1, name: 'PUSH_NOTIFICATIONS', }, ], - notification_medium_configurations: [], + notification_channel_configurations: [], }); }); - it('should delete the notification_medium_configuration if the notification_medium is deleted', async () => { + it('should delete the notification_channel_configuration if the notification_channel is deleted', async () => { const result = await migrator.test({ migration: '00004_notifications', after: async (sql) => { @@ -560,13 +560,13 @@ describe('Migration 00004_notifications', () => { await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) VALUES (1, 3, 1, '0x420')`; - // Enable notification medium - await transaction`INSERT INTO notification_medium_configurations (notification_subscription_id, notification_medium_id, cloud_messaging_token) + // Enable notification channel + await transaction`INSERT INTO notification_channel_configurations (notification_subscription_id, notification_channel_id, cloud_messaging_token) VALUES (1, 1, '69420')`; }); - // Delete PUSH_NOTIFICATIONS notification medium - await sql`DELETE FROM notification_mediums WHERE name = 'PUSH_NOTIFICATIONS'`; + // Delete PUSH_NOTIFICATIONS notification channel + await sql`DELETE FROM notification_channels WHERE name = 'PUSH_NOTIFICATIONS'`; return { notification_types: await sql< @@ -575,12 +575,12 @@ describe('Migration 00004_notifications', () => { notification_subscriptions: await sql< Array >`SELECT * FROM notification_subscriptions`, - notification_mediums: await sql< - Array - >`SELECT * FROM notification_mediums`, - notification_medium_configurations: await sql< - Array - >`SELECT * FROM notification_medium_configurations`, + notification_channels: await sql< + Array + >`SELECT * FROM notification_channels`, + notification_channel_configurations: await sql< + Array + >`SELECT * FROM notification_channel_configurations`, }; }, }); @@ -665,9 +665,9 @@ describe('Migration 00004_notifications', () => { updated_at: expect.any(Date), }, ], - notification_mediums: [], + notification_channels: [], // No configurations should exist - notification_medium_configurations: [], + notification_channel_configurations: [], }); }); }); From aa939ff5efecd0358debdd820dfbbe894d5608cd Mon Sep 17 00:00:00 2001 From: iamacook Date: Mon, 22 Jul 2024 08:33:08 +0200 Subject: [PATCH 08/37] Add datasource for notifications --- migrations/00004_notifications/index.sql | 32 +- .../__tests__/00004_notifications.spec.ts | 170 +++--- ...upsert-subscriptions.dto.entity.builder.ts | 33 ++ .../notification-channel-config.entity.ts | 11 + .../entities/notification-channel.entity.ts | 6 + .../notification-subscription.entity.ts | 8 + .../entities/notification-type.entity.ts | 6 + .../upsert-subscriptions.dto.entity.ts | 16 + .../notifications.datasource.spec.ts | 234 ++++++++ .../notifications/notifications.datasource.ts | 525 ++++++++++++++++++ .../notifications.datasource.interface.ts | 30 + .../entities-v2/device-type.entity.ts | 5 + .../notification-channel.entity.ts | 3 + .../entities-v2/notification-type.entity.ts | 14 + .../notifications/entities-v2/uuid.entity.ts | 1 + 15 files changed, 1020 insertions(+), 74 deletions(-) create mode 100644 src/datasources/accounts/notifications/__tests__/upsert-subscriptions.dto.entity.builder.ts create mode 100644 src/datasources/accounts/notifications/entities/notification-channel-config.entity.ts create mode 100644 src/datasources/accounts/notifications/entities/notification-channel.entity.ts create mode 100644 src/datasources/accounts/notifications/entities/notification-subscription.entity.ts create mode 100644 src/datasources/accounts/notifications/entities/notification-type.entity.ts create mode 100644 src/datasources/accounts/notifications/entities/upsert-subscriptions.dto.entity.ts create mode 100644 src/datasources/accounts/notifications/notifications.datasource.spec.ts create mode 100644 src/datasources/accounts/notifications/notifications.datasource.ts create mode 100644 src/domain/interfaces/notifications.datasource.interface.ts create mode 100644 src/domain/notifications/entities-v2/device-type.entity.ts create mode 100644 src/domain/notifications/entities-v2/notification-channel.entity.ts create mode 100644 src/domain/notifications/entities-v2/notification-type.entity.ts create mode 100644 src/domain/notifications/entities-v2/uuid.entity.ts diff --git a/migrations/00004_notifications/index.sql b/migrations/00004_notifications/index.sql index 5dbf5ac441..e4d0d2f140 100644 --- a/migrations/00004_notifications/index.sql +++ b/migrations/00004_notifications/index.sql @@ -1,14 +1,15 @@ DROP TABLE IF EXISTS notification_types, notification_subscriptions, + notification_subscription_notification_types, notification_channels, notification_channel_configurations CASCADE; -------------------------------------------- --- Notification types, e.g.INCOMING_TOKEN -- +-- Notification types, e.g. INCOMING_TOKEN -- -------------------------------------------- CREATE TABLE notification_types( id SERIAL PRIMARY KEY, - name VARCHAR(255) NOT NULL + name VARCHAR(255) NOT NULL UNIQUE ); -- TODO: Confirm these types @@ -32,14 +33,12 @@ INSERT INTO notification_types (name) VALUES CREATE TABLE notification_subscriptions( id SERIAL PRIMARY KEY, account_id INT NOT NULL, - notification_type_id INT NOT NULL, - chain_id INT NOT NULL, + chain_id VARCHAR(255) NOT NULL, safe_address VARCHAR(42) NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE, - FOREIGN KEY (notification_type_id) REFERENCES notification_types(id) ON DELETE CASCADE, - UNIQUE(account_id, chain_id, safe_address, notification_type_id) + UNIQUE(account_id, chain_id, safe_address) ); -- Update updated_at when a notification subscription is updated @@ -48,12 +47,22 @@ CREATE OR REPLACE TRIGGER update_notification_subscriptions_updated_at FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +-- Join table for subscriptions/notification types +CREATE TABLE notification_subscription_notification_types( + id SERIAL PRIMARY KEY, + subscription_id INT NOT NULL, + notification_type_id INT NOT NULL, + FOREIGN KEY (subscription_id) REFERENCES notification_subscriptions(id) ON DELETE CASCADE, + FOREIGN KEY (notification_type_id) REFERENCES notification_types(id) ON DELETE CASCADE, + UNIQUE (subscription_id, notification_type_id) +); + --------------------------------------------------- -- Notification channels, e.g. PUSH_NOTIFICATIONS -- --------------------------------------------------- CREATE TABLE notification_channels( id SERIAL PRIMARY KEY, - name VARCHAR(255) NOT NULL + name VARCHAR(255) NOT NULL UNIQUE ); -- Add PUSH_NOTIFICATIONS as a notification channel @@ -65,18 +74,21 @@ INSERT INTO notification_channels (name) VALUES ---------------------------------------------------------------- CREATE TABLE notification_channel_configurations( id SERIAL PRIMARY KEY, + -- TODO: Does this need this ID, it could be the same across subs notification_subscription_id INT NOT NULL, notification_channel_id INT NOT NULL, cloud_messaging_token VARCHAR(255) NOT NULL, - device_uuid UUID DEFAULT gen_random_uuid(), + device_type VARCHAR(255) CHECK (device_type IN ('ANDROID', 'IOS', 'WEB')) NOT NULL, + device_uuid UUID NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), FOREIGN KEY (notification_subscription_id) REFERENCES notification_subscriptions(id) ON DELETE CASCADE, - FOREIGN KEY (notification_channel_id) REFERENCES notification_channels(id) ON DELETE CASCADE + FOREIGN KEY (notification_channel_id) REFERENCES notification_channels(id) ON DELETE CASCADE, + UNIQUE (notification_subscription_id, notification_channel_id, device_uuid) ); -- Update updated_at when a notification channel is updated CREATE OR REPLACE TRIGGER update_notification_channel_configurations_updated_at BEFORE UPDATE ON notification_channel_configurations FOR EACH ROW -EXECUTE FUNCTION update_updated_at_column(); \ No newline at end of file +EXECUTE FUNCTION update_updated_at_column(); diff --git a/migrations/__tests__/00004_notifications.spec.ts b/migrations/__tests__/00004_notifications.spec.ts index 23842a7033..ae6a6a57e3 100644 --- a/migrations/__tests__/00004_notifications.spec.ts +++ b/migrations/__tests__/00004_notifications.spec.ts @@ -11,13 +11,18 @@ type NotificationTypesRow = { type NotificationSubscriptionsRow = { id: number; account_id: number; - notification_type_id: number; - chain_id: number; - safe_address: string; + chain_id: string; + safe_address: `0x${string}`; created_at: Date; updated_at: Date; }; +type NotificationSubscriptionNotificationTypesRow = { + id: number; + subscription_id: number; + notification_type_id: number; +}; + type NotificationChannelsRow = { id: number; name: string; @@ -66,6 +71,13 @@ describe('Migration 00004_notifications', () => { Array >`SELECT * FROM notification_subscriptions`, }, + notification_subscription_notification_types: { + columns: + await sql`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'notification_subscription_notification_types'`, + rows: await sql< + Array + >`SELECT * FROM notification_subscription_notification_types`, + }, notification_channels: { columns: await sql`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'notification_channels'`, @@ -90,13 +102,20 @@ describe('Migration 00004_notifications', () => { { column_name: 'id' }, { column_name: 'account_id' }, { column_name: 'chain_id' }, - { column_name: 'notification_type_id' }, { column_name: 'safe_address' }, { column_name: 'created_at' }, { column_name: 'updated_at' }, ]), rows: [], }, + notification_subscription_notification_types: { + columns: expect.arrayContaining([ + { column_name: 'id' }, + { column_name: 'subscription_id' }, + { column_name: 'notification_type_id' }, + ]), + rows: [], + }, notification_types: { columns: expect.arrayContaining([ { column_name: 'id' }, @@ -189,8 +208,8 @@ describe('Migration 00004_notifications', () => { await transaction`INSERT INTO accounts (address) VALUES ('0x69');`; // Add notification subscription to account - await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) - VALUES (1, 1, 1, '0x420')`; + await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, safe_address) + VALUES (1, 1, '0x420')`; }); return { @@ -208,7 +227,6 @@ describe('Migration 00004_notifications', () => { { column_name: 'id' }, { column_name: 'account_id' }, { column_name: 'chain_id' }, - { column_name: 'notification_type_id' }, { column_name: 'safe_address' }, { column_name: 'created_at' }, { column_name: 'updated_at' }, @@ -216,9 +234,8 @@ describe('Migration 00004_notifications', () => { rows: [ { id: 1, - chain_id: 1, + chain_id: '1', account_id: 1, - notification_type_id: 1, safe_address: '0x420', created_at: expect.any(Date), updated_at: expect.any(Date), @@ -235,9 +252,8 @@ describe('Migration 00004_notifications', () => { expect(afterUpdate).toStrictEqual([ { id: 1, - chain_id: 1, + chain_id: '1', account_id: 1, - notification_type_id: 1, safe_address: '0x69', // created_at should have remained the same created_at: afterInsert.after.rows[0].created_at, @@ -259,11 +275,17 @@ describe('Migration 00004_notifications', () => { await transaction`INSERT INTO accounts (address) VALUES ('0x69');`; // Add notification subscription to account - await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) - VALUES (1, 1, 1, '0x420')`; + const [subscription] = await transaction< + [Pick] + >`INSERT INTO notification_subscriptions (account_id, chain_id, safe_address) + VALUES (1, 1, '0x420') RETURNING id`; + // Add notification preference + await transaction`INSERT INTO notification_subscription_notification_types (subscription_id, notification_type_id) + VALUES(${subscription.id}, 1)`; + // Enable notification channel - await transaction`INSERT INTO notification_channel_configurations (notification_subscription_id, notification_channel_id, cloud_messaging_token) - VALUES (1, 1, '69420')`; + await transaction`INSERT INTO notification_channel_configurations (notification_subscription_id, notification_channel_id, cloud_messaging_token, device_uuid, device_type) + VALUES (1, 1, '69420', ${crypto.randomUUID()}, 'WEB')`; }); return { @@ -282,6 +304,7 @@ describe('Migration 00004_notifications', () => { { column_name: 'notification_subscription_id' }, { column_name: 'notification_channel_id' }, { column_name: 'device_uuid' }, + { column_name: 'device_type' }, { column_name: 'cloud_messaging_token' }, { column_name: 'created_at' }, { column_name: 'updated_at' }, @@ -292,6 +315,7 @@ describe('Migration 00004_notifications', () => { notification_subscription_id: 1, notification_channel_id: 1, device_uuid: expect.any(String), + device_type: 'WEB', cloud_messaging_token: '69420', created_at: expect.any(Date), updated_at: expect.any(Date), @@ -311,6 +335,7 @@ describe('Migration 00004_notifications', () => { notification_subscription_id: 1, notification_channel_id: 1, device_uuid: expect.any(String), + device_type: 'WEB', cloud_messaging_token: '1337', // created_at should have remained the same created_at: afterInsert.after.rows[0].created_at, @@ -332,8 +357,8 @@ describe('Migration 00004_notifications', () => { await transaction`INSERT INTO accounts (address) VALUES ('0x69');`; // Add notification subscription to account - await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) - VALUES (1, 1, 1, '0x420')`; + await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, safe_address) + VALUES (1, 1,'0x420')`; }); }, }); @@ -341,8 +366,8 @@ describe('Migration 00004_notifications', () => { // Try to add the same subscription again await expect(sql< Array - >`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) - VALUES (1, 1, 1, '0x420')`).rejects.toThrow( + >`INSERT INTO notification_subscriptions (account_id, chain_id, safe_address) + VALUES (1, 1, '0x420')`).rejects.toThrow( 'duplicate key value violates unique constraint "notification_subscriptions_account_id_chain_id_safe_address_key"', ); }); @@ -355,13 +380,16 @@ describe('Migration 00004_notifications', () => { // Create account await transaction`INSERT INTO accounts (address) VALUES ('0x69');`; - // Add notification subscription to account on chains 1, 2, 3 - await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) - VALUES (1, 1, 1, '0x420')`; - await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) - VALUES (1, 2, 1, '0x420')`; - await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) - VALUES (1, 3, 1, '0x420')`; + + // Add subscriptions to chains 1, 2, 3 + const chainIds = ['1', '2', '3']; + + await Promise.all( + chainIds.map((chainId) => { + return transaction`INSERT INTO notification_subscriptions (account_id, chain_id, safe_address) + VALUES (1, ${chainId}, '0x420')`; + }), + ); }); // Delete account @@ -374,6 +402,9 @@ describe('Migration 00004_notifications', () => { notification_subscriptions: await sql< Array >`SELECT * FROM notification_subscriptions`, + notification_subscription_notification_types: await sql< + Array + >`SELECT * FROM notification_subscription_notification_types`, notification_channels: await sql< Array >`SELECT * FROM notification_channels`, @@ -437,6 +468,7 @@ describe('Migration 00004_notifications', () => { ], // No subscriptions should exist notification_subscriptions: [], + notification_subscription_notification_types: [], notification_channels: [ { id: 1, @@ -448,24 +480,39 @@ describe('Migration 00004_notifications', () => { }); it('should delete the subscription if the notification_type is deleted', async () => { + let deletedMultisigTransactionId: number; + const result = await migrator.test({ migration: '00004_notifications', after: async (sql) => { await sql.begin(async (transaction) => { + const [type] = await sql< + [Pick] + >`SELECT id FROM notification_types WHERE name = 'DELETED_MULTISIG_TRANSACTION'`; + + deletedMultisigTransactionId = type.id; + // Create account await transaction`INSERT INTO accounts (address) VALUES ('0x69');`; - // Add notification subscription to account on chains 1, 2, 3 - await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) - VALUES (1, 1, 1, '0x420')`; - await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) - VALUES (1, 2, 1, '0x420')`; - await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) - VALUES (1, 3, 1, '0x420')`; + + // Add subscriptions to chains 1, 2, 3 + const chainIds = ['1', '2', '3']; + + await Promise.all( + chainIds.map(async (chainId) => { + const [subscription] = await transaction< + [Pick] + >`INSERT INTO notification_subscriptions (account_id, chain_id, safe_address) + VALUES (1, ${chainId}, '0x420') RETURNING id`; + await transaction`INSERT INTO notification_subscription_notification_types (subscription_id, notification_type_id) + VALUES (${subscription.id}, ${deletedMultisigTransactionId})`; + }), + ); }); // Delete DELETED_MULTISIG_TRANSACTION notification type - await sql`DELETE FROM notification_types WHERE name = 'DELETED_MULTISIG_TRANSACTION'`; + await sql`DELETE FROM notification_types WHERE id = ${deletedMultisigTransactionId}`; return { notification_types: await sql< @@ -474,6 +521,9 @@ describe('Migration 00004_notifications', () => { notification_subscriptions: await sql< Array >`SELECT * FROM notification_subscriptions`, + notification_subscription_notification_types: await sql< + Array + >`SELECT * FROM notification_subscription_notification_types`, notification_channels: await sql< Array >`SELECT * FROM notification_channels`, @@ -534,6 +584,7 @@ describe('Migration 00004_notifications', () => { ], // No subscriptions should exist notification_subscriptions: [], + notification_subscription_notification_types: [], notification_channels: [ { id: 1, @@ -552,21 +603,27 @@ describe('Migration 00004_notifications', () => { // Create account await transaction`INSERT INTO accounts (address) VALUES ('0x69');`; - // Add notification subscription to account on chains 1, 2, 3 - await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) - VALUES (1, 1, 1, '0x420')`; - await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) - VALUES (1, 2, 1, '0x420')`; - await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, notification_type_id, safe_address) - VALUES (1, 3, 1, '0x420')`; + + // Add subscriptions to chains 1, 2, 3 + const chainIds = ['1', '2', '3']; + + await Promise.all( + chainIds.map((chainId) => { + return transaction`INSERT INTO notification_subscriptions (account_id, chain_id, safe_address) + VALUES (1, ${chainId}, '0x420')`; + }), + ); // Enable notification channel - await transaction`INSERT INTO notification_channel_configurations (notification_subscription_id, notification_channel_id, cloud_messaging_token) - VALUES (1, 1, '69420')`; + await transaction`INSERT INTO notification_channel_configurations (notification_subscription_id, notification_channel_id, cloud_messaging_token, device_uuid, device_type) + VALUES (1, 1, '69420', ${crypto.randomUUID()}, 'WEB')`; }); + const [channel] = await sql< + [Pick] + >`SELECT id FROM notification_channels WHERE name = 'PUSH_NOTIFICATIONS'`; // Delete PUSH_NOTIFICATIONS notification channel - await sql`DELETE FROM notification_channels WHERE name = 'PUSH_NOTIFICATIONS'`; + await sql`DELETE FROM notification_channels WHERE id = ${channel.id}`; return { notification_types: await sql< @@ -575,6 +632,9 @@ describe('Migration 00004_notifications', () => { notification_subscriptions: await sql< Array >`SELECT * FROM notification_subscriptions`, + notification_subscription_notification_types: await sql< + Array + >`SELECT * FROM notification_subscription_notification_types`, notification_channels: await sql< Array >`SELECT * FROM notification_channels`, @@ -639,32 +699,14 @@ describe('Migration 00004_notifications', () => { notification_subscriptions: [ { id: 1, - chain_id: 1, - account_id: 1, - notification_type_id: 1, - safe_address: '0x420', - created_at: expect.any(Date), - updated_at: expect.any(Date), - }, - { - id: 2, - chain_id: 2, - account_id: 1, - notification_type_id: 1, - safe_address: '0x420', - created_at: expect.any(Date), - updated_at: expect.any(Date), - }, - { - id: 3, - chain_id: 3, + chain_id: '1', account_id: 1, - notification_type_id: 1, safe_address: '0x420', created_at: expect.any(Date), updated_at: expect.any(Date), }, ], + notification_subscription_notification_types: [], notification_channels: [], // No configurations should exist notification_channel_configurations: [], diff --git a/src/datasources/accounts/notifications/__tests__/upsert-subscriptions.dto.entity.builder.ts b/src/datasources/accounts/notifications/__tests__/upsert-subscriptions.dto.entity.builder.ts new file mode 100644 index 0000000000..c9c2cc2810 --- /dev/null +++ b/src/datasources/accounts/notifications/__tests__/upsert-subscriptions.dto.entity.builder.ts @@ -0,0 +1,33 @@ +import { faker } from '@faker-js/faker'; +import { Builder, IBuilder } from '@/__tests__/builder'; +import { getAddress } from 'viem'; +import { UpsertSubscriptionsDto } from '@/datasources/accounts/notifications/entities/upsert-subscriptions.dto.entity'; +import { DeviceType } from '@/domain/notifications/entities-v2/device-type.entity'; +import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; +import { NotificationType } from '@/domain/notifications/entities-v2/notification-type.entity'; + +// TODO: Move to domain +export function upsertSubscriptionsDtoBuilder(): IBuilder { + return new Builder() + .with('account', getAddress(faker.finance.ethereumAddress())) + .with('cloudMessagingToken', faker.string.alphanumeric()) + .with('deviceType', faker.helpers.arrayElement(Object.values(DeviceType))) + .with('deviceUuid', faker.string.uuid() as Uuid) + .with( + 'safes', + Array.from( + { + length: faker.number.int({ min: 1, max: 5 }), + }, + () => { + return { + chainId: faker.string.numeric(), + address: getAddress(faker.finance.ethereumAddress()), + notificationTypes: faker.helpers.arrayElements( + Object.values(NotificationType), + ), + }; + }, + ), + ); +} diff --git a/src/datasources/accounts/notifications/entities/notification-channel-config.entity.ts b/src/datasources/accounts/notifications/entities/notification-channel-config.entity.ts new file mode 100644 index 0000000000..acc6ecf4e3 --- /dev/null +++ b/src/datasources/accounts/notifications/entities/notification-channel-config.entity.ts @@ -0,0 +1,11 @@ +import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; + +export type NotificationChannelConfig = { + id: number; + notification_subscription_id: number; + notification_channel_id: number; + device_uuid: Uuid; + cloud_messaging_token: string; + created_at: Date; + updated_at: Date; +}; diff --git a/src/datasources/accounts/notifications/entities/notification-channel.entity.ts b/src/datasources/accounts/notifications/entities/notification-channel.entity.ts new file mode 100644 index 0000000000..307129b42b --- /dev/null +++ b/src/datasources/accounts/notifications/entities/notification-channel.entity.ts @@ -0,0 +1,6 @@ +import { NotificationChannel as DomainNotificationChannel } from '@/domain/notifications/entities-v2/notification-channel.entity'; + +export type NotificationChannel = { + id: number; + name: DomainNotificationChannel; +}; diff --git a/src/datasources/accounts/notifications/entities/notification-subscription.entity.ts b/src/datasources/accounts/notifications/entities/notification-subscription.entity.ts new file mode 100644 index 0000000000..1d5912b4ab --- /dev/null +++ b/src/datasources/accounts/notifications/entities/notification-subscription.entity.ts @@ -0,0 +1,8 @@ +export type NotificationSubscription = { + id: number; + account_id: number; + chain_id: string; + safe_address: `0x${string}`; + created_at: Date; + updated_at: Date; +}; diff --git a/src/datasources/accounts/notifications/entities/notification-type.entity.ts b/src/datasources/accounts/notifications/entities/notification-type.entity.ts new file mode 100644 index 0000000000..8ec3023cb9 --- /dev/null +++ b/src/datasources/accounts/notifications/entities/notification-type.entity.ts @@ -0,0 +1,6 @@ +import { NotificationType as DomainNotificationType } from '@/domain/notifications/entities-v2/notification-type.entity'; + +export type NotificationType = { + id: number; + name: DomainNotificationType; +}; diff --git a/src/datasources/accounts/notifications/entities/upsert-subscriptions.dto.entity.ts b/src/datasources/accounts/notifications/entities/upsert-subscriptions.dto.entity.ts new file mode 100644 index 0000000000..c30e07ecd4 --- /dev/null +++ b/src/datasources/accounts/notifications/entities/upsert-subscriptions.dto.entity.ts @@ -0,0 +1,16 @@ +import { DeviceType } from '@/domain/notifications/entities-v2/device-type.entity'; +import { NotificationType } from '@/domain/notifications/entities-v2/notification-type.entity'; +import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; + +// TODO: Move to domain +export type UpsertSubscriptionsDto = { + account: `0x${string}`; + cloudMessagingToken: string; + safes: Array<{ + chainId: string; + address: `0x${string}`; + notificationTypes: Array; + }>; + deviceType: DeviceType; + deviceUuid?: Uuid; +}; diff --git a/src/datasources/accounts/notifications/notifications.datasource.spec.ts b/src/datasources/accounts/notifications/notifications.datasource.spec.ts new file mode 100644 index 0000000000..77ae637bd1 --- /dev/null +++ b/src/datasources/accounts/notifications/notifications.datasource.spec.ts @@ -0,0 +1,234 @@ +import { TestDbFactory } from '@/__tests__/db.factory'; +import { AccountsDatasource } from '@/datasources/accounts/accounts.datasource'; +import { upsertSubscriptionsDtoBuilder } from '@/datasources/accounts/notifications/__tests__/upsert-subscriptions.dto.entity.builder'; +import { NotificationsDatasource } from '@/datasources/accounts/notifications/notifications.datasource'; +import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; +import { NotificationType } from '@/domain/notifications/entities-v2/notification-type.entity'; +import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; +import { ILoggingService } from '@/logging/logging.interface'; +import { faker } from '@faker-js/faker'; +import postgres from 'postgres'; + +const mockLoggingService = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), +} as jest.MockedObjectDeep; + +describe('NotificationsDatasource', () => { + let accountsDatasource: AccountsDatasource; + let target: NotificationsDatasource; + let migrator: PostgresDatabaseMigrator; + let sql: postgres.Sql; + const testDbFactory = new TestDbFactory(); + + beforeAll(async () => { + sql = await testDbFactory.createTestDatabase(faker.string.uuid()); + migrator = new PostgresDatabaseMigrator(sql); + await migrator.migrate(); + accountsDatasource = new AccountsDatasource(sql, mockLoggingService); + target = new NotificationsDatasource( + sql, + mockLoggingService, + accountsDatasource, + ); + }); + + afterAll(async () => { + await testDbFactory.destroyTestDatabase(sql); + }); + + describe('upsertSubscriptions', () => { + it('should insert subscriptions', async () => { + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() + .with('deviceUuid', undefined) + .build(); + await accountsDatasource.createAccount(upsertSubscriptionsDto.account); + + const actual = await target.upsertSubscriptions(upsertSubscriptionsDto); + + expect(actual).toStrictEqual({ deviceUuid: expect.any(String) }); + + // TODO: Check database structure + }); + + it('should update subscriptions with new preferences', async () => { + const deviceUuid = faker.string.uuid() as Uuid; + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() + .with('deviceUuid', deviceUuid) + .build(); + await accountsDatasource.createAccount(upsertSubscriptionsDto.account); + await target.upsertSubscriptions(upsertSubscriptionsDto); + + await expect( + target.getSafeSubscription({ + account: upsertSubscriptionsDto.account, + chainId: upsertSubscriptionsDto.safes[0].chainId, + safeAddress: upsertSubscriptionsDto.safes[0].address, + deviceUuid: deviceUuid, + }), + ).resolves.toEqual( + Object.values(NotificationType).reduce< + Record + >( + (acc, type) => { + acc[type] = + upsertSubscriptionsDto.safes[0].notificationTypes.includes(type); + return acc; + }, + {} as Record, + ), + ); + + const upsertedUpsertSubscriptionsDto = { + ...upsertSubscriptionsDto, + safes: [ + { + chainId: upsertSubscriptionsDto.safes[0].chainId, + address: upsertSubscriptionsDto.safes[0].address, + notificationTypes: faker.helpers.arrayElements( + Object.values(NotificationType), + ), + }, + ], + }; + + await target.upsertSubscriptions(upsertedUpsertSubscriptionsDto); + + await expect( + target.getSafeSubscription({ + account: upsertSubscriptionsDto.account, + chainId: upsertSubscriptionsDto.safes[0].chainId, + safeAddress: upsertSubscriptionsDto.safes[0].address, + deviceUuid: deviceUuid, + }), + ).resolves.toEqual( + Object.values(NotificationType).reduce< + Record + >( + (acc, type) => { + acc[type] = + upsertedUpsertSubscriptionsDto.safes[0].notificationTypes.includes( + type, + ); + return acc; + }, + {} as Record, + ), + ); + + // TODO: Check new preferences in database + }); + }); + + describe('getSafeSubscription', () => { + it('should get the Safe subscription', async () => { + const deviceUuid = faker.string.uuid() as Uuid; + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() + .with('deviceUuid', deviceUuid) + .build(); + await accountsDatasource.createAccount(upsertSubscriptionsDto.account); + await target.upsertSubscriptions(upsertSubscriptionsDto); + + await Promise.all( + upsertSubscriptionsDto.safes.map(async (safe) => { + const actual = await target.getSafeSubscription({ + account: upsertSubscriptionsDto.account, + chainId: safe.chainId, + safeAddress: safe.address, + deviceUuid, + }); + + expect(actual).toEqual( + Object.values(NotificationType).reduce< + Record + >( + (acc, type) => { + acc[type] = safe.notificationTypes.includes(type); + return acc; + }, + {} as Record, + ), + ); + }), + ); + }); + }); + + describe('getCloudMessagingTokensBySafe', () => { + it('should get the cloud messaging tokens subscribed to a Safe', async () => { + const deviceUuid = faker.string.uuid() as Uuid; + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() + .with('deviceUuid', deviceUuid) + .build(); + await accountsDatasource.createAccount(upsertSubscriptionsDto.account); + await target.upsertSubscriptions(upsertSubscriptionsDto); + + await Promise.all( + upsertSubscriptionsDto.safes.map((safe) => { + return expect( + target.getCloudMessagingTokensBySafe({ + chainId: safe.chainId, + safeAddress: safe.address, + }), + ).resolves.toEqual([upsertSubscriptionsDto.cloudMessagingToken]); + }), + ); + }); + }); + + describe('deleteSubscription', () => { + it('should delete a subscription', async () => { + const deviceUuid = faker.string.uuid() as Uuid; + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() + .with('deviceUuid', deviceUuid) + .build(); + await accountsDatasource.createAccount(upsertSubscriptionsDto.account); + await target.upsertSubscriptions(upsertSubscriptionsDto); + + await target.deleteSubscription({ + account: upsertSubscriptionsDto.account, + chainId: upsertSubscriptionsDto.safes[0].chainId, + safeAddress: upsertSubscriptionsDto.safes[0].address, + }); + + await expect( + target.getSafeSubscription({ + account: upsertSubscriptionsDto.account, + chainId: upsertSubscriptionsDto.safes[0].chainId, + safeAddress: upsertSubscriptionsDto.safes[0].address, + deviceUuid, + }), + ).rejects.toThrow('Error getting account subscription'); + + // TODO: Check database structure + }); + }); + + describe('deleteDevice', () => { + it('should delete a device', async () => { + const deviceUuid = faker.string.uuid() as Uuid; + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() + .with('deviceUuid', deviceUuid) + .build(); + await accountsDatasource.createAccount(upsertSubscriptionsDto.account); + await target.upsertSubscriptions(upsertSubscriptionsDto); + await target.deleteDevice(deviceUuid); + + await Promise.all( + upsertSubscriptionsDto.safes.map((safe) => { + return expect( + target.getSafeSubscription({ + account: upsertSubscriptionsDto.account, + chainId: safe.chainId, + safeAddress: safe.address, + deviceUuid, + }), + ).rejects.toThrow('Error getting account subscription'); + }), + ); + + // TODO: Check database structure + }); + }); +}); diff --git a/src/datasources/accounts/notifications/notifications.datasource.ts b/src/datasources/accounts/notifications/notifications.datasource.ts new file mode 100644 index 0000000000..ec8d329635 --- /dev/null +++ b/src/datasources/accounts/notifications/notifications.datasource.ts @@ -0,0 +1,525 @@ +import { NotificationType as DomainNotificationType } from '@/domain/notifications/entities-v2/notification-type.entity'; +import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; +import { IAccountsDatasource } from '@/domain/interfaces/accounts.datasource.interface'; +import { INotificationsDatasource } from '@/domain/interfaces/notifications.datasource.interface'; +import { LoggingService, ILoggingService } from '@/logging/logging.interface'; +import { asError } from '@/logging/utils'; +import { + Inject, + Injectable, + NotFoundException, + UnprocessableEntityException, +} from '@nestjs/common'; +import postgres from 'postgres'; +import { NotificationChannel as DomainNotificationChannel } from '@/domain/notifications/entities-v2/notification-channel.entity'; +import { UpsertSubscriptionsDto } from '@/datasources/accounts/notifications/entities/upsert-subscriptions.dto.entity'; +import { DeviceType } from '@/domain/notifications/entities-v2/device-type.entity'; +import { NotificationChannel } from '@/datasources/accounts/notifications/entities/notification-channel.entity'; +import { NotificationSubscription } from '@/datasources/accounts/notifications/entities/notification-subscription.entity'; +import { NotificationChannelConfig } from '@/datasources/accounts/notifications/entities/notification-channel-config.entity'; +import { NotificationType } from '@/datasources/accounts/notifications/entities/notification-type.entity'; + +@Injectable() +export class NotificationsDatasource implements INotificationsDatasource { + constructor( + @Inject('DB_INSTANCE') + private readonly sql: postgres.Sql, + @Inject(LoggingService) + private readonly loggingService: ILoggingService, + @Inject(IAccountsDatasource) + private readonly accountsDatasource: IAccountsDatasource, + ) {} + + /** + * Upserts subscriptions for the given account as per the list of Safes + * and notification types provided. + * + * @param args.account Account address + * @param args.cloudMessagingToken Cloud messaging token + * @param args.deviceType Device type + * @param args.deviceUuid Device UUID (defaults to random UUID) + * @param args.safes List of Safes with notification types + * + * @returns Device UUID + */ + async upsertSubscriptions( + args: UpsertSubscriptionsDto, + ): Promise<{ deviceUuid: Uuid }> { + const account = await this.accountsDatasource.getAccount(args.account); + const deviceUuid = args.deviceUuid ?? crypto.randomUUID(); + + await this.sql.begin(async (sql) => { + const channel = await this.getChannel({ + sql, + name: DomainNotificationChannel.PUSH_NOTIFICATIONS, + }); + + await Promise.all( + args.safes.map(async (safe) => { + const subscription = await this.insertSubscription({ + sql, + accountId: account.id, + chainId: safe.chainId, + safeAddress: safe.address, + }); + + await this.insertChannelConfig({ + sql, + subscriptionId: subscription.id, + channelId: channel.id, + cloudMessagingToken: args.cloudMessagingToken, + deviceType: args.deviceType, + deviceUuid, + }); + + // Cleanup existing types as incoming safe.notificationTypes + // are only those to-be-enabled + await this.deleteSubscriptionTypes({ + sql, + subscriptionId: subscription.id, + }); + + // Set new notification type preferences + return await Promise.all( + safe.notificationTypes.map((notificationType) => { + return this.insertSubscriptionType({ + sql, + subscriptionId: subscription.id, + notificationType, + }); + }), + ); + }), + ); + }); + + return { deviceUuid }; + } + + private async getChannel(args: { + sql: postgres.TransactionSql; + name: DomainNotificationChannel; + }): Promise { + const [channel] = await args.sql<[NotificationChannel]>` + SELECT id + FROM notification_channels + WHERE name = ${args.name} + `.catch((e) => { + this.loggingService.info(`Error getting channel: ${asError(e).message}`); + return []; + }); + + if (!channel) { + throw new NotFoundException('Error getting channel'); + } + + return channel; + } + + private async insertSubscription(args: { + sql: postgres.TransactionSql; + accountId: number; + chainId: string; + safeAddress: `0x${string}`; + }): Promise { + const [subscription] = await args.sql<[NotificationSubscription]>` + INSERT INTO notification_subscriptions (account_id, chain_id, safe_address) + VALUES (${args.accountId}, ${args.chainId}, ${args.safeAddress}) + ON CONFLICT (account_id, chain_id, safe_address) + DO UPDATE SET + -- Field must be set to return value + updated_at = NOW() + RETURNING *`.catch((e) => { + this.loggingService.info( + `Error inserting subscription: ${asError(e).message}`, + ); + return []; + }); + + if (!subscription) { + throw new UnprocessableEntityException('Error inserting subscription'); + } + + return subscription; + } + + private async insertChannelConfig(args: { + sql: postgres.TransactionSql; + subscriptionId: number; + channelId: number; + cloudMessagingToken: string; + deviceType: DeviceType; + deviceUuid: Uuid; + }): Promise { + const [config] = await args.sql<[NotificationChannelConfig]>` + INSERT INTO notification_channel_configurations ( + notification_subscription_id, + notification_channel_id, + cloud_messaging_token, + device_type, + device_uuid + ) VALUES ( + ${args.subscriptionId}, + ${args.channelId}, + ${args.cloudMessagingToken}, + ${args.deviceType}, + ${args.deviceUuid} + ) + ON CONFLICT (notification_subscription_id, notification_channel_id, device_uuid) + DO UPDATE SET + cloud_messaging_token = EXCLUDED.cloud_messaging_token + RETURNING * + `.catch((e) => { + this.loggingService.info( + `Error inserting channel configuration: ${asError(e).message}`, + ); + return []; + }); + + console.log({ config }); + if (!config) { + throw new UnprocessableEntityException( + 'Error inserting channel configuration', + ); + } + + return config; + } + + private async deleteSubscriptionTypes(args: { + sql: postgres.TransactionSql; + subscriptionId: number; + }): Promise { + await args.sql` + DELETE FROM notification_subscription_notification_types + WHERE subscription_id = ${args.subscriptionId} + `.catch((e) => { + this.loggingService.info( + `Error deleting subscription notification types: ${asError(e).message}`, + ); + throw new UnprocessableEntityException( + 'Error deleting subscription notification types', + ); + }); + } + + private async insertSubscriptionType(args: { + sql: postgres.TransactionSql; + subscriptionId: number; + // TODO: Accept array + notificationType: DomainNotificationType; + }): Promise { + await args.sql`INSERT INTO notification_subscription_notification_types (subscription_id, notification_type_id) + VALUES (${args.subscriptionId}, (SELECT id FROM notification_types WHERE name = ${args.notificationType}))`.catch( + (e) => { + this.loggingService.info( + `Error inserting subscription notification type: ${asError(e).message}`, + ); + throw new UnprocessableEntityException( + 'Error inserting subscription notification type', + ); + }, + ); + } + + /** + * Gets notification preferences for given account for the device/Safe. + * + * @param args.account Account address + * @param args.deviceUuid Device UUID + * @param args.chainId Chain ID + * @param args.safeAddress Safe address + * + * @returns Notification preferences for the device/Safe + */ + async getSafeSubscription(args: { + account: `0x${string}`; + deviceUuid: Uuid; + chainId: string; + safeAddress: `0x${string}`; + }): Promise> { + const account = await this.accountsDatasource.getAccount(args.account); + + return this.sql.begin(async (sql) => { + const subscription = await this.getAccountSubscription({ + sql, + accountId: account.id, + deviceUuid: args.deviceUuid, + chainId: args.chainId, + safeAddress: args.safeAddress, + }); + + const notificationTypes = await this.getNotificationTypes({ + sql, + subscriptionId: subscription.id, + }); + + return this.mapNotificationTypes(notificationTypes); + }); + } + + private async getAccountSubscription(args: { + sql: postgres.TransactionSql; + accountId: number; + deviceUuid: Uuid; + chainId: string; + safeAddress: `0x${string}`; + }): Promise { + const [subscription] = await args.sql<[NotificationSubscription]>` + SELECT * + FROM notification_subscriptions ns + JOIN notification_channel_configurations ncc + ON ns.id = ncc.notification_subscription_id + WHERE ns.account_id = ${args.accountId} + AND ns.chain_id = ${args.chainId} + AND ns.safe_address = ${args.safeAddress} + AND ncc.device_uuid = ${args.deviceUuid}; + `.catch((e) => { + this.loggingService.info( + `Error getting account subscription: ${asError(e).message}`, + ); + return []; + }); + + if (!subscription) { + throw new NotFoundException('Error getting account subscription'); + } + + return subscription; + } + + private async getNotificationTypes(args: { + sql: postgres.TransactionSql; + subscriptionId: number; + }): Promise> { + const types = await args.sql>` + SELECT nt.name + FROM notification_subscription_notification_types nsnt + JOIN notification_types nt ON nsnt.notification_type_id = nt.id + WHERE nsnt.subscription_id = ${args.subscriptionId}; + `.catch((e) => { + this.loggingService.info( + `Error getting notification types: ${asError(e).message}`, + ); + return []; + }); + + if (types.length === 0) { + throw new NotFoundException('Error getting notification types'); + } + + return types; + } + + private mapNotificationTypes( + notificationTypes: Array, + ): Record { + return Object.values(DomainNotificationType).reduce< + Record + >( + (acc, type) => { + acc[type] = notificationTypes.some((row) => row.name === type); + return acc; + }, + {} as Record, + ); + } + + /** + * Gets cloud messaging tokens for the given Safe. + * + * @param args.chainId Chain ID + * @param args.safeAddress Safe address + * + * @returns List of cloud messaging tokens for the Safe + */ + async getCloudMessagingTokensBySafe(args: { + chainId: string; + safeAddress: `0x${string}`; + }): Promise> { + return this.sql.begin(async (sql) => { + const subscriptions = await this.getSafeSubscriptions({ + sql, + chainId: args.chainId, + safeAddress: args.safeAddress, + }); + const subscriptionIds = subscriptions.map((row) => row.id); + + const configurations = await this.getChannelConfigs({ + sql, + subscriptionIds, + }); + return configurations.map((row) => row.cloud_messaging_token); + }); + } + + private async getSafeSubscriptions(args: { + sql: postgres.TransactionSql; + chainId: string; + safeAddress: `0x${string}`; + }): Promise> { + const subscriptions = await args.sql>` + SELECT * + FROM notification_subscriptions + WHERE chain_id = ${args.chainId} AND safe_address = ${args.safeAddress}; + `.catch((e) => { + this.loggingService.info( + `Error getting Safe subscriptions: ${asError(e).message}`, + ); + return []; + }); + + if (subscriptions.length === 0) { + throw new NotFoundException('Error getting Safe subscriptions'); + } + + return subscriptions; + } + + private async getChannelConfigs(args: { + sql: postgres.TransactionSql; + subscriptionIds: Array; + }): Promise> { + const configs = await args.sql>` + SELECT cloud_messaging_token + FROM notification_channel_configurations + WHERE notification_subscription_id = ANY(${args.subscriptionIds}); + `.catch((e) => { + this.loggingService.info( + `Error getting channel configurations: ${asError(e).message}`, + ); + return []; + }); + + if (configs.length === 0) { + throw new NotFoundException('Error getting channel configurations'); + } + + return configs; + } + + /** + * Deletes the subscription for the given account/Safe. + * + * @param args.account Account address + * @param args.chainId Chain ID + * @param args.safeAddress Safe address + */ + async deleteSubscription(args: { + account: `0x${string}`; + chainId: string; + safeAddress: `0x${string}`; + }): Promise { + await this.sql.begin(async (sql) => { + const subscription = await this.getAccountSubscriptions({ + sql, + ...args, + }); + + return this.deleteSubscriptionById({ + sql, + subscriptionId: subscription.id, + }); + }); + } + + private async getAccountSubscriptions(args: { + sql: postgres.TransactionSql; + account: `0x${string}`; + chainId: string; + safeAddress: `0x${string}`; + }): Promise { + const [subscription] = await args.sql<[NotificationSubscription]>` + SELECT notification_subscriptions.id + FROM notification_subscriptions + JOIN accounts ON notification_subscriptions.account_id = accounts.id + WHERE accounts.address = ${args.account} AND notification_subscriptions.safe_address = ${args.safeAddress} AND notification_subscriptions.chain_id = ${args.chainId} + `.catch((e) => { + this.loggingService.info( + `Error getting subscription for account/Safe: ${asError(e).message}`, + ); + return []; + }); + + if (!subscription) { + throw new NotFoundException( + 'Error getting subscription for account/Safe', + ); + } + + return subscription; + } + + private async deleteSubscriptionById(args: { + sql: postgres.TransactionSql; + subscriptionId: number; + }): Promise { + await args.sql` + DELETE FROM notification_subscriptions + WHERE id = ${args.subscriptionId} + `.catch((e) => { + this.loggingService.info( + `Error deleting subscription: ${asError(e).message}`, + ); + throw new UnprocessableEntityException('Error deleting subscription'); + }); + } + + /** + * Deletes the device and all its subscriptions. + * @param deviceUuid Device UUID + */ + async deleteDevice(deviceUuid: Uuid): Promise { + await this.sql.begin(async (sql) => { + const configs = await this.getChannelConfigsByDevice({ + sql, + deviceUuid, + }); + const subscriptionIds = configs.map( + (row) => row.notification_subscription_id, + ); + + await this.deleteSubscriptions({ + sql, + subscriptionIds, + }); + }); + } + + private async getChannelConfigsByDevice(args: { + sql: postgres.TransactionSql; + deviceUuid: Uuid; + }): Promise> { + const configs = await args.sql>` + SELECT DISTINCT * + FROM notification_channel_configurations + WHERE device_uuid = ${args.deviceUuid} + `.catch((e) => { + this.loggingService.info( + `Error getting channel configurations: ${asError(e).message}`, + ); + return []; + }); + + if (configs.length === 0) { + throw new NotFoundException('Error getting channel configurations'); + } + + return configs; + } + + private async deleteSubscriptions(args: { + sql: postgres.TransactionSql; + subscriptionIds: Array; + }): Promise { + await args.sql` + DELETE FROM notification_subscriptions + WHERE id = ANY(${args.subscriptionIds}) + `.catch((e) => { + this.loggingService.info( + `Error deleting subscriptions: ${asError(e).message}`, + ); + throw new UnprocessableEntityException('Error deleting subscriptions'); + }); + } +} diff --git a/src/domain/interfaces/notifications.datasource.interface.ts b/src/domain/interfaces/notifications.datasource.interface.ts new file mode 100644 index 0000000000..fafe6cc7f5 --- /dev/null +++ b/src/domain/interfaces/notifications.datasource.interface.ts @@ -0,0 +1,30 @@ +import { UpsertSubscriptionsDto } from '@/datasources/accounts/notifications/entities/upsert-subscriptions.dto.entity'; +import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; + +export const INotificationsDatasource = Symbol('INotificationsDatasource'); + +export interface INotificationsDatasource { + upsertSubscriptions(args: UpsertSubscriptionsDto): Promise<{ + deviceUuid: Uuid; + }>; + + getSafeSubscription(args: { + account: `0x${string}`; + deviceUuid: Uuid; + chainId: `0x${string}`; + safeAddress: `0x${string}`; + }): Promise; + + getCloudMessagingTokensBySafe(args: { + chainId: `0x${string}`; + safeAddress: `0x${string}`; + }): Promise>; + + deleteSubscription(args: { + account: `0x${string}`; + chainId: `0x${string}`; + safeAddress: `0x${string}`; + }): Promise; + + deleteDevice(deviceUuid: Uuid): Promise; +} diff --git a/src/domain/notifications/entities-v2/device-type.entity.ts b/src/domain/notifications/entities-v2/device-type.entity.ts new file mode 100644 index 0000000000..996df9dd7e --- /dev/null +++ b/src/domain/notifications/entities-v2/device-type.entity.ts @@ -0,0 +1,5 @@ +export enum DeviceType { + ANDROID = 'ANDROID', + IOS = 'IOS', + WEB = 'WEB', +} diff --git a/src/domain/notifications/entities-v2/notification-channel.entity.ts b/src/domain/notifications/entities-v2/notification-channel.entity.ts new file mode 100644 index 0000000000..6523ad58b4 --- /dev/null +++ b/src/domain/notifications/entities-v2/notification-channel.entity.ts @@ -0,0 +1,3 @@ +export enum NotificationChannel { + PUSH_NOTIFICATIONS = 'PUSH_NOTIFICATIONS', +} diff --git a/src/domain/notifications/entities-v2/notification-type.entity.ts b/src/domain/notifications/entities-v2/notification-type.entity.ts new file mode 100644 index 0000000000..dbf8802edd --- /dev/null +++ b/src/domain/notifications/entities-v2/notification-type.entity.ts @@ -0,0 +1,14 @@ +export enum NotificationType { + DELETED_MULTISIG_TRANSACTION = 'DELETED_MULTISIG_TRANSACTION', + EXECUTED_MULTISIG_TRANSACTION = 'EXECUTED_MULTISIG_TRANSACTION', + INCOMING_ETHER = 'INCOMING_ETHER', + INCOMING_TOKEN = 'INCOMING_TOKEN', + MESSAGE_CREATED = 'MESSAGE_CREATED', + MODULE_TRANSACTION = 'MODULE_TRANSACTION', + NEW_CONFIRMATION = 'NEW_CONFIRMATION', + MESSAGE_CONFIRMATION = 'MESSAGE_CONFIRMATION', + OUTGOING_ETHER = 'OUTGOING_ETHER', + OUTGOING_TOKEN = 'OUTGOING_TOKEN', + PENDING_MULTISIG_TRANSACTION = 'PENDING_MULTISIG_TRANSACTION', + SAFE_CREATED = 'SAFE_CREATED', +} diff --git a/src/domain/notifications/entities-v2/uuid.entity.ts b/src/domain/notifications/entities-v2/uuid.entity.ts new file mode 100644 index 0000000000..feddb9c4e2 --- /dev/null +++ b/src/domain/notifications/entities-v2/uuid.entity.ts @@ -0,0 +1 @@ +export type Uuid = `${string}-${string}-${string}-${string}-${string}`; From 422c71944329dfcc839845f7690a03dfe5f2ef3d Mon Sep 17 00:00:00 2001 From: iamacook Date: Mon, 22 Jul 2024 08:56:34 +0200 Subject: [PATCH 09/37] Fix tests --- migrations/00004_notifications/index.sql | 1 - .../__tests__/00004_notifications.spec.ts | 132 +++--------------- 2 files changed, 16 insertions(+), 117 deletions(-) diff --git a/migrations/00004_notifications/index.sql b/migrations/00004_notifications/index.sql index e4d0d2f140..8f0c74ad20 100644 --- a/migrations/00004_notifications/index.sql +++ b/migrations/00004_notifications/index.sql @@ -74,7 +74,6 @@ INSERT INTO notification_channels (name) VALUES ---------------------------------------------------------------- CREATE TABLE notification_channel_configurations( id SERIAL PRIMARY KEY, - -- TODO: Does this need this ID, it could be the same across subs notification_subscription_id INT NOT NULL, notification_channel_id INT NOT NULL, cloud_messaging_token VARCHAR(255) NOT NULL, diff --git a/migrations/__tests__/00004_notifications.spec.ts b/migrations/__tests__/00004_notifications.spec.ts index ae6a6a57e3..fa6c26d33a 100644 --- a/migrations/__tests__/00004_notifications.spec.ts +++ b/migrations/__tests__/00004_notifications.spec.ts @@ -479,122 +479,6 @@ describe('Migration 00004_notifications', () => { }); }); - it('should delete the subscription if the notification_type is deleted', async () => { - let deletedMultisigTransactionId: number; - - const result = await migrator.test({ - migration: '00004_notifications', - after: async (sql) => { - await sql.begin(async (transaction) => { - const [type] = await sql< - [Pick] - >`SELECT id FROM notification_types WHERE name = 'DELETED_MULTISIG_TRANSACTION'`; - - deletedMultisigTransactionId = type.id; - - // Create account - await transaction`INSERT INTO accounts (address) - VALUES ('0x69');`; - - // Add subscriptions to chains 1, 2, 3 - const chainIds = ['1', '2', '3']; - - await Promise.all( - chainIds.map(async (chainId) => { - const [subscription] = await transaction< - [Pick] - >`INSERT INTO notification_subscriptions (account_id, chain_id, safe_address) - VALUES (1, ${chainId}, '0x420') RETURNING id`; - await transaction`INSERT INTO notification_subscription_notification_types (subscription_id, notification_type_id) - VALUES (${subscription.id}, ${deletedMultisigTransactionId})`; - }), - ); - }); - - // Delete DELETED_MULTISIG_TRANSACTION notification type - await sql`DELETE FROM notification_types WHERE id = ${deletedMultisigTransactionId}`; - - return { - notification_types: await sql< - Array - >`SELECT * FROM notification_types`, - notification_subscriptions: await sql< - Array - >`SELECT * FROM notification_subscriptions`, - notification_subscription_notification_types: await sql< - Array - >`SELECT * FROM notification_subscription_notification_types`, - notification_channels: await sql< - Array - >`SELECT * FROM notification_channels`, - notification_channel_configurations: await sql< - Array - >`SELECT * FROM notification_channel_configurations`, - }; - }, - }); - - expect(result.after).toStrictEqual({ - notification_types: [ - // DELETED_MULTISIG_TRANSACTION is deleted - { - id: expect.any(Number), - name: 'EXECUTED_MULTISIG_TRANSACTION', - }, - { - id: expect.any(Number), - name: 'INCOMING_ETHER', - }, - { - id: expect.any(Number), - name: 'INCOMING_TOKEN', - }, - { - id: expect.any(Number), - name: 'MESSAGE_CREATED', - }, - { - id: expect.any(Number), - name: 'MODULE_TRANSACTION', - }, - { - id: expect.any(Number), - name: 'NEW_CONFIRMATION', - }, - { - id: expect.any(Number), - name: 'MESSAGE_CONFIRMATION', - }, - { - id: expect.any(Number), - name: 'OUTGOING_ETHER', - }, - { - id: expect.any(Number), - name: 'OUTGOING_TOKEN', - }, - { - id: expect.any(Number), - name: 'PENDING_MULTISIG_TRANSACTION', - }, - { - id: expect.any(Number), - name: 'SAFE_CREATED', - }, - ], - // No subscriptions should exist - notification_subscriptions: [], - notification_subscription_notification_types: [], - notification_channels: [ - { - id: 1, - name: 'PUSH_NOTIFICATIONS', - }, - ], - notification_channel_configurations: [], - }); - }); - it('should delete the notification_channel_configuration if the notification_channel is deleted', async () => { const result = await migrator.test({ migration: '00004_notifications', @@ -705,6 +589,22 @@ describe('Migration 00004_notifications', () => { created_at: expect.any(Date), updated_at: expect.any(Date), }, + { + id: 2, + chain_id: '2', + account_id: 1, + safe_address: '0x420', + created_at: expect.any(Date), + updated_at: expect.any(Date), + }, + { + id: 3, + chain_id: '3', + account_id: 1, + safe_address: '0x420', + created_at: expect.any(Date), + updated_at: expect.any(Date), + }, ], notification_subscription_notification_types: [], notification_channels: [], From 6c33fa5ee04df97488eb1dbd22b577c14be57057 Mon Sep 17 00:00:00 2001 From: iamacook Date: Mon, 22 Jul 2024 09:57:35 +0200 Subject: [PATCH 10/37] Improve datasource tests --- .../notifications.datasource.spec.ts | 305 ++++++++++++++++-- .../notifications/notifications.datasource.ts | 4 +- 2 files changed, 276 insertions(+), 33 deletions(-) diff --git a/src/datasources/accounts/notifications/notifications.datasource.spec.ts b/src/datasources/accounts/notifications/notifications.datasource.spec.ts index 77ae637bd1..50265c7525 100644 --- a/src/datasources/accounts/notifications/notifications.datasource.spec.ts +++ b/src/datasources/accounts/notifications/notifications.datasource.spec.ts @@ -3,11 +3,14 @@ import { AccountsDatasource } from '@/datasources/accounts/accounts.datasource'; import { upsertSubscriptionsDtoBuilder } from '@/datasources/accounts/notifications/__tests__/upsert-subscriptions.dto.entity.builder'; import { NotificationsDatasource } from '@/datasources/accounts/notifications/notifications.datasource'; import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; +import { NotificationChannel } from '@/domain/notifications/entities-v2/notification-channel.entity'; import { NotificationType } from '@/domain/notifications/entities-v2/notification-type.entity'; import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; import { ILoggingService } from '@/logging/logging.interface'; import { faker } from '@faker-js/faker'; +import { isEqual } from 'lodash'; import postgres from 'postgres'; +import { getAddress } from 'viem'; const mockLoggingService = { debug: jest.fn(), @@ -34,6 +37,11 @@ describe('NotificationsDatasource', () => { ); }); + afterEach(async () => { + // Don't truncate notification_types or notification_channels as they have predefined rows + await sql`TRUNCATE TABLE accounts, notification_subscriptions, notification_subscription_notification_types, notification_channel_configurations RESTART IDENTITY CASCADE`; + }); + afterAll(async () => { await testDbFactory.destroyTestDatabase(sql); }); @@ -49,25 +57,127 @@ describe('NotificationsDatasource', () => { expect(actual).toStrictEqual({ deviceUuid: expect.any(String) }); - // TODO: Check database structure + // Ensure correct database structure + await Promise.all([ + sql`SELECT * FROM accounts`, + sql`SELECT * FROM notification_subscriptions`, + sql`SELECT * FROM notification_subscription_notification_types`, + sql`SELECT * from notification_types`, + sql`SELECT * FROM notification_channels`, + sql`SELECT * FROM notification_channel_configurations`, + ]).then( + ([ + accounts, + subscriptions, + subscribedNotificationTypes, + notificationTypes, + channels, + channelsConfigs, + ]) => { + expect(accounts).toStrictEqual([ + { + id: 1, + group_id: null, + address: upsertSubscriptionsDto.account, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }, + ]); + expect(subscriptions).toStrictEqual( + upsertSubscriptionsDto.safes.map((subscription, i) => { + return { + id: i + 1, + account_id: 1, + chain_id: subscription.chainId, + safe_address: subscription.address, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }; + }), + ); + expect(subscribedNotificationTypes).toStrictEqual( + upsertSubscriptionsDto.safes.flatMap((subscription, i) => { + return subscription.notificationTypes.map(() => { + return { + id: expect.any(Number), + subscription_id: i + 1, + notification_type_id: expect.any(Number), + }; + }); + }), + ); + expect(notificationTypes).toStrictEqual( + Object.values(NotificationType).map((type) => { + return { + id: expect.any(Number), + name: type, + }; + }), + ); + expect(channels).toStrictEqual([ + { + id: 1, + name: NotificationChannel.PUSH_NOTIFICATIONS, + }, + ]); + expect(channelsConfigs).toStrictEqual( + upsertSubscriptionsDto.safes.map((_, i) => { + return { + id: i + 1, + notification_subscription_id: i + 1, + notification_channel_id: 1, + cloud_messaging_token: + upsertSubscriptionsDto.cloudMessagingToken, + device_type: upsertSubscriptionsDto.deviceType, + device_uuid: actual.deviceUuid, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }; + }), + ); + }, + ); }); it('should update subscriptions with new preferences', async () => { const deviceUuid = faker.string.uuid() as Uuid; + const chainId = faker.string.numeric(); + const safeAddress1 = getAddress(faker.finance.ethereumAddress()); + const safeAddress2 = getAddress(faker.finance.ethereumAddress()); + const notificationTypes1 = faker.helpers.arrayElements( + Object.values(NotificationType), + ); + const notificationTypes2 = faker.helpers.arrayElements( + Object.values(NotificationType), + ); + const notificationTypes2Upserted = faker.helpers.arrayElements( + Object.values(NotificationType), + ); const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() + .with('safes', [ + { + address: safeAddress1, + chainId, + notificationTypes: notificationTypes1, + }, + { + address: safeAddress2, + chainId, + notificationTypes: notificationTypes2, + }, + ]) .with('deviceUuid', deviceUuid) .build(); await accountsDatasource.createAccount(upsertSubscriptionsDto.account); await target.upsertSubscriptions(upsertSubscriptionsDto); + const safeSubscription = await target.getSafeSubscription({ + account: upsertSubscriptionsDto.account, + chainId: upsertSubscriptionsDto.safes[0].chainId, + safeAddress: upsertSubscriptionsDto.safes[0].address, + deviceUuid: deviceUuid, + }); - await expect( - target.getSafeSubscription({ - account: upsertSubscriptionsDto.account, - chainId: upsertSubscriptionsDto.safes[0].chainId, - safeAddress: upsertSubscriptionsDto.safes[0].address, - deviceUuid: deviceUuid, - }), - ).resolves.toEqual( + expect(safeSubscription).toEqual( Object.values(NotificationType).reduce< Record >( @@ -80,29 +190,28 @@ describe('NotificationsDatasource', () => { ), ); - const upsertedUpsertSubscriptionsDto = { - ...upsertSubscriptionsDto, - safes: [ + const upsertedUpsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() + .with('account', upsertSubscriptionsDto.account) + .with('cloudMessagingToken', upsertSubscriptionsDto.cloudMessagingToken) + .with('deviceType', upsertSubscriptionsDto.deviceType) + .with('deviceUuid', upsertSubscriptionsDto.deviceUuid) + .with('safes', [ { - chainId: upsertSubscriptionsDto.safes[0].chainId, - address: upsertSubscriptionsDto.safes[0].address, - notificationTypes: faker.helpers.arrayElements( - Object.values(NotificationType), - ), + address: safeAddress2, + chainId, + notificationTypes: notificationTypes2Upserted, }, - ], - }; - + ]) + .build(); await target.upsertSubscriptions(upsertedUpsertSubscriptionsDto); + const upsertedSafeSubscription = await target.getSafeSubscription({ + account: upsertedUpsertSubscriptionsDto.account, + chainId: upsertedUpsertSubscriptionsDto.safes[0].chainId, + safeAddress: upsertedUpsertSubscriptionsDto.safes[0].address, + deviceUuid: deviceUuid, + }); - await expect( - target.getSafeSubscription({ - account: upsertSubscriptionsDto.account, - chainId: upsertSubscriptionsDto.safes[0].chainId, - safeAddress: upsertSubscriptionsDto.safes[0].address, - deviceUuid: deviceUuid, - }), - ).resolves.toEqual( + expect(upsertedSafeSubscription).toEqual( Object.values(NotificationType).reduce< Record >( @@ -117,7 +226,12 @@ describe('NotificationsDatasource', () => { ), ); - // TODO: Check new preferences in database + expect( + isEqual( + Object.values(safeSubscription).sort(), + Object.values(upsertedSafeSubscription).sort(), + ), + ).toBe(false); }); }); @@ -201,7 +315,92 @@ describe('NotificationsDatasource', () => { }), ).rejects.toThrow('Error getting account subscription'); - // TODO: Check database structure + const remainingSubscriptions = upsertSubscriptionsDto.safes.filter( + (safe) => { + return safe.address !== upsertSubscriptionsDto.safes[0].address; + }, + ); + + // Ensure correct database structure + await Promise.all([ + sql`SELECT * FROM accounts`, + sql`SELECT * FROM notification_subscriptions`, + sql`SELECT * FROM notification_subscription_notification_types`, + sql`SELECT * from notification_types`, + sql`SELECT * FROM notification_channels`, + sql`SELECT * FROM notification_channel_configurations`, + ]).then( + ([ + accounts, + subscriptions, + subscribedNotificationTypes, + notificationTypes, + channels, + channelsConfigs, + ]) => { + expect(accounts).toStrictEqual([ + { + id: 1, + group_id: null, + address: upsertSubscriptionsDto.account, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }, + ]); + expect(subscriptions).toStrictEqual( + remainingSubscriptions.map((subscription) => { + return { + id: expect.any(Number), + account_id: 1, + chain_id: subscription.chainId, + safe_address: subscription.address, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }; + }), + ); + expect(subscribedNotificationTypes).toStrictEqual( + remainingSubscriptions.flatMap((subscription) => { + return subscription.notificationTypes.map(() => { + return { + id: expect.any(Number), + subscription_id: expect.any(Number), + notification_type_id: expect.any(Number), + }; + }); + }), + ); + expect(notificationTypes).toStrictEqual( + Object.values(NotificationType).map((type) => { + return { + id: expect.any(Number), + name: type, + }; + }), + ); + expect(channels).toStrictEqual([ + { + id: 1, + name: NotificationChannel.PUSH_NOTIFICATIONS, + }, + ]); + expect(channelsConfigs).toStrictEqual( + remainingSubscriptions.map(() => { + return { + id: expect.any(Number), + notification_subscription_id: expect.any(Number), + notification_channel_id: 1, + cloud_messaging_token: + upsertSubscriptionsDto.cloudMessagingToken, + device_type: upsertSubscriptionsDto.deviceType, + device_uuid: deviceUuid, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }; + }), + ); + }, + ); }); }); @@ -228,7 +427,51 @@ describe('NotificationsDatasource', () => { }), ); - // TODO: Check database structure + // Ensure correct database structure + await Promise.all([ + sql`SELECT * FROM accounts`, + sql`SELECT * FROM notification_subscriptions`, + sql`SELECT * FROM notification_subscription_notification_types`, + sql`SELECT * from notification_types`, + sql`SELECT * FROM notification_channels`, + sql`SELECT * FROM notification_channel_configurations`, + ]).then( + ([ + accounts, + subscriptions, + subscribedNotificationTypes, + notificationTypes, + channels, + channelsConfigs, + ]) => { + expect(accounts).toStrictEqual([ + { + id: 1, + group_id: null, + address: upsertSubscriptionsDto.account, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }, + ]); + expect(subscriptions).toStrictEqual([]); + expect(subscribedNotificationTypes).toStrictEqual([]); + expect(notificationTypes).toStrictEqual( + Object.values(NotificationType).map((type) => { + return { + id: expect.any(Number), + name: type, + }; + }), + ); + expect(channels).toStrictEqual([ + { + id: 1, + name: NotificationChannel.PUSH_NOTIFICATIONS, + }, + ]); + expect(channelsConfigs).toStrictEqual([]); + }, + ); }); }); }); diff --git a/src/datasources/accounts/notifications/notifications.datasource.ts b/src/datasources/accounts/notifications/notifications.datasource.ts index ec8d329635..ab8f1ad15b 100644 --- a/src/datasources/accounts/notifications/notifications.datasource.ts +++ b/src/datasources/accounts/notifications/notifications.datasource.ts @@ -19,6 +19,7 @@ import { NotificationSubscription } from '@/datasources/accounts/notifications/e import { NotificationChannelConfig } from '@/datasources/accounts/notifications/entities/notification-channel-config.entity'; import { NotificationType } from '@/datasources/accounts/notifications/entities/notification-type.entity'; +// TODO: Add caching @Injectable() export class NotificationsDatasource implements INotificationsDatasource { constructor( @@ -176,7 +177,6 @@ export class NotificationsDatasource implements INotificationsDatasource { return []; }); - console.log({ config }); if (!config) { throw new UnprocessableEntityException( 'Error inserting channel configuration', @@ -206,7 +206,7 @@ export class NotificationsDatasource implements INotificationsDatasource { private async insertSubscriptionType(args: { sql: postgres.TransactionSql; subscriptionId: number; - // TODO: Accept array + // TODO: Accept array? notificationType: DomainNotificationType; }): Promise { await args.sql`INSERT INTO notification_subscription_notification_types (subscription_id, notification_type_id) From 107b7da37a31c7eef8309325ddf6a51c44cadba4 Mon Sep 17 00:00:00 2001 From: iamacook Date: Mon, 22 Jul 2024 10:21:36 +0200 Subject: [PATCH 11/37] Merge branch 'main' into notifications-database --- .../notifications.datasource.spec.ts | 20 ++++++++- .../notifications/notifications.datasource.ts | 43 +++++++++---------- 2 files changed, 38 insertions(+), 25 deletions(-) diff --git a/src/datasources/accounts/notifications/notifications.datasource.spec.ts b/src/datasources/accounts/notifications/notifications.datasource.spec.ts index 50265c7525..bf1120a4c6 100644 --- a/src/datasources/accounts/notifications/notifications.datasource.spec.ts +++ b/src/datasources/accounts/notifications/notifications.datasource.spec.ts @@ -1,7 +1,9 @@ import { TestDbFactory } from '@/__tests__/db.factory'; +import { IConfigurationService } from '@/config/configuration.service.interface'; import { AccountsDatasource } from '@/datasources/accounts/accounts.datasource'; import { upsertSubscriptionsDtoBuilder } from '@/datasources/accounts/notifications/__tests__/upsert-subscriptions.dto.entity.builder'; import { NotificationsDatasource } from '@/datasources/accounts/notifications/notifications.datasource'; +import { FakeCacheService } from '@/datasources/cache/__tests__/fake.cache.service'; import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; import { NotificationChannel } from '@/domain/notifications/entities-v2/notification-channel.entity'; import { NotificationType } from '@/domain/notifications/entities-v2/notification-type.entity'; @@ -18,18 +20,32 @@ const mockLoggingService = { warn: jest.fn(), } as jest.MockedObjectDeep; +const mockConfigurationService = jest.mocked({ + getOrThrow: jest.fn(), +} as jest.MockedObjectDeep); + describe('NotificationsDatasource', () => { + let fakeCacheService: FakeCacheService; let accountsDatasource: AccountsDatasource; - let target: NotificationsDatasource; let migrator: PostgresDatabaseMigrator; let sql: postgres.Sql; const testDbFactory = new TestDbFactory(); + let target: NotificationsDatasource; beforeAll(async () => { + fakeCacheService = new FakeCacheService(); sql = await testDbFactory.createTestDatabase(faker.string.uuid()); migrator = new PostgresDatabaseMigrator(sql); await migrator.migrate(); - accountsDatasource = new AccountsDatasource(sql, mockLoggingService); + mockConfigurationService.getOrThrow.mockImplementation((key) => { + if (key === 'expirationTimeInSeconds.default') return faker.number.int(); + }); + accountsDatasource = new AccountsDatasource( + fakeCacheService, + sql, + mockLoggingService, + mockConfigurationService, + ); target = new NotificationsDatasource( sql, mockLoggingService, diff --git a/src/datasources/accounts/notifications/notifications.datasource.ts b/src/datasources/accounts/notifications/notifications.datasource.ts index ab8f1ad15b..a323bc4bba 100644 --- a/src/datasources/accounts/notifications/notifications.datasource.ts +++ b/src/datasources/accounts/notifications/notifications.datasource.ts @@ -81,15 +81,11 @@ export class NotificationsDatasource implements INotificationsDatasource { }); // Set new notification type preferences - return await Promise.all( - safe.notificationTypes.map((notificationType) => { - return this.insertSubscriptionType({ - sql, - subscriptionId: subscription.id, - notificationType, - }); - }), - ); + return this.insertSubscriptionTypes({ + sql, + subscriptionId: subscription.id, + notificationTypes: safe.notificationTypes, + }); }), ); }); @@ -203,23 +199,24 @@ export class NotificationsDatasource implements INotificationsDatasource { }); } - private async insertSubscriptionType(args: { + private async insertSubscriptionTypes(args: { sql: postgres.TransactionSql; subscriptionId: number; - // TODO: Accept array? - notificationType: DomainNotificationType; + notificationTypes: Array; }): Promise { - await args.sql`INSERT INTO notification_subscription_notification_types (subscription_id, notification_type_id) - VALUES (${args.subscriptionId}, (SELECT id FROM notification_types WHERE name = ${args.notificationType}))`.catch( - (e) => { - this.loggingService.info( - `Error inserting subscription notification type: ${asError(e).message}`, - ); - throw new UnprocessableEntityException( - 'Error inserting subscription notification type', - ); - }, - ); + await args.sql` + INSERT INTO notification_subscription_notification_types (subscription_id, notification_type_id) + SELECT ${args.subscriptionId}, nt.id + FROM UNNEST(${args.notificationTypes}::text[]) AS nt_name + JOIN notification_types nt ON nt.name = nt_name + `.catch((e) => { + this.loggingService.info( + `Error inserting subscription notification types: ${asError(e).message}`, + ); + throw new UnprocessableEntityException( + 'Error inserting subscription notification types', + ); + }); } /** From 4e10a90331e2c06c285de1a273299895ae15cb77 Mon Sep 17 00:00:00 2001 From: iamacook Date: Mon, 22 Jul 2024 10:40:34 +0200 Subject: [PATCH 12/37] Fix types --- src/domain/interfaces/notifications.datasource.interface.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/domain/interfaces/notifications.datasource.interface.ts b/src/domain/interfaces/notifications.datasource.interface.ts index fafe6cc7f5..d223b3a903 100644 --- a/src/domain/interfaces/notifications.datasource.interface.ts +++ b/src/domain/interfaces/notifications.datasource.interface.ts @@ -1,4 +1,6 @@ +import { NotificationChannelConfig } from '@/datasources/accounts/notifications/entities/notification-channel-config.entity'; import { UpsertSubscriptionsDto } from '@/datasources/accounts/notifications/entities/upsert-subscriptions.dto.entity'; +import { NotificationType } from '@/domain/notifications/entities-v2/notification-type.entity'; import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; export const INotificationsDatasource = Symbol('INotificationsDatasource'); @@ -13,12 +15,12 @@ export interface INotificationsDatasource { deviceUuid: Uuid; chainId: `0x${string}`; safeAddress: `0x${string}`; - }): Promise; + }): Promise>; getCloudMessagingTokensBySafe(args: { chainId: `0x${string}`; safeAddress: `0x${string}`; - }): Promise>; + }): Promise>; deleteSubscription(args: { account: `0x${string}`; From 8eddd8401ac899ba5fe9a2d8f5a5cca88d78b91a Mon Sep 17 00:00:00 2001 From: iamacook Date: Mon, 22 Jul 2024 14:33:27 +0200 Subject: [PATCH 13/37] Get subscribers with their tokens --- .../notifications.datasource.spec.ts | 13 +++- .../notifications/notifications.datasource.ts | 73 ++++++++++++++----- 2 files changed, 62 insertions(+), 24 deletions(-) diff --git a/src/datasources/accounts/notifications/notifications.datasource.spec.ts b/src/datasources/accounts/notifications/notifications.datasource.spec.ts index bf1120a4c6..8636b5b545 100644 --- a/src/datasources/accounts/notifications/notifications.datasource.spec.ts +++ b/src/datasources/accounts/notifications/notifications.datasource.spec.ts @@ -285,8 +285,8 @@ describe('NotificationsDatasource', () => { }); }); - describe('getCloudMessagingTokensBySafe', () => { - it('should get the cloud messaging tokens subscribed to a Safe', async () => { + describe('getSubscribersWithTokensBySafe', () => { + it('should get the subscribers with cloud messaging token for a Safe', async () => { const deviceUuid = faker.string.uuid() as Uuid; const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() .with('deviceUuid', deviceUuid) @@ -297,11 +297,16 @@ describe('NotificationsDatasource', () => { await Promise.all( upsertSubscriptionsDto.safes.map((safe) => { return expect( - target.getCloudMessagingTokensBySafe({ + target.getSubscribersWithTokensBySafe({ chainId: safe.chainId, safeAddress: safe.address, }), - ).resolves.toEqual([upsertSubscriptionsDto.cloudMessagingToken]); + ).resolves.toEqual([ + { + subscriber: upsertSubscriptionsDto.account, + cloudMessagingToken: upsertSubscriptionsDto.cloudMessagingToken, + }, + ]); }), ); }); diff --git a/src/datasources/accounts/notifications/notifications.datasource.ts b/src/datasources/accounts/notifications/notifications.datasource.ts index a323bc4bba..50c68978da 100644 --- a/src/datasources/accounts/notifications/notifications.datasource.ts +++ b/src/datasources/accounts/notifications/notifications.datasource.ts @@ -18,6 +18,7 @@ import { NotificationChannel } from '@/datasources/accounts/notifications/entiti import { NotificationSubscription } from '@/datasources/accounts/notifications/entities/notification-subscription.entity'; import { NotificationChannelConfig } from '@/datasources/accounts/notifications/entities/notification-channel-config.entity'; import { NotificationType } from '@/datasources/accounts/notifications/entities/notification-type.entity'; +import { Account } from '@/domain/accounts/entities/account.entity'; // TODO: Add caching @Injectable() @@ -323,30 +324,42 @@ export class NotificationsDatasource implements INotificationsDatasource { } /** - * Gets cloud messaging tokens for the given Safe. + * Gets subscribers and their cloud messaging tokens for the given Safe. * * @param args.chainId Chain ID * @param args.safeAddress Safe address * - * @returns List of cloud messaging tokens for the Safe + * @returns List of subscribers/tokens for given Safe */ - async getCloudMessagingTokensBySafe(args: { + async getSubscribersWithTokensBySafe(args: { chainId: string; safeAddress: `0x${string}`; - }): Promise> { + }): Promise< + Array<{ + subscriber: `0x${string}`; + cloudMessagingToken: string; + }> + > { return this.sql.begin(async (sql) => { const subscriptions = await this.getSafeSubscriptions({ sql, chainId: args.chainId, safeAddress: args.safeAddress, }); - const subscriptionIds = subscriptions.map((row) => row.id); - const configurations = await this.getChannelConfigs({ - sql, - subscriptionIds, - }); - return configurations.map((row) => row.cloud_messaging_token); + return Promise.all( + subscriptions.map(async (subscription) => { + const [account, config] = await Promise.all([ + this.getAccountById({ sql, accountId: subscription.account_id }), + this.getChannelConfig({ sql, subscriptionId: subscription.id }), + ]); + + return { + subscriber: account.address, + cloudMessagingToken: config.cloud_messaging_token, + }; + }), + ); }); } @@ -373,26 +386,46 @@ export class NotificationsDatasource implements INotificationsDatasource { return subscriptions; } - private async getChannelConfigs(args: { + private async getAccountById(args: { sql: postgres.TransactionSql; - subscriptionIds: Array; - }): Promise> { - const configs = await args.sql>` - SELECT cloud_messaging_token + accountId: number; + }): Promise { + const [account] = await args.sql<[Account]>` + SELECT * + FROM accounts + WHERE id = ${args.accountId} + `.catch((e) => { + this.loggingService.info(`Error getting account: ${asError(e).message}`); + return []; + }); + + if (!account) { + throw new NotFoundException('Error getting account'); + } + + return account; + } + + private async getChannelConfig(args: { + sql: postgres.TransactionSql; + subscriptionId: number; + }): Promise { + const [config] = await args.sql<[NotificationChannelConfig]>` + SELECT * FROM notification_channel_configurations - WHERE notification_subscription_id = ANY(${args.subscriptionIds}); + WHERE notification_subscription_id = ${args.subscriptionId}; `.catch((e) => { this.loggingService.info( - `Error getting channel configurations: ${asError(e).message}`, + `Error getting channel configuration: ${asError(e).message}`, ); return []; }); - if (configs.length === 0) { - throw new NotFoundException('Error getting channel configurations'); + if (!config) { + throw new NotFoundException('Error getting channel configuration'); } - return configs; + return config; } /** From bbf887220263d3371eb3bbe8021baa16e7b1470c Mon Sep 17 00:00:00 2001 From: iamacook Date: Tue, 23 Jul 2024 09:17:58 +0200 Subject: [PATCH 14/37] Fix lint and tests --- .../__tests__/00005_notifications.spec.ts | 132 ++++++++++-------- .../notifications.datasource.interface.ts | 6 - .../entities-v2/notification-type.entity.ts | 9 +- 3 files changed, 72 insertions(+), 75 deletions(-) diff --git a/migrations/__tests__/00005_notifications.spec.ts b/migrations/__tests__/00005_notifications.spec.ts index c12a1e4a1c..bfb54592db 100644 --- a/migrations/__tests__/00005_notifications.spec.ts +++ b/migrations/__tests__/00005_notifications.spec.ts @@ -192,36 +192,22 @@ describe('Migration 00005_notifications', () => { VALUES (1, 1, '0x420')`; }); - return { - columns: - await sql`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'notification_subscriptions'`, - rows: await sql< - Array - >`SELECT * FROM notification_subscriptions`, - }; + return await sql< + Array + >`SELECT * FROM notification_subscriptions`; }, }); - expect(afterInsert.after).toStrictEqual({ - columns: expect.arrayContaining([ - { column_name: 'id' }, - { column_name: 'account_id' }, - { column_name: 'chain_id' }, - { column_name: 'safe_address' }, - { column_name: 'created_at' }, - { column_name: 'updated_at' }, - ]), - rows: [ - { - id: 1, - chain_id: '1', - account_id: 1, - safe_address: '0x420', - created_at: expect.any(Date), - updated_at: expect.any(Date), - }, - ], - }); + expect(afterInsert.after).toStrictEqual([ + { + id: 1, + chain_id: '1', + account_id: 1, + safe_address: '0x420', + created_at: expect.any(Date), + updated_at: expect.any(Date), + }, + ]); const afterUpdate = await sql< Array @@ -236,12 +222,12 @@ describe('Migration 00005_notifications', () => { account_id: 1, safe_address: '0x69', // created_at should have remained the same - created_at: afterInsert.after.rows[0].created_at, + created_at: afterInsert.after[0].created_at, updated_at: expect.any(Date), }, ]); // updated_at should have updated - expect(afterInsert.after.rows[0].updated_at).not.toEqual( + expect(afterInsert.after[0].updated_at).not.toEqual( afterUpdate[0].updated_at, ); }); @@ -268,40 +254,24 @@ describe('Migration 00005_notifications', () => { VALUES (1, 1, '69420', ${crypto.randomUUID()}, 'WEB')`; }); - return { - columns: - await sql`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'notification_channel_configurations'`, - rows: await sql< - Array - >`SELECT * FROM notification_channel_configurations`, - }; + return await sql< + Array + >`SELECT * FROM notification_channel_configurations`; }, }); - expect(afterInsert.after).toStrictEqual({ - columns: expect.arrayContaining([ - { column_name: 'id' }, - { column_name: 'notification_subscription_id' }, - { column_name: 'notification_channel_id' }, - { column_name: 'device_uuid' }, - { column_name: 'device_type' }, - { column_name: 'cloud_messaging_token' }, - { column_name: 'created_at' }, - { column_name: 'updated_at' }, - ]), - rows: [ - { - id: 1, - notification_subscription_id: 1, - notification_channel_id: 1, - device_uuid: expect.any(String), - device_type: 'WEB', - cloud_messaging_token: '69420', - created_at: expect.any(Date), - updated_at: expect.any(Date), - }, - ], - }); + expect(afterInsert.after).toStrictEqual([ + { + id: 1, + notification_subscription_id: 1, + notification_channel_id: 1, + device_uuid: expect.any(String), + device_type: 'WEB', + cloud_messaging_token: '69420', + created_at: expect.any(Date), + updated_at: expect.any(Date), + }, + ]); const afterUpdate = await sql< Array @@ -318,16 +288,54 @@ describe('Migration 00005_notifications', () => { device_type: 'WEB', cloud_messaging_token: '1337', // created_at should have remained the same - created_at: afterInsert.after.rows[0].created_at, + created_at: afterInsert.after[0].created_at, updated_at: expect.any(Date), }, ]); // updated_at should have updated - expect(afterInsert.after.rows[0].updated_at).not.toEqual( + expect(afterInsert.after[0].updated_at).not.toEqual( afterUpdate[0].updated_at, ); }); + it('should only allow ANDROID, IOS and WEB as device_type in notification_channel_configurations', async () => { + await migrator.test({ + migration: '00005_notifications', + after: async (sql: postgres.Sql) => { + await sql.begin(async (transaction) => { + // Create account + await transaction`INSERT INTO accounts (address) + VALUES ('0x69');`; + // Add notification subscription to account + await transaction< + [Pick] + >`INSERT INTO notification_subscriptions (account_id, chain_id, safe_address) + VALUES (1, 1, '0x420')`; + }); + + return await sql< + Array + >`SELECT * FROM notification_channel_configurations`; + }, + }); + + const deviceTypes = ['ANDROID', 'IOS', 'WEB']; + const invalidDeviceType = faker.string.alpha().toUpperCase(); + + await expect( + Promise.all( + deviceTypes.map((deviceType) => { + return sql`INSERT INTO notification_channel_configurations (notification_subscription_id, notification_channel_id, cloud_messaging_token, device_uuid, device_type) + VALUES (1, 1, '69420', ${crypto.randomUUID()}, ${deviceType})`; + }), + ), + ).resolves.not.toThrow(); + await expect(sql`INSERT INTO notification_channel_configurations (notification_subscription_id, notification_channel_id, cloud_messaging_token, device_uuid, device_type) + VALUES (1, 1, '69420', ${crypto.randomUUID()}, ${invalidDeviceType})`).rejects.toThrow( + 'new row for relation "notification_channel_configurations" violates check constraint "notification_channel_configurations_device_type_check"', + ); + }); + it('should prevent duplicate subscriptions in notification_subscriptions', async () => { await migrator.test({ migration: '00005_notifications', diff --git a/src/domain/interfaces/notifications.datasource.interface.ts b/src/domain/interfaces/notifications.datasource.interface.ts index d223b3a903..61df5600e6 100644 --- a/src/domain/interfaces/notifications.datasource.interface.ts +++ b/src/domain/interfaces/notifications.datasource.interface.ts @@ -1,4 +1,3 @@ -import { NotificationChannelConfig } from '@/datasources/accounts/notifications/entities/notification-channel-config.entity'; import { UpsertSubscriptionsDto } from '@/datasources/accounts/notifications/entities/upsert-subscriptions.dto.entity'; import { NotificationType } from '@/domain/notifications/entities-v2/notification-type.entity'; import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; @@ -17,11 +16,6 @@ export interface INotificationsDatasource { safeAddress: `0x${string}`; }): Promise>; - getCloudMessagingTokensBySafe(args: { - chainId: `0x${string}`; - safeAddress: `0x${string}`; - }): Promise>; - deleteSubscription(args: { account: `0x${string}`; chainId: `0x${string}`; diff --git a/src/domain/notifications/entities-v2/notification-type.entity.ts b/src/domain/notifications/entities-v2/notification-type.entity.ts index dbf8802edd..fb94b624fb 100644 --- a/src/domain/notifications/entities-v2/notification-type.entity.ts +++ b/src/domain/notifications/entities-v2/notification-type.entity.ts @@ -1,14 +1,9 @@ export enum NotificationType { + CONFIRMATION_REQUEST = 'CONFIRMATION_REQUEST', DELETED_MULTISIG_TRANSACTION = 'DELETED_MULTISIG_TRANSACTION', EXECUTED_MULTISIG_TRANSACTION = 'EXECUTED_MULTISIG_TRANSACTION', INCOMING_ETHER = 'INCOMING_ETHER', INCOMING_TOKEN = 'INCOMING_TOKEN', - MESSAGE_CREATED = 'MESSAGE_CREATED', + MESSAGE_CONFIRMATION_REQUEST = 'MESSAGE_CONFIRMATION_REQUEST', MODULE_TRANSACTION = 'MODULE_TRANSACTION', - NEW_CONFIRMATION = 'NEW_CONFIRMATION', - MESSAGE_CONFIRMATION = 'MESSAGE_CONFIRMATION', - OUTGOING_ETHER = 'OUTGOING_ETHER', - OUTGOING_TOKEN = 'OUTGOING_TOKEN', - PENDING_MULTISIG_TRANSACTION = 'PENDING_MULTISIG_TRANSACTION', - SAFE_CREATED = 'SAFE_CREATED', } From 906e7b670a23618d9ee900af028a275c0852cf04 Mon Sep 17 00:00:00 2001 From: iamacook Date: Wed, 24 Jul 2024 08:05:20 +0200 Subject: [PATCH 15/37] Facilitate multiple Safe notifications across devices --- migrations/00005_notifications/index.sql | 98 +- .../__tests__/00005_notifications.spec.ts | 940 +++++++++++------- ...upsert-subscriptions.dto.entity.builder.ts | 2 +- .../notifications.datasource.spec.ts | 649 ++++++------ .../notifications/notifications.datasource.ts | 545 +++------- .../notifications.datasource.interface.ts | 17 +- 6 files changed, 1079 insertions(+), 1172 deletions(-) diff --git a/migrations/00005_notifications/index.sql b/migrations/00005_notifications/index.sql index ea56c2c2fb..8cc9aaa947 100644 --- a/migrations/00005_notifications/index.sql +++ b/migrations/00005_notifications/index.sql @@ -1,13 +1,40 @@ -DROP TABLE IF EXISTS notification_types, - notification_subscriptions, - notification_subscription_notification_types, - notification_channels, - notification_channel_configurations CASCADE; +DROP TABLE IF EXISTS notification_devices, notification_channels, notification_types, notification_subscriptions, notification_subscription_notification_types CASCADE; + +--------------------------------------------------- +-- Notification devices: 'ANDROID', 'IOS', 'WEB' -- +--------------------------------------------------- +CREATE TABLE notification_devices ( + id SERIAL PRIMARY KEY, + account_id INT NOT NULL, + device_type VARCHAR(255) CHECK (device_type IN ('ANDROID', 'IOS', 'WEB')) NOT NULL, + device_uuid UUID NOT NULL UNIQUE, + cloud_messaging_token VARCHAR(255) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE +); + +-- Update updated_at when device is updated to track validity of token +CREATE OR REPLACE TRIGGER update_notification_devices_updated_at + BEFORE UPDATE ON notification_devices + FOR EACH ROW +EXECUTE FUNCTION update_updated_at_column(); + +CREATE TABLE notification_channels ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE +); + +---------------------------------------------------- +-- Notification channels, e.g. PUSH_NOTIFICATIONS -- +---------------------------------------------------- +INSERT INTO notification_channels (name) VALUES + ('PUSH_NOTIFICATIONS'); -------------------------------------------- -- Notification types, e.g. INCOMING_TOKEN -- -------------------------------------------- -CREATE TABLE notification_types( +CREATE TABLE notification_types ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL UNIQUE ); @@ -21,18 +48,22 @@ INSERT INTO notification_types (name) VALUES ('MESSAGE_CONFIRMATION_REQUEST'), -- MESSAGE_CREATED ('MODULE_TRANSACTION'); ----------------------------------------------------------------------- --- Chain-specific Safe notification preferences for a given account -- ----------------------------------------------------------------------- -CREATE TABLE notification_subscriptions( +----------------------------------------------------------- +-- Safe subscriptions for a given account-device-channel -- +----------------------------------------------------------- +CREATE TABLE notification_subscriptions ( id SERIAL PRIMARY KEY, account_id INT NOT NULL, + device_id INT NOT NULL, chain_id VARCHAR(255) NOT NULL, safe_address VARCHAR(42) NOT NULL, + notification_channel_id INT NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE, - UNIQUE(account_id, chain_id, safe_address) + FOREIGN KEY (device_id) REFERENCES notification_devices(id) ON DELETE CASCADE, + FOREIGN KEY (notification_channel_id) REFERENCES notification_channels(id) ON DELETE CASCADE, + UNIQUE(account_id, chain_id, safe_address, device_id, notification_channel_id) ); -- Update updated_at when a notification subscription is updated @@ -42,46 +73,11 @@ CREATE OR REPLACE TRIGGER update_notification_subscriptions_updated_at EXECUTE FUNCTION update_updated_at_column(); -- Join table for subscriptions/notification types -CREATE TABLE notification_subscription_notification_types( - id SERIAL PRIMARY KEY, - subscription_id INT NOT NULL, - notification_type_id INT NOT NULL, - FOREIGN KEY (subscription_id) REFERENCES notification_subscriptions(id) ON DELETE CASCADE, - FOREIGN KEY (notification_type_id) REFERENCES notification_types(id) ON DELETE CASCADE, - UNIQUE (subscription_id, notification_type_id) -); - ---------------------------------------------------- --- Notification channels, e.g. PUSH_NOTIFICATIONS -- ---------------------------------------------------- -CREATE TABLE notification_channels( - id SERIAL PRIMARY KEY, - name VARCHAR(255) NOT NULL UNIQUE -); - --- Add PUSH_NOTIFICATIONS as a notification channel -INSERT INTO notification_channels (name) VALUES - ('PUSH_NOTIFICATIONS'); - ----------------------------------------------------------------- --- Configuration for a given notification subscription/channel -- ----------------------------------------------------------------- -CREATE TABLE notification_channel_configurations( +CREATE TABLE notification_subscription_notification_types ( id SERIAL PRIMARY KEY, notification_subscription_id INT NOT NULL, - notification_channel_id INT NOT NULL, - cloud_messaging_token VARCHAR(255) NOT NULL, - device_type VARCHAR(255) CHECK (device_type IN ('ANDROID', 'IOS', 'WEB')) NOT NULL, - device_uuid UUID NOT NULL, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + notification_type_id INT NOT NULL, FOREIGN KEY (notification_subscription_id) REFERENCES notification_subscriptions(id) ON DELETE CASCADE, - FOREIGN KEY (notification_channel_id) REFERENCES notification_channels(id) ON DELETE CASCADE, - UNIQUE (notification_subscription_id, notification_channel_id, device_uuid) -); - --- Update updated_at when a notification channel is updated -CREATE OR REPLACE TRIGGER update_notification_channel_configurations_updated_at - BEFORE UPDATE ON notification_channel_configurations - FOR EACH ROW -EXECUTE FUNCTION update_updated_at_column(); + FOREIGN KEY (notification_type_id) REFERENCES notification_types(id) ON DELETE CASCADE, + UNIQUE(notification_subscription_id, notification_type_id) +); \ No newline at end of file diff --git a/migrations/__tests__/00005_notifications.spec.ts b/migrations/__tests__/00005_notifications.spec.ts index bfb54592db..78a3f11c4c 100644 --- a/migrations/__tests__/00005_notifications.spec.ts +++ b/migrations/__tests__/00005_notifications.spec.ts @@ -1,43 +1,55 @@ import { TestDbFactory } from '@/__tests__/db.factory'; import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; +import { DeviceType } from '@/domain/notifications/entities-v2/device-type.entity'; +import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; import { faker } from '@faker-js/faker'; import postgres from 'postgres'; +import { getAddress } from 'viem'; -type NotificationTypesRow = { - id: number; - name: string; -}; - -type NotificationSubscriptionsRow = { +type NotificationDevicesRow = { id: number; account_id: number; - chain_id: string; - safe_address: `0x${string}`; + device_type: 'ANDROID' | 'IOS' | 'WEB'; + device_uuid: Uuid; + cloud_messaging_token: string; created_at: Date; updated_at: Date; }; -type NotificationSubscriptionNotificationTypesRow = { +type NotificationChannelsRow = { id: number; - subscription_id: number; - notification_type_id: number; + name: 'PUSH_NOTIFICATIONS'; }; -type NotificationChannelsRow = { +type NotificationTypesRow = { id: number; - name: string; + name: + | 'CONFIRMATION_REQUEST' + | 'DELETED_MULTISIG_TRANSACTION' + | 'EXECUTED_MULTISIG_TRANSACTION' + | 'INCOMING_ETHER' + | 'INCOMING_TOKEN' + | 'MESSAGE_CONFIRMATION_REQUEST' + | 'MODULE_TRANSACTION'; }; -type NotificationChannelConfigurationsRow = { +type NotificationSubscriptionsRow = { id: number; - notification_subscription_id: number; - notification_channel_id: number; - device_uuid: `${string}-${string}-${string}-${string}-${string}`; - cloud_messaging_token: string; + account_id: number; + device_id: NotificationDevicesRow['id']; + chain_id: string; + safe_address: `0x${string}`; + notification_channel_id: NotificationChannelsRow['id']; created_at: Date; updated_at: Date; }; +type NotificationSubscriptionNotificationTypesRow = { + id: number; + notification_subscription_id: NotificationSubscriptionsRow['id']; + notification_type_id: NotificationTypesRow['id']; +}; + describe('Migration 00005_notifications', () => { let sql: postgres.Sql; let migrator: PostgresDatabaseMigrator; @@ -57,6 +69,20 @@ describe('Migration 00005_notifications', () => { migration: '00005_notifications', after: async (sql: postgres.Sql) => { return { + notification_devices: { + columns: + await sql`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'notification_devices'`, + rows: await sql< + Array + >`SELECT * FROM notification_devices`, + }, + notification_channels: { + columns: + await sql`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'notification_channels'`, + rows: await sql< + Array + >`SELECT * FROM notification_channels`, + }, notification_types: { columns: await sql`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'notification_types'`, @@ -75,46 +101,37 @@ describe('Migration 00005_notifications', () => { columns: await sql`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'notification_subscription_notification_types'`, rows: await sql< - Array + Array >`SELECT * FROM notification_subscription_notification_types`, }, - notification_channels: { - columns: - await sql`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'notification_channels'`, - rows: await sql< - Array - >`SELECT * FROM notification_channels`, - }, - notification_channel_configurations: { - columns: - await sql`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'notification_channel_configurations'`, - rows: await sql< - Array - >`SELECT * FROM notification_channel_configurations`, - }, }; }, }); expect(result.after).toStrictEqual({ - notification_subscriptions: { + notification_devices: { columns: expect.arrayContaining([ { column_name: 'id' }, { column_name: 'account_id' }, - { column_name: 'chain_id' }, - { column_name: 'safe_address' }, + { column_name: 'device_type' }, + { column_name: 'device_uuid' }, + { column_name: 'cloud_messaging_token' }, { column_name: 'created_at' }, { column_name: 'updated_at' }, ]), rows: [], }, - notification_subscription_notification_types: { + notification_channels: { columns: expect.arrayContaining([ { column_name: 'id' }, - { column_name: 'subscription_id' }, - { column_name: 'notification_type_id' }, + { column_name: 'name' }, ]), - rows: [], + rows: [ + { + id: expect.any(Number), + name: 'PUSH_NOTIFICATIONS', + }, + ], }, notification_types: { columns: expect.arrayContaining([ @@ -152,412 +169,613 @@ describe('Migration 00005_notifications', () => { }, ], }, - notification_channels: { + notification_subscriptions: { columns: expect.arrayContaining([ { column_name: 'id' }, - { column_name: 'name' }, + { column_name: 'account_id' }, + { column_name: 'device_id' }, + { column_name: 'chain_id' }, + { column_name: 'safe_address' }, + { column_name: 'notification_channel_id' }, + { column_name: 'created_at' }, + { column_name: 'updated_at' }, ]), - rows: [ - { - id: expect.any(Number), - name: 'PUSH_NOTIFICATIONS', - }, - ], + rows: [], }, - notification_channel_configurations: { + notification_subscription_notification_types: { columns: expect.arrayContaining([ - { column_name: 'updated_at' }, - { column_name: 'created_at' }, - { column_name: 'notification_subscription_id' }, - { column_name: 'notification_channel_id' }, { column_name: 'id' }, - { column_name: 'device_uuid' }, - { column_name: 'cloud_messaging_token' }, + { column_name: 'notification_subscription_id' }, + { column_name: 'notification_type_id' }, ]), rows: [], }, }); }); - it('should upsert the row timestamps of notification_subscriptions on insertion/update', async () => { - const afterInsert = await migrator.test({ + it('should upsert the updated_at timestamp in notification_devices', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const deviceType = faker.helpers.arrayElement(Object.values(DeviceType)); + const deviceUuid = faker.string.uuid() as Uuid; + const cloudMessagingToken = faker.string.alphanumeric(); + const afterMigration = await migrator.test({ migration: '00005_notifications', after: async (sql: postgres.Sql) => { - await sql.begin(async (transaction) => { - // Create account - await transaction`INSERT INTO accounts (address) - VALUES ('0x69');`; - // Add notification subscription to account - await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, safe_address) - VALUES (1, 1, '0x420')`; - }); - - return await sql< - Array - >`SELECT * FROM notification_subscriptions`; + // Create account + const [account] = await sql< + [ + { + id: number; + }, + ] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`; + // Create device + return sql< + [NotificationDevicesRow] + >`INSERT INTO notification_devices (account_id, device_type, device_uuid, cloud_messaging_token) VALUES (${account.id}, ${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`; }, }); - expect(afterInsert.after).toStrictEqual([ + expect(afterMigration.after).toStrictEqual([ { id: 1, - chain_id: '1', account_id: 1, - safe_address: '0x420', + device_type: deviceType, + device_uuid: deviceUuid, + cloud_messaging_token: cloudMessagingToken, created_at: expect.any(Date), updated_at: expect.any(Date), }, ]); + const newDeviceUuid = faker.string.uuid() as Uuid; + // Update device with new device_uuid const afterUpdate = await sql< - Array - >`UPDATE notification_subscriptions - SET safe_address = '0x69' - WHERE id = 1 RETURNING *`; + [NotificationDevicesRow] + >`UPDATE notification_devices SET device_uuid = ${newDeviceUuid} WHERE device_uuid = ${deviceUuid} RETURNING *`; expect(afterUpdate).toStrictEqual([ { - id: 1, - chain_id: '1', - account_id: 1, - safe_address: '0x69', + id: afterMigration.after[0].id, + account_id: afterMigration.after[0].account_id, + device_type: afterMigration.after[0].device_type, + device_uuid: newDeviceUuid, + cloud_messaging_token: afterMigration.after[0].cloud_messaging_token, // created_at should have remained the same - created_at: afterInsert.after[0].created_at, + created_at: afterMigration.after[0].created_at, updated_at: expect.any(Date), }, ]); // updated_at should have updated - expect(afterInsert.after[0].updated_at).not.toEqual( + expect(afterMigration.after[0].updated_at).not.toEqual( afterUpdate[0].updated_at, ); }); - it('should upsert the row timestamps of notification_channel_configurations on insertion/update', async () => { - const afterInsert = await migrator.test({ + it('should only allow an ANDROID, IOS, or WEB as device_type in notification_devices', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const deviceType = faker.lorem.word() as DeviceType; + const deviceUuid = faker.string.uuid() as Uuid; + const cloudMessagingToken = faker.string.alphanumeric(); + const afterMigration = await migrator.test({ + migration: '00005_notifications', + after: (sql: postgres.Sql) => { + // Create account + return sql< + [ + { + id: number; + }, + ] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`; + }, + }); + + // Create device with invalid device_type + await expect( + sql`INSERT INTO notification_devices (account_id, device_type, device_uuid, cloud_messaging_token) VALUES (${afterMigration.after[0].id}, ${deviceType}, ${deviceUuid}, ${cloudMessagingToken})`, + ).rejects.toThrow( + 'new row for relation "notification_devices" violates check constraint "notification_devices_device_type_check"', + ); + }); + + it('should not allow a duplicate device_uuid in notification_devices', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const deviceType = faker.helpers.arrayElement(Object.values(DeviceType)); + const deviceUuid = faker.string.uuid() as Uuid; + const cloudMessagingToken = faker.string.alphanumeric(); + const afterMigration = await migrator.test({ + migration: '00005_notifications', + after: async (sql: postgres.Sql) => { + // Create account + const [account] = await sql< + [ + { + id: number; + }, + ] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`; + // Create device + return sql< + [NotificationDevicesRow] + >`INSERT INTO notification_devices (account_id, device_type, device_uuid, cloud_messaging_token) VALUES (${account.id}, ${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`; + }, + }); + + // Create device with duplicate device_uuid + await expect( + sql`INSERT INTO notification_devices (account_id, device_type, device_uuid, cloud_messaging_token) VALUES (${afterMigration.after[0].id}, ${deviceType}, ${deviceUuid}, ${cloudMessagingToken})`, + ).rejects.toThrow( + 'duplicate key value violates unique constraint "notification_devices_device_uuid_key"', + ); + }); + + it('should delete the device if the account is deleted', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const deviceType = faker.helpers.arrayElement(Object.values(DeviceType)); + const deviceUuid = faker.string.uuid() as Uuid; + const cloudMessagingToken = faker.string.alphanumeric(); + const afterMigration = await migrator.test({ migration: '00005_notifications', after: async (sql: postgres.Sql) => { - await sql.begin(async (transaction) => { - // Create account - await transaction`INSERT INTO accounts (address) - VALUES ('0x69');`; - // Add notification subscription to account - const [subscription] = await transaction< - [Pick] - >`INSERT INTO notification_subscriptions (account_id, chain_id, safe_address) - VALUES (1, 1, '0x420') RETURNING id`; - // Add notification preference - await transaction`INSERT INTO notification_subscription_notification_types (subscription_id, notification_type_id) - VALUES(${subscription.id}, 1)`; - - // Enable notification channel - await transaction`INSERT INTO notification_channel_configurations (notification_subscription_id, notification_channel_id, cloud_messaging_token, device_uuid, device_type) - VALUES (1, 1, '69420', ${crypto.randomUUID()}, 'WEB')`; - }); - - return await sql< - Array - >`SELECT * FROM notification_channel_configurations`; + // Create account + const [account] = await sql< + [ + { + id: number; + }, + ] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`; + // Create device + const [device] = await sql< + [NotificationDevicesRow] + >`INSERT INTO notification_devices (account_id, device_type, device_uuid, cloud_messaging_token) VALUES (${account.id}, ${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`; + return { account, device }; }, }); + // Delete account + await sql`DELETE FROM accounts WHERE id = ${afterMigration.after.account.id}`; - expect(afterInsert.after).toStrictEqual([ + // Assert that device was deleted + await expect( + sql`SELECT * FROM notification_devices WHERE id = ${afterMigration.after.device.id}`, + ).resolves.toStrictEqual([]); + }); + + it("shouldn't allow a duplicate name in notification_channels", async () => { + const afterMigration = await migrator.test({ + migration: '00005_notifications', + after: (sql: postgres.Sql) => { + // Get all notification channels + return sql< + Array + >`SELECT * FROM notification_channels`; + }, + }); + + // Create channel with duplicate name + await expect( + sql`INSERT INTO notification_channels (name) VALUES (${afterMigration.after[0].name})`, + ).rejects.toThrow( + 'duplicate key value violates unique constraint "notification_channels_name_key"', + ); + }); + + it("shouldn't allow a duplicate name in notification_types", async () => { + const afterMigration = await migrator.test({ + migration: '00005_notifications', + after: (sql: postgres.Sql) => { + // Get all notification types + return sql< + Array + >`SELECT * FROM notification_types`; + }, + }); + + // Create type with duplicate name + await expect( + sql`INSERT INTO notification_types (name) VALUES (${afterMigration.after[0].name})`, + ).rejects.toThrow( + 'duplicate key value violates unique constraint "notification_types_name_key"', + ); + }); + + it('should delete the subscription if the account is deleted', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const safeAddress = getAddress(faker.finance.ethereumAddress()); + const chainId = faker.string.numeric(); + const deviceType = faker.helpers.arrayElement(Object.values(DeviceType)); + const deviceUuid = faker.string.uuid() as Uuid; + const cloudMessagingToken = faker.string.alphanumeric(); + const afterMigration = await migrator.test({ + migration: '00005_notifications', + after: async (sql: postgres.Sql) => { + // Create account + const [account] = await sql< + [ + { + id: number; + }, + ] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`; + const [[device], [channel]] = await Promise.all([ + // Create device + sql< + [NotificationDevicesRow] + >`INSERT INTO notification_devices (account_id, device_type, device_uuid, cloud_messaging_token) VALUES (${account.id}, ${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, + // Get all notification channels + sql< + Array + >`SELECT * FROM notification_channels`, + ]); + // Create subscription + const [subscription] = await sql< + [NotificationSubscriptionsRow] + >`INSERT INTO notification_subscriptions (account_id, device_id, chain_id, safe_address, notification_channel_id) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress}, ${channel.id}) RETURNING *`; + return { account, subscription }; + }, + }); + // Delete account + await sql`DELETE FROM accounts WHERE id = ${afterMigration.after.account.id}`; + + // Assert that subscription was deleted + await expect( + sql`SELECT * FROM notification_subscriptions WHERE id = ${afterMigration.after.subscription.id}`, + ).resolves.toStrictEqual([]); + }); + + it('should delete the subscription if the device is deleted', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const safeAddress = getAddress(faker.finance.ethereumAddress()); + const chainId = faker.string.numeric(); + const deviceType = faker.helpers.arrayElement(Object.values(DeviceType)); + const deviceUuid = faker.string.uuid() as Uuid; + const cloudMessagingToken = faker.string.alphanumeric(); + const afterMigration = await migrator.test({ + migration: '00005_notifications', + after: async (sql: postgres.Sql) => { + // Create account + const [account] = await sql< + [ + { + id: number; + }, + ] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`; + const [[device], [channel]] = await Promise.all([ + // Create device + sql< + [NotificationDevicesRow] + >`INSERT INTO notification_devices (account_id, device_type, device_uuid, cloud_messaging_token) VALUES (${account.id}, ${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, + // Get all notification channels + sql< + Array + >`SELECT * FROM notification_channels`, + ]); + // Create subscription + const [subscription] = await sql< + [NotificationSubscriptionsRow] + >`INSERT INTO notification_subscriptions (account_id, device_id, chain_id, safe_address, notification_channel_id) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress}, ${channel.id}) RETURNING *`; + + return { device, subscription }; + }, + }); + // Delete device + await sql`DELETE FROM notification_devices WHERE id = ${afterMigration.after.device.id}`; + + // Assert that subscription was deleted + await expect( + sql`SELECT * FROM notification_subscriptions WHERE id = ${afterMigration.after.subscription.id}`, + ).resolves.toStrictEqual([]); + }); + + it('should delete the subscription if the channel is deleted', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const safeAddress = getAddress(faker.finance.ethereumAddress()); + const chainId = faker.string.numeric(); + const deviceType = faker.helpers.arrayElement(Object.values(DeviceType)); + const deviceUuid = faker.string.uuid() as Uuid; + const cloudMessagingToken = faker.string.alphanumeric(); + const afterMigration = await migrator.test({ + migration: '00005_notifications', + after: async (sql: postgres.Sql) => { + // Create account + const [account] = await sql< + [ + { + id: number; + }, + ] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`; + const [[device], [channel]] = await Promise.all([ + // Create device + sql< + [NotificationDevicesRow] + >`INSERT INTO notification_devices (account_id, device_type, device_uuid, cloud_messaging_token) VALUES (${account.id}, ${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, + // Get all notification channels + sql< + Array + >`SELECT * FROM notification_channels`, + ]); + // Create subscription + const [subscription] = await sql< + [NotificationSubscriptionsRow] + >`INSERT INTO notification_subscriptions (account_id, device_id, chain_id, safe_address, notification_channel_id) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress}, ${channel.id}) RETURNING *`; + return { channel, subscription }; + }, + }); + // Delete channel + await sql`DELETE FROM notification_channels WHERE id = ${afterMigration.after.channel.id}`; + + // Assert that subscription was deleted + await expect( + sql`SELECT * FROM notification_subscriptions WHERE id = ${afterMigration.after.subscription.id}`, + ).resolves.toStrictEqual([]); + }); + + it('should prevent duplicate subscriptions (account, chain, Safe, device and channel) in notification_subscriptions', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const safeAddress = getAddress(faker.finance.ethereumAddress()); + const chainId = faker.string.numeric(); + const deviceType = faker.helpers.arrayElement(Object.values(DeviceType)); + const deviceUuid = faker.string.uuid() as Uuid; + const cloudMessagingToken = faker.string.alphanumeric(); + const afterMigration = await migrator.test({ + migration: '00005_notifications', + after: async (sql: postgres.Sql) => { + // Create account + const [account] = await sql< + [ + { + id: number; + }, + ] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`; + const [[device], [channel]] = await Promise.all([ + // Create device + sql< + [NotificationDevicesRow] + >`INSERT INTO notification_devices (account_id, device_type, device_uuid, cloud_messaging_token) VALUES (${account.id}, ${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, + // Get all notification channels + sql< + Array + >`SELECT * FROM notification_channels`, + ]); + // Create subscription + return sql< + [NotificationSubscriptionsRow] + >`INSERT INTO notification_subscriptions (account_id, device_id, chain_id, safe_address, notification_channel_id) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress}, ${channel.id}) RETURNING *`; + }, + }); + + // Create duplicate subscription + await expect( + sql`INSERT INTO notification_subscriptions (account_id, device_id, chain_id, safe_address, notification_channel_id) VALUES (${afterMigration.after[0].account_id}, ${afterMigration.after[0].device_id}, ${chainId}, ${safeAddress}, ${afterMigration.after[0].notification_channel_id})`, + ).rejects.toThrow( + 'duplicate key value violates unique constraint "notification_subscriptions_account_id_chain_id_safe_address_key"', + ); + }); + + it('should upsert the updated_at timestamp in notification_subscriptions', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const safeAddress = getAddress(faker.finance.ethereumAddress()); + const chainId = faker.string.numeric(); + const deviceType = faker.helpers.arrayElement(Object.values(DeviceType)); + const deviceUuid = faker.string.uuid() as Uuid; + const cloudMessagingToken = faker.string.alphanumeric(); + const afterMigration = await migrator.test({ + migration: '00005_notifications', + after: async (sql: postgres.Sql) => { + // Create account + const [account] = await sql< + [ + { + id: number; + }, + ] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`; + const [[device], [channel]] = await Promise.all([ + // Create device + sql< + [NotificationDevicesRow] + >`INSERT INTO notification_devices (account_id, device_type, device_uuid, cloud_messaging_token) VALUES (${account.id}, ${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, + // Get all notification channels + sql< + Array + >`SELECT * FROM notification_channels`, + ]); + // Create subscription + return sql< + [NotificationSubscriptionsRow] + >`INSERT INTO notification_subscriptions (account_id, device_id, chain_id, safe_address, notification_channel_id) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress}, ${channel.id}) RETURNING *`; + }, + }); + + expect(afterMigration.after).toStrictEqual([ { id: 1, - notification_subscription_id: 1, + account_id: 1, + device_id: 1, + chain_id: chainId, + safe_address: safeAddress, notification_channel_id: 1, - device_uuid: expect.any(String), - device_type: 'WEB', - cloud_messaging_token: '69420', created_at: expect.any(Date), updated_at: expect.any(Date), }, ]); + const newSafeAddress = getAddress(faker.finance.ethereumAddress()); + // Update subscription with new safe_address const afterUpdate = await sql< - Array - >`UPDATE notification_channel_configurations - SET cloud_messaging_token = '1337' - WHERE id = 1 RETURNING *`; + [NotificationDevicesRow] + >`UPDATE notification_subscriptions SET safe_address = ${newSafeAddress} WHERE id = ${afterMigration.after[0].id} RETURNING *`; expect(afterUpdate).toStrictEqual([ { - id: 1, - notification_subscription_id: 1, - notification_channel_id: 1, - device_uuid: expect.any(String), - device_type: 'WEB', - cloud_messaging_token: '1337', + id: afterMigration.after[0].id, + account_id: afterMigration.after[0].account_id, + device_id: afterMigration.after[0].device_id, + chain_id: afterMigration.after[0].chain_id, + safe_address: newSafeAddress, + notification_channel_id: + afterMigration.after[0].notification_channel_id, // created_at should have remained the same - created_at: afterInsert.after[0].created_at, + created_at: afterMigration.after[0].created_at, updated_at: expect.any(Date), }, ]); // updated_at should have updated - expect(afterInsert.after[0].updated_at).not.toEqual( + expect(afterMigration.after[0].updated_at).not.toEqual( afterUpdate[0].updated_at, ); }); - it('should only allow ANDROID, IOS and WEB as device_type in notification_channel_configurations', async () => { - await migrator.test({ + it('should delete the subscribed notification type(s) if the subscription is deleted', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const safeAddress = getAddress(faker.finance.ethereumAddress()); + const chainId = faker.string.numeric(); + const deviceType = faker.helpers.arrayElement(Object.values(DeviceType)); + const deviceUuid = faker.string.uuid() as Uuid; + const cloudMessagingToken = faker.string.alphanumeric(); + const afterMigration = await migrator.test({ migration: '00005_notifications', after: async (sql: postgres.Sql) => { - await sql.begin(async (transaction) => { - // Create account - await transaction`INSERT INTO accounts (address) - VALUES ('0x69');`; - // Add notification subscription to account - await transaction< - [Pick] - >`INSERT INTO notification_subscriptions (account_id, chain_id, safe_address) - VALUES (1, 1, '0x420')`; - }); - - return await sql< - Array - >`SELECT * FROM notification_channel_configurations`; + // Create account + const [account] = await sql< + [ + { + id: number; + }, + ] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`; + const [[device], [channel], [notificationType]] = await Promise.all([ + // Create device + sql< + [NotificationDevicesRow] + >`INSERT INTO notification_devices (account_id, device_type, device_uuid, cloud_messaging_token) VALUES (${account.id}, ${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, + // Get all notification channels + sql< + Array + >`SELECT * FROM notification_channels`, + // Get all notification types + sql>`SELECT * FROM notification_types`, + ]); + // Create subscription + const [subscription] = await sql< + [NotificationSubscriptionsRow] + >`INSERT INTO notification_subscriptions (account_id, device_id, chain_id, safe_address, notification_channel_id) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress}, ${channel.id}) RETURNING *`; + // Subscribe to notification type + const [subscribedNotificationType] = await sql< + [NotificationSubscriptionNotificationTypesRow] + >`INSERT INTO notification_subscription_notification_types (notification_subscription_id, notification_type_id) VALUES (${subscription.id}, ${notificationType.id}) RETURNING *`; + return { subscription, subscribedNotificationType }; }, }); + // Delete subscription + await sql`DELETE FROM notification_subscriptions WHERE id = ${afterMigration.after.subscription.id}`; - const deviceTypes = ['ANDROID', 'IOS', 'WEB']; - const invalidDeviceType = faker.string.alpha().toUpperCase(); - + // Assert that the subscribed notification type was deleted await expect( - Promise.all( - deviceTypes.map((deviceType) => { - return sql`INSERT INTO notification_channel_configurations (notification_subscription_id, notification_channel_id, cloud_messaging_token, device_uuid, device_type) - VALUES (1, 1, '69420', ${crypto.randomUUID()}, ${deviceType})`; - }), - ), - ).resolves.not.toThrow(); - await expect(sql`INSERT INTO notification_channel_configurations (notification_subscription_id, notification_channel_id, cloud_messaging_token, device_uuid, device_type) - VALUES (1, 1, '69420', ${crypto.randomUUID()}, ${invalidDeviceType})`).rejects.toThrow( - 'new row for relation "notification_channel_configurations" violates check constraint "notification_channel_configurations_device_type_check"', - ); + sql`SELECT * FROM notification_subscription_notification_types WHERE id = ${afterMigration.after.subscribedNotificationType.id}`, + ).resolves.toStrictEqual([]); }); - it('should prevent duplicate subscriptions in notification_subscriptions', async () => { - await migrator.test({ + it('should delete the subscribed notification type if the notification type is deleted', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const safeAddress = getAddress(faker.finance.ethereumAddress()); + const chainId = faker.string.numeric(); + const deviceType = faker.helpers.arrayElement(Object.values(DeviceType)); + const deviceUuid = faker.string.uuid() as Uuid; + const cloudMessagingToken = faker.string.alphanumeric(); + const afterMigration = await migrator.test({ migration: '00005_notifications', - after: async (sql) => { - await sql.begin(async (transaction) => { - // Create account - await transaction`INSERT INTO accounts (address) - VALUES ('0x69');`; - // Add notification subscription to account - await transaction`INSERT INTO notification_subscriptions (account_id, chain_id, safe_address) - VALUES (1, 1,'0x420')`; - }); - }, - }); - - // Try to add the same subscription again - await expect(sql< - Array - >`INSERT INTO notification_subscriptions (account_id, chain_id, safe_address) - VALUES (1, 1, '0x420')`).rejects.toThrow( - 'duplicate key value violates unique constraint "notification_subscriptions_account_id_chain_id_safe_address_key"', - ); - }); - - it('should delete the subscription and configuration if the account is deleted', async () => { - const result = await migrator.test({ - migration: '00005_notifications', - after: async (sql) => { - await sql.begin(async (transaction) => { - // Create account - await transaction`INSERT INTO accounts (address) - VALUES ('0x69');`; - - // Add subscriptions to chains 1, 2, 3 - const chainIds = ['1', '2', '3']; - - await Promise.all( - chainIds.map((chainId) => { - return transaction`INSERT INTO notification_subscriptions (account_id, chain_id, safe_address) - VALUES (1, ${chainId}, '0x420')`; - }), - ); - }); - - // Delete account - await sql`DELETE FROM accounts WHERE id = 1`; - - return { - notification_types: await sql< - Array - >`SELECT * FROM notification_types`, - notification_subscriptions: await sql< - Array - >`SELECT * FROM notification_subscriptions`, - notification_subscription_notification_types: await sql< - Array - >`SELECT * FROM notification_subscription_notification_types`, - notification_channels: await sql< + after: async (sql: postgres.Sql) => { + // Create account + const [account] = await sql< + [ + { + id: number; + }, + ] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`; + const [[device], [channel], [notificationType]] = await Promise.all([ + // Create device + sql< + [NotificationDevicesRow] + >`INSERT INTO notification_devices (account_id, device_type, device_uuid, cloud_messaging_token) VALUES (${account.id}, ${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, + // Get all notification channels + sql< Array >`SELECT * FROM notification_channels`, - notification_channel_configurations: await sql< - Array - >`SELECT * FROM notification_channel_configurations`, - }; + // Get all notification types + sql>`SELECT * FROM notification_types`, + ]); + // Create subscription + const [subscription] = await sql< + [NotificationSubscriptionsRow] + >`INSERT INTO notification_subscriptions (account_id, device_id, chain_id, safe_address, notification_channel_id) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress}, ${channel.id}) RETURNING *`; + // Subscribe to notification type + const [subscribedNotificationType] = await sql< + [NotificationSubscriptionNotificationTypesRow] + >`INSERT INTO notification_subscription_notification_types (notification_subscription_id, notification_type_id) VALUES (${subscription.id}, ${notificationType.id}) RETURNING *`; + return { notificationType, subscribedNotificationType }; }, }); + // Delete subscription + await sql`DELETE FROM notification_types WHERE id = ${afterMigration.after.notificationType.id}`; - expect(result.after).toStrictEqual({ - notification_types: [ - { - id: expect.any(Number), - name: 'CONFIRMATION_REQUEST', - }, - { - id: expect.any(Number), - name: 'DELETED_MULTISIG_TRANSACTION', - }, - { - id: expect.any(Number), - name: 'EXECUTED_MULTISIG_TRANSACTION', - }, - { - id: expect.any(Number), - name: 'INCOMING_ETHER', - }, - { - id: expect.any(Number), - name: 'INCOMING_TOKEN', - }, - { - id: expect.any(Number), - name: 'MESSAGE_CONFIRMATION_REQUEST', - }, - { - id: expect.any(Number), - name: 'MODULE_TRANSACTION', - }, - ], - // No subscriptions should exist - notification_subscriptions: [], - notification_subscription_notification_types: [], - notification_channels: [ - { - id: 1, - name: 'PUSH_NOTIFICATIONS', - }, - ], - notification_channel_configurations: [], - }); + // Assert that the subscribed notification type was deleted + await expect( + sql`SELECT * FROM notification_subscription_notification_types WHERE id = ${afterMigration.after.subscribedNotificationType.id}`, + ).resolves.toStrictEqual([]); }); - it('should delete the notification_channel_configuration if the notification_channel is deleted', async () => { - const result = await migrator.test({ + it('should prevent duplicate notification types (subscription, notification type) in notification_subscription_notification_types', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const safeAddress = getAddress(faker.finance.ethereumAddress()); + const chainId = faker.string.numeric(); + const deviceType = faker.helpers.arrayElement(Object.values(DeviceType)); + const deviceUuid = faker.string.uuid() as Uuid; + const cloudMessagingToken = faker.string.alphanumeric(); + const afterMigration = await migrator.test({ migration: '00005_notifications', - after: async (sql) => { - await sql.begin(async (transaction) => { - // Create account - await transaction`INSERT INTO accounts (address) - VALUES ('0x69');`; - - // Add subscriptions to chains 1, 2, 3 - const chainIds = ['1', '2', '3']; - - await Promise.all( - chainIds.map((chainId) => { - return transaction`INSERT INTO notification_subscriptions (account_id, chain_id, safe_address) - VALUES (1, ${chainId}, '0x420')`; - }), - ); - - // Enable notification channel - await transaction`INSERT INTO notification_channel_configurations (notification_subscription_id, notification_channel_id, cloud_messaging_token, device_uuid, device_type) - VALUES (1, 1, '69420', ${crypto.randomUUID()}, 'WEB')`; - }); - - const [channel] = await sql< - [Pick] - >`SELECT id FROM notification_channels WHERE name = 'PUSH_NOTIFICATIONS'`; - // Delete PUSH_NOTIFICATIONS notification channel - await sql`DELETE FROM notification_channels WHERE id = ${channel.id}`; - - return { - notification_types: await sql< - Array - >`SELECT * FROM notification_types`, - notification_subscriptions: await sql< - Array - >`SELECT * FROM notification_subscriptions`, - notification_subscription_notification_types: await sql< - Array - >`SELECT * FROM notification_subscription_notification_types`, - notification_channels: await sql< + after: async (sql: postgres.Sql) => { + // Create account + const [account] = await sql< + [ + { + id: number; + }, + ] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`; + const [[device], [channel], [notificationType]] = await Promise.all([ + // Create device + sql< + [NotificationDevicesRow] + >`INSERT INTO notification_devices (account_id, device_type, device_uuid, cloud_messaging_token) VALUES (${account.id}, ${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, + // Get all notification channels + sql< Array >`SELECT * FROM notification_channels`, - notification_channel_configurations: await sql< - Array - >`SELECT * FROM notification_channel_configurations`, - }; + // Get all notification types + sql>`SELECT * FROM notification_types`, + ]); + // Create subscription + const [subscription] = await sql< + [NotificationSubscriptionsRow] + >`INSERT INTO notification_subscriptions (account_id, device_id, chain_id, safe_address, notification_channel_id) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress}, ${channel.id}) RETURNING *`; + // Subscribe to notification type + return sql< + [NotificationSubscriptionNotificationTypesRow] + >`INSERT INTO notification_subscription_notification_types (notification_subscription_id, notification_type_id) VALUES (${subscription.id}, ${notificationType.id}) RETURNING *`; }, }); - expect(result.after).toStrictEqual({ - notification_types: [ - { - id: expect.any(Number), - name: 'CONFIRMATION_REQUEST', - }, - { - id: expect.any(Number), - name: 'DELETED_MULTISIG_TRANSACTION', - }, - { - id: expect.any(Number), - name: 'EXECUTED_MULTISIG_TRANSACTION', - }, - { - id: expect.any(Number), - name: 'INCOMING_ETHER', - }, - { - id: expect.any(Number), - name: 'INCOMING_TOKEN', - }, - { - id: expect.any(Number), - name: 'MESSAGE_CONFIRMATION_REQUEST', - }, - { - id: expect.any(Number), - name: 'MODULE_TRANSACTION', - }, - ], - notification_subscriptions: [ - { - id: 1, - chain_id: '1', - account_id: 1, - safe_address: '0x420', - created_at: expect.any(Date), - updated_at: expect.any(Date), - }, - { - id: 2, - chain_id: '2', - account_id: 1, - safe_address: '0x420', - created_at: expect.any(Date), - updated_at: expect.any(Date), - }, - { - id: 3, - chain_id: '3', - account_id: 1, - safe_address: '0x420', - created_at: expect.any(Date), - updated_at: expect.any(Date), - }, - ], - notification_subscription_notification_types: [], - notification_channels: [], - // No configurations should exist - notification_channel_configurations: [], - }); + // Create duplicate subscription + await expect( + sql`INSERT INTO notification_subscription_notification_types (notification_subscription_id, notification_type_id) VALUES (${afterMigration.after[0].notification_subscription_id}, ${afterMigration.after[0].notification_type_id})`, + ).rejects.toThrow( + 'duplicate key value violates unique constraint "notification_subscription_not_notification_subscription_id__key"', + ); }); }); diff --git a/src/datasources/accounts/notifications/__tests__/upsert-subscriptions.dto.entity.builder.ts b/src/datasources/accounts/notifications/__tests__/upsert-subscriptions.dto.entity.builder.ts index c9c2cc2810..4be052cd09 100644 --- a/src/datasources/accounts/notifications/__tests__/upsert-subscriptions.dto.entity.builder.ts +++ b/src/datasources/accounts/notifications/__tests__/upsert-subscriptions.dto.entity.builder.ts @@ -10,7 +10,7 @@ import { NotificationType } from '@/domain/notifications/entities-v2/notificatio export function upsertSubscriptionsDtoBuilder(): IBuilder { return new Builder() .with('account', getAddress(faker.finance.ethereumAddress())) - .with('cloudMessagingToken', faker.string.alphanumeric()) + .with('cloudMessagingToken', faker.string.alphanumeric({ length: 10 })) .with('deviceType', faker.helpers.arrayElement(Object.values(DeviceType))) .with('deviceUuid', faker.string.uuid() as Uuid) .with( diff --git a/src/datasources/accounts/notifications/notifications.datasource.spec.ts b/src/datasources/accounts/notifications/notifications.datasource.spec.ts index 8636b5b545..cc9e1e8270 100644 --- a/src/datasources/accounts/notifications/notifications.datasource.spec.ts +++ b/src/datasources/accounts/notifications/notifications.datasource.spec.ts @@ -10,7 +10,6 @@ import { NotificationType } from '@/domain/notifications/entities-v2/notificatio import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; import { ILoggingService } from '@/logging/logging.interface'; import { faker } from '@faker-js/faker'; -import { isEqual } from 'lodash'; import postgres from 'postgres'; import { getAddress } from 'viem'; @@ -54,8 +53,8 @@ describe('NotificationsDatasource', () => { }); afterEach(async () => { - // Don't truncate notification_types or notification_channels as they have predefined rows - await sql`TRUNCATE TABLE accounts, notification_subscriptions, notification_subscription_notification_types, notification_channel_configurations RESTART IDENTITY CASCADE`; + // Don't truncate notification_channels or notification_types as they have predefined rows + await sql`TRUNCATE TABLE accounts, notification_devices, notification_subscriptions, notification_subscription_notification_types RESTART IDENTITY CASCADE`; }); afterAll(async () => { @@ -63,7 +62,7 @@ describe('NotificationsDatasource', () => { }); describe('upsertSubscriptions', () => { - it('should insert subscriptions', async () => { + it('should insert a subscription', async () => { const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() .with('deviceUuid', undefined) .build(); @@ -76,19 +75,19 @@ describe('NotificationsDatasource', () => { // Ensure correct database structure await Promise.all([ sql`SELECT * FROM accounts`, + sql`SELECT * FROM notification_devices`, + sql`SELECT * FROM notification_channels`, + sql`SELECT * FROM notification_types`, sql`SELECT * FROM notification_subscriptions`, sql`SELECT * FROM notification_subscription_notification_types`, - sql`SELECT * from notification_types`, - sql`SELECT * FROM notification_channels`, - sql`SELECT * FROM notification_channel_configurations`, ]).then( ([ accounts, - subscriptions, - subscribedNotificationTypes, - notificationTypes, + devices, channels, - channelsConfigs, + types, + subscriptions, + subscribedNotifications, ]) => { expect(accounts).toStrictEqual([ { @@ -99,30 +98,24 @@ describe('NotificationsDatasource', () => { updated_at: expect.any(Date), }, ]); - expect(subscriptions).toStrictEqual( - upsertSubscriptionsDto.safes.map((subscription, i) => { - return { - id: i + 1, - account_id: 1, - chain_id: subscription.chainId, - safe_address: subscription.address, - created_at: expect.any(Date), - updated_at: expect.any(Date), - }; - }), - ); - expect(subscribedNotificationTypes).toStrictEqual( - upsertSubscriptionsDto.safes.flatMap((subscription, i) => { - return subscription.notificationTypes.map(() => { - return { - id: expect.any(Number), - subscription_id: i + 1, - notification_type_id: expect.any(Number), - }; - }); - }), - ); - expect(notificationTypes).toStrictEqual( + expect(devices).toStrictEqual([ + { + id: 1, + account_id: accounts[0].id, + device_type: upsertSubscriptionsDto.deviceType, + device_uuid: actual.deviceUuid, + cloud_messaging_token: upsertSubscriptionsDto.cloudMessagingToken, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }, + ]); + expect(channels).toStrictEqual([ + { + id: 1, + name: NotificationChannel.PUSH_NOTIFICATIONS, + }, + ]); + expect(types).toStrictEqual( Object.values(NotificationType).map((type) => { return { id: expect.any(Number), @@ -130,234 +123,143 @@ describe('NotificationsDatasource', () => { }; }), ); - expect(channels).toStrictEqual([ - { - id: 1, - name: NotificationChannel.PUSH_NOTIFICATIONS, - }, - ]); - expect(channelsConfigs).toStrictEqual( - upsertSubscriptionsDto.safes.map((_, i) => { + expect(subscriptions).toStrictEqual( + upsertSubscriptionsDto.safes.map((safe, i) => { return { id: i + 1, - notification_subscription_id: i + 1, + account_id: accounts[0].id, + device_id: devices[0].id, + chain_id: safe.chainId, + safe_address: safe.address, notification_channel_id: 1, - cloud_messaging_token: - upsertSubscriptionsDto.cloudMessagingToken, - device_type: upsertSubscriptionsDto.deviceType, - device_uuid: actual.deviceUuid, created_at: expect.any(Date), updated_at: expect.any(Date), }; }), ); + expect(subscribedNotifications).toStrictEqual( + expect.arrayContaining( + upsertSubscriptionsDto.safes.flatMap((safe, i) => { + return safe.notificationTypes.map((type) => { + return { + id: expect.any(Number), + notification_subscription_id: i + 1, + notification_type_id: types.find((t) => t.name === type) + ?.id, + }; + }); + }), + ), + ); }, ); }); - it('should update subscriptions with new preferences', async () => { - const deviceUuid = faker.string.uuid() as Uuid; - const chainId = faker.string.numeric(); - const safeAddress1 = getAddress(faker.finance.ethereumAddress()); - const safeAddress2 = getAddress(faker.finance.ethereumAddress()); - const notificationTypes1 = faker.helpers.arrayElements( - Object.values(NotificationType), - ); - const notificationTypes2 = faker.helpers.arrayElements( - Object.values(NotificationType), - ); - const notificationTypes2Upserted = faker.helpers.arrayElements( - Object.values(NotificationType), - ); - const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() - .with('safes', [ - { - address: safeAddress1, - chainId, - notificationTypes: notificationTypes1, - }, - { - address: safeAddress2, - chainId, - notificationTypes: notificationTypes2, - }, - ]) - .with('deviceUuid', deviceUuid) - .build(); + it('should always update the cloud messaging token', async () => { + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); + const newCloudMessagingToken = + upsertSubscriptionsDtoBuilder().build().cloudMessagingToken; await accountsDatasource.createAccount(upsertSubscriptionsDto.account); await target.upsertSubscriptions(upsertSubscriptionsDto); - const safeSubscription = await target.getSafeSubscription({ - account: upsertSubscriptionsDto.account, - chainId: upsertSubscriptionsDto.safes[0].chainId, - safeAddress: upsertSubscriptionsDto.safes[0].address, - deviceUuid: deviceUuid, - }); - expect(safeSubscription).toEqual( - Object.values(NotificationType).reduce< - Record - >( - (acc, type) => { - acc[type] = - upsertSubscriptionsDto.safes[0].notificationTypes.includes(type); - return acc; - }, - {} as Record, - ), - ); + // Insert should not throw despite it being the same device UUID + await expect( + target.upsertSubscriptions({ + ...upsertSubscriptionsDto, + cloudMessagingToken: newCloudMessagingToken, + }), + ).resolves.not.toThrow(); + // Device UUID should have updated + await expect( + sql`SELECT * FROM notification_devices`, + ).resolves.toStrictEqual([ + { + id: 1, + account_id: 1, + device_type: upsertSubscriptionsDto.deviceType, + device_uuid: expect.any(String), + cloud_messaging_token: newCloudMessagingToken, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }, + ]); + }); - const upsertedUpsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() - .with('account', upsertSubscriptionsDto.account) - .with('cloudMessagingToken', upsertSubscriptionsDto.cloudMessagingToken) - .with('deviceType', upsertSubscriptionsDto.deviceType) - .with('deviceUuid', upsertSubscriptionsDto.deviceUuid) + it('should update a subscription, setting only the newly subscribed notification types', async () => { + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() .with('safes', [ { - address: safeAddress2, - chainId, - notificationTypes: notificationTypes2Upserted, + chainId: faker.string.numeric(), + address: getAddress(faker.finance.ethereumAddress()), + notificationTypes: faker.helpers.arrayElements( + Object.values(NotificationType), + ), }, ]) .build(); - await target.upsertSubscriptions(upsertedUpsertSubscriptionsDto); - const upsertedSafeSubscription = await target.getSafeSubscription({ - account: upsertedUpsertSubscriptionsDto.account, - chainId: upsertedUpsertSubscriptionsDto.safes[0].chainId, - safeAddress: upsertedUpsertSubscriptionsDto.safes[0].address, - deviceUuid: deviceUuid, - }); - - expect(upsertedSafeSubscription).toEqual( - Object.values(NotificationType).reduce< - Record - >( - (acc, type) => { - acc[type] = - upsertedUpsertSubscriptionsDto.safes[0].notificationTypes.includes( - type, - ); - return acc; - }, - {} as Record, - ), - ); - - expect( - isEqual( - Object.values(safeSubscription).sort(), - Object.values(upsertedSafeSubscription).sort(), - ), - ).toBe(false); - }); - }); - - describe('getSafeSubscription', () => { - it('should get the Safe subscription', async () => { - const deviceUuid = faker.string.uuid() as Uuid; - const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() - .with('deviceUuid', deviceUuid) - .build(); - await accountsDatasource.createAccount(upsertSubscriptionsDto.account); - await target.upsertSubscriptions(upsertSubscriptionsDto); - - await Promise.all( - upsertSubscriptionsDto.safes.map(async (safe) => { - const actual = await target.getSafeSubscription({ - account: upsertSubscriptionsDto.account, - chainId: safe.chainId, - safeAddress: safe.address, - deviceUuid, - }); - - expect(actual).toEqual( - Object.values(NotificationType).reduce< - Record - >( - (acc, type) => { - acc[type] = safe.notificationTypes.includes(type); - return acc; - }, - {} as Record, - ), - ); - }), + const newNotificationTypes = faker.helpers.arrayElements( + Object.values(NotificationType), ); - }); - }); - - describe('getSubscribersWithTokensBySafe', () => { - it('should get the subscribers with cloud messaging token for a Safe', async () => { - const deviceUuid = faker.string.uuid() as Uuid; - const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() - .with('deviceUuid', deviceUuid) - .build(); await accountsDatasource.createAccount(upsertSubscriptionsDto.account); await target.upsertSubscriptions(upsertSubscriptionsDto); + await target.upsertSubscriptions({ + ...upsertSubscriptionsDto, + safes: [ + { + ...upsertSubscriptionsDto.safes[0], + notificationTypes: newNotificationTypes, + }, + ], + }); - await Promise.all( - upsertSubscriptionsDto.safes.map((safe) => { - return expect( - target.getSubscribersWithTokensBySafe({ - chainId: safe.chainId, - safeAddress: safe.address, + await Promise.all([ + sql`SELECT * FROM notification_types`, + sql`SELECT * FROM notification_subscription_notification_types`, + ]).then(([notificationTypes, subscribedNotifications]) => { + // Only new notification types should be subscribed to + expect(subscribedNotifications).toStrictEqual( + expect.arrayContaining( + newNotificationTypes.map((type) => { + return { + id: expect.any(Number), + notification_subscription_id: 1, + notification_type_id: notificationTypes.find( + (t) => t.name === type, + )?.id, + }; }), - ).resolves.toEqual([ - { - subscriber: upsertSubscriptionsDto.account, - cloudMessagingToken: upsertSubscriptionsDto.cloudMessagingToken, - }, - ]); - }), - ); + ), + ); + }); }); - }); - describe('deleteSubscription', () => { - it('should delete a subscription', async () => { - const deviceUuid = faker.string.uuid() as Uuid; - const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() - .with('deviceUuid', deviceUuid) - .build(); + it('should allow multiple subscriptions, varying by device UUID', async () => { + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); + const secondDeviceUuid = faker.string.uuid() as Uuid; + const secondUpsertSubscriptionsDto = { + ...upsertSubscriptionsDto, + deviceUuid: secondDeviceUuid, + }; await accountsDatasource.createAccount(upsertSubscriptionsDto.account); await target.upsertSubscriptions(upsertSubscriptionsDto); - - await target.deleteSubscription({ - account: upsertSubscriptionsDto.account, - chainId: upsertSubscriptionsDto.safes[0].chainId, - safeAddress: upsertSubscriptionsDto.safes[0].address, - }); - - await expect( - target.getSafeSubscription({ - account: upsertSubscriptionsDto.account, - chainId: upsertSubscriptionsDto.safes[0].chainId, - safeAddress: upsertSubscriptionsDto.safes[0].address, - deviceUuid, - }), - ).rejects.toThrow('Error getting account subscription'); - - const remainingSubscriptions = upsertSubscriptionsDto.safes.filter( - (safe) => { - return safe.address !== upsertSubscriptionsDto.safes[0].address; - }, - ); + await target.upsertSubscriptions(secondUpsertSubscriptionsDto); // Ensure correct database structure await Promise.all([ sql`SELECT * FROM accounts`, + sql`SELECT * FROM notification_devices`, + sql`SELECT * FROM notification_channels`, + sql`SELECT * FROM notification_types`, sql`SELECT * FROM notification_subscriptions`, sql`SELECT * FROM notification_subscription_notification_types`, - sql`SELECT * from notification_types`, - sql`SELECT * FROM notification_channels`, - sql`SELECT * FROM notification_channel_configurations`, ]).then( ([ accounts, - subscriptions, - subscribedNotificationTypes, - notificationTypes, + devices, channels, - channelsConfigs, + types, + subscriptions, + subscribedNotifications, ]) => { expect(accounts).toStrictEqual([ { @@ -368,131 +270,232 @@ describe('NotificationsDatasource', () => { updated_at: expect.any(Date), }, ]); - expect(subscriptions).toStrictEqual( - remainingSubscriptions.map((subscription) => { - return { - id: expect.any(Number), - account_id: 1, - chain_id: subscription.chainId, - safe_address: subscription.address, - created_at: expect.any(Date), - updated_at: expect.any(Date), - }; - }), - ); - expect(subscribedNotificationTypes).toStrictEqual( - remainingSubscriptions.flatMap((subscription) => { - return subscription.notificationTypes.map(() => { - return { - id: expect.any(Number), - subscription_id: expect.any(Number), - notification_type_id: expect.any(Number), - }; - }); - }), - ); - expect(notificationTypes).toStrictEqual( - Object.values(NotificationType).map((type) => { - return { - id: expect.any(Number), - name: type, - }; - }), - ); + expect(devices).toStrictEqual([ + { + id: 1, + account_id: accounts[0].id, + device_type: upsertSubscriptionsDto.deviceType, + device_uuid: upsertSubscriptionsDto.deviceUuid, + cloud_messaging_token: upsertSubscriptionsDto.cloudMessagingToken, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }, + { + id: 2, + account_id: accounts[0].id, + device_type: upsertSubscriptionsDto.deviceType, + device_uuid: secondDeviceUuid, + cloud_messaging_token: upsertSubscriptionsDto.cloudMessagingToken, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }, + ]); expect(channels).toStrictEqual([ { id: 1, name: NotificationChannel.PUSH_NOTIFICATIONS, }, ]); - expect(channelsConfigs).toStrictEqual( - remainingSubscriptions.map(() => { + expect(types).toStrictEqual( + Object.values(NotificationType).map((type) => { return { id: expect.any(Number), - notification_subscription_id: expect.any(Number), - notification_channel_id: 1, - cloud_messaging_token: - upsertSubscriptionsDto.cloudMessagingToken, - device_type: upsertSubscriptionsDto.deviceType, - device_uuid: deviceUuid, - created_at: expect.any(Date), - updated_at: expect.any(Date), + name: type, }; }), ); + expect(subscriptions).toStrictEqual( + upsertSubscriptionsDto.safes + .map((safe, i) => { + return { + id: i + 1, + account_id: accounts[0].id, + device_id: devices[0].id, + chain_id: safe.chainId, + safe_address: safe.address, + notification_channel_id: 1, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }; + }) + .concat( + secondUpsertSubscriptionsDto.safes.map((safe, i) => { + return { + id: upsertSubscriptionsDto.safes.length + i + 1, + account_id: accounts[0].id, + device_id: devices[1].id, + chain_id: safe.chainId, + safe_address: safe.address, + notification_channel_id: 1, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }; + }), + ), + ); + expect(subscribedNotifications).toStrictEqual( + expect.arrayContaining( + upsertSubscriptionsDto.safes + .flatMap((safe, i) => { + return safe.notificationTypes.map((type) => { + return { + id: expect.any(Number), + notification_subscription_id: i + 1, + notification_type_id: types.find((t) => t.name === type) + ?.id, + }; + }); + }) + .concat( + secondUpsertSubscriptionsDto.safes.flatMap((safe, i) => { + return safe.notificationTypes.map((type) => { + return { + id: expect.any(Number), + notification_subscription_id: + upsertSubscriptionsDto.safes.length + i + 1, + notification_type_id: types.find((t) => t.name === type) + ?.id, + }; + }); + }), + ), + ), + ); }, ); }); }); - describe('deleteDevice', () => { - it('should delete a device', async () => { - const deviceUuid = faker.string.uuid() as Uuid; - const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() - .with('deviceUuid', deviceUuid) + describe('getSafeSubscription', () => { + it('should return a subscription for a Safe', async () => { + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); + const safe = upsertSubscriptionsDto.safes[0]; + await accountsDatasource.createAccount(upsertSubscriptionsDto.account); + await target.upsertSubscriptions(upsertSubscriptionsDto); + + await expect( + target.getSafeSubscription({ + account: upsertSubscriptionsDto.account, + deviceUuid: upsertSubscriptionsDto.deviceUuid!, + chainId: safe.chainId, + safeAddress: safe.address, + }), + ).resolves.toStrictEqual(expect.arrayContaining(safe.notificationTypes)); + }); + }); + + describe('getSubscribersWithTokensBySafe', () => { + it('should return a list of subscribers with tokens for a Safe', async () => { + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); + const secondUpsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() + .with('safes', upsertSubscriptionsDto.safes) .build(); + const safe = upsertSubscriptionsDto.safes[0]; await accountsDatasource.createAccount(upsertSubscriptionsDto.account); + await accountsDatasource.createAccount( + secondUpsertSubscriptionsDto.account, + ); await target.upsertSubscriptions(upsertSubscriptionsDto); - await target.deleteDevice(deviceUuid); + await target.upsertSubscriptions(secondUpsertSubscriptionsDto); - await Promise.all( - upsertSubscriptionsDto.safes.map((safe) => { - return expect( - target.getSafeSubscription({ - account: upsertSubscriptionsDto.account, - chainId: safe.chainId, - safeAddress: safe.address, - deviceUuid, - }), - ).rejects.toThrow('Error getting account subscription'); + await expect( + target.getSubscribersWithTokensBySafe({ + chainId: safe.chainId, + safeAddress: safe.address, }), + ).resolves.toStrictEqual([ + { + subscriber: upsertSubscriptionsDto.account, + cloudMessagingToken: upsertSubscriptionsDto.cloudMessagingToken, + }, + { + subscriber: secondUpsertSubscriptionsDto.account, + cloudMessagingToken: secondUpsertSubscriptionsDto.cloudMessagingToken, + }, + ]); + }); + }); + + describe('deleteSubscription', () => { + it('should delete a subscription', async () => { + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); + const safe = upsertSubscriptionsDto.safes[0]; + const account = await accountsDatasource.createAccount( + upsertSubscriptionsDto.account, ); + await target.upsertSubscriptions(upsertSubscriptionsDto); - // Ensure correct database structure - await Promise.all([ - sql`SELECT * FROM accounts`, + await target.deleteSubscription({ + account: upsertSubscriptionsDto.account, + deviceUuid: upsertSubscriptionsDto.deviceUuid!, + chainId: safe.chainId, + safeAddress: safe.address, + }); + + await expect( + sql`SELECT * FROM notification_subscriptions WHERE account_id = ${account.id} AND chain_id = ${safe.chainId} AND safe_address = ${safe.address}`, + ).resolves.toStrictEqual([]); + }); + + it('should not delete subscriptions of other device UUIDs', async () => { + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() + .with('safes', [ + { + chainId: faker.string.numeric(), + address: getAddress(faker.finance.ethereumAddress()), + notificationTypes: faker.helpers.arrayElements( + Object.values(NotificationType), + ), + }, + ]) + .build(); + const secondDeviceUuid = faker.string.uuid() as Uuid; + const secondUpsertSubscriptionsDto = { + ...upsertSubscriptionsDto, + deviceUuid: secondDeviceUuid, + }; + const safe = upsertSubscriptionsDto.safes[0]; + await accountsDatasource.createAccount(upsertSubscriptionsDto.account); + await target.upsertSubscriptions(upsertSubscriptionsDto); + await target.upsertSubscriptions(secondUpsertSubscriptionsDto); + + await target.deleteSubscription({ + account: upsertSubscriptionsDto.account, + deviceUuid: upsertSubscriptionsDto.deviceUuid!, + chainId: safe.chainId, + safeAddress: safe.address, + }); + + // The second subscription should remain + await expect( sql`SELECT * FROM notification_subscriptions`, - sql`SELECT * FROM notification_subscription_notification_types`, - sql`SELECT * from notification_types`, - sql`SELECT * FROM notification_channels`, - sql`SELECT * FROM notification_channel_configurations`, - ]).then( - ([ - accounts, - subscriptions, - subscribedNotificationTypes, - notificationTypes, - channels, - channelsConfigs, - ]) => { - expect(accounts).toStrictEqual([ - { - id: 1, - group_id: null, - address: upsertSubscriptionsDto.account, - created_at: expect.any(Date), - updated_at: expect.any(Date), - }, - ]); - expect(subscriptions).toStrictEqual([]); - expect(subscribedNotificationTypes).toStrictEqual([]); - expect(notificationTypes).toStrictEqual( - Object.values(NotificationType).map((type) => { - return { - id: expect.any(Number), - name: type, - }; - }), - ); - expect(channels).toStrictEqual([ - { - id: 1, - name: NotificationChannel.PUSH_NOTIFICATIONS, - }, - ]); - expect(channelsConfigs).toStrictEqual([]); + ).resolves.toStrictEqual([ + { + id: 2, + account_id: 1, + device_id: 2, + chain_id: safe.chainId, + safe_address: safe.address, + notification_channel_id: 1, + created_at: expect.any(Date), + updated_at: expect.any(Date), }, - ); + ]); + }); + }); + + describe('deleteDevice', () => { + it('should delete all subscriptions of a device', async () => { + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); + await accountsDatasource.createAccount(upsertSubscriptionsDto.account); + await target.upsertSubscriptions(upsertSubscriptionsDto); + + await target.deleteDevice(upsertSubscriptionsDto.deviceUuid!); + + // All subscriptions of the device should be deleted + await expect( + sql`SELECT * FROM notification_subscriptions`, + ).resolves.toStrictEqual([]); }); }); }); diff --git a/src/datasources/accounts/notifications/notifications.datasource.ts b/src/datasources/accounts/notifications/notifications.datasource.ts index 50c68978da..1dbc27301e 100644 --- a/src/datasources/accounts/notifications/notifications.datasource.ts +++ b/src/datasources/accounts/notifications/notifications.datasource.ts @@ -13,14 +13,7 @@ import { import postgres from 'postgres'; import { NotificationChannel as DomainNotificationChannel } from '@/domain/notifications/entities-v2/notification-channel.entity'; import { UpsertSubscriptionsDto } from '@/datasources/accounts/notifications/entities/upsert-subscriptions.dto.entity'; -import { DeviceType } from '@/domain/notifications/entities-v2/device-type.entity'; -import { NotificationChannel } from '@/datasources/accounts/notifications/entities/notification-channel.entity'; -import { NotificationSubscription } from '@/datasources/accounts/notifications/entities/notification-subscription.entity'; -import { NotificationChannelConfig } from '@/datasources/accounts/notifications/entities/notification-channel-config.entity'; -import { NotificationType } from '@/datasources/accounts/notifications/entities/notification-type.entity'; -import { Account } from '@/domain/accounts/entities/account.entity'; -// TODO: Add caching @Injectable() export class NotificationsDatasource implements INotificationsDatasource { constructor( @@ -33,7 +26,7 @@ export class NotificationsDatasource implements INotificationsDatasource { ) {} /** - * Upserts subscriptions for the given account as per the list of Safes + * Upserts subscriptions for the given account/device as per the list of Safes * and notification types provided. * * @param args.account Account address @@ -51,42 +44,62 @@ export class NotificationsDatasource implements INotificationsDatasource { const deviceUuid = args.deviceUuid ?? crypto.randomUUID(); await this.sql.begin(async (sql) => { - const channel = await this.getChannel({ - sql, - name: DomainNotificationChannel.PUSH_NOTIFICATIONS, + // Get the push notifications channel + const [channel] = await sql<[{ id: number }]>` + SELECT id FROM notification_channels + WHERE name = ${DomainNotificationChannel.PUSH_NOTIFICATIONS} + `.catch((e) => { + const error = 'Error getting channel'; + this.loggingService.info(`${error}: ${asError(e).message}`); + throw new NotFoundException(error); }); + // Insert (or update the cloud messaging token of) a device + const [device] = await sql<[{ id: number }]>` + INSERT INTO notification_devices (account_id, device_type, device_uuid, cloud_messaging_token) + VALUES (${account.id}, ${args.deviceType}, ${deviceUuid}, ${args.cloudMessagingToken}) + ON CONFLICT (device_uuid) + DO UPDATE SET + cloud_messaging_token = EXCLUDED.cloud_messaging_token, + -- Throws if updated_at is not set + updated_at = NOW() + RETURNING id + `.catch((e) => { + const error = 'Error getting device'; + this.loggingService.info(`${error}: ${asError(e).message}`); + throw new UnprocessableEntityException(error); + }); + + // For each Safe, upsert the subscription and overwrite the subscribed-to notification types await Promise.all( args.safes.map(async (safe) => { - const subscription = await this.insertSubscription({ - sql, - accountId: account.id, - chainId: safe.chainId, - safeAddress: safe.address, - }); - - await this.insertChannelConfig({ - sql, - subscriptionId: subscription.id, - channelId: channel.id, - cloudMessagingToken: args.cloudMessagingToken, - deviceType: args.deviceType, - deviceUuid, - }); - - // Cleanup existing types as incoming safe.notificationTypes - // are only those to-be-enabled - await this.deleteSubscriptionTypes({ - sql, - subscriptionId: subscription.id, - }); - - // Set new notification type preferences - return this.insertSubscriptionTypes({ - sql, - subscriptionId: subscription.id, - notificationTypes: safe.notificationTypes, - }); + try { + // 1. Upsert subscription + const [subscription] = await sql<[{ id: number }]>` + INSERT INTO notification_subscriptions (account_id, chain_id, safe_address, device_id, notification_channel_id) + VALUES (${account.id}, ${safe.chainId}, ${safe.address}, ${device.id}, ${channel.id}) + ON CONFLICT (account_id, chain_id, safe_address, device_id, notification_channel_id) + -- A field must be set to return the id + DO UPDATE SET updated_at = NOW() + RETURNING id + `; + // 2. Remove existing subscribed-to notification types + await sql` + DELETE FROM notification_subscription_notification_types + WHERE notification_subscription_id = ${subscription.id} + `; + // 3. Insert new subscribed-to notification types + await sql` + INSERT INTO notification_subscription_notification_types (notification_subscription_id, notification_type_id) + SELECT ${subscription.id}, id + FROM notification_types + WHERE name = ANY(${safe.notificationTypes}) + `; + } catch (e) { + const error = 'Error upserting subscription'; + this.loggingService.info(`${error}: ${asError(e).message}`); + throw new NotFoundException(); + } }), ); }); @@ -94,233 +107,43 @@ export class NotificationsDatasource implements INotificationsDatasource { return { deviceUuid }; } - private async getChannel(args: { - sql: postgres.TransactionSql; - name: DomainNotificationChannel; - }): Promise { - const [channel] = await args.sql<[NotificationChannel]>` - SELECT id - FROM notification_channels - WHERE name = ${args.name} - `.catch((e) => { - this.loggingService.info(`Error getting channel: ${asError(e).message}`); - return []; - }); - - if (!channel) { - throw new NotFoundException('Error getting channel'); - } - - return channel; - } - - private async insertSubscription(args: { - sql: postgres.TransactionSql; - accountId: number; - chainId: string; - safeAddress: `0x${string}`; - }): Promise { - const [subscription] = await args.sql<[NotificationSubscription]>` - INSERT INTO notification_subscriptions (account_id, chain_id, safe_address) - VALUES (${args.accountId}, ${args.chainId}, ${args.safeAddress}) - ON CONFLICT (account_id, chain_id, safe_address) - DO UPDATE SET - -- Field must be set to return value - updated_at = NOW() - RETURNING *`.catch((e) => { - this.loggingService.info( - `Error inserting subscription: ${asError(e).message}`, - ); - return []; - }); - - if (!subscription) { - throw new UnprocessableEntityException('Error inserting subscription'); - } - - return subscription; - } - - private async insertChannelConfig(args: { - sql: postgres.TransactionSql; - subscriptionId: number; - channelId: number; - cloudMessagingToken: string; - deviceType: DeviceType; - deviceUuid: Uuid; - }): Promise { - const [config] = await args.sql<[NotificationChannelConfig]>` - INSERT INTO notification_channel_configurations ( - notification_subscription_id, - notification_channel_id, - cloud_messaging_token, - device_type, - device_uuid - ) VALUES ( - ${args.subscriptionId}, - ${args.channelId}, - ${args.cloudMessagingToken}, - ${args.deviceType}, - ${args.deviceUuid} - ) - ON CONFLICT (notification_subscription_id, notification_channel_id, device_uuid) - DO UPDATE SET - cloud_messaging_token = EXCLUDED.cloud_messaging_token - RETURNING * - `.catch((e) => { - this.loggingService.info( - `Error inserting channel configuration: ${asError(e).message}`, - ); - return []; - }); - - if (!config) { - throw new UnprocessableEntityException( - 'Error inserting channel configuration', - ); - } - - return config; - } - - private async deleteSubscriptionTypes(args: { - sql: postgres.TransactionSql; - subscriptionId: number; - }): Promise { - await args.sql` - DELETE FROM notification_subscription_notification_types - WHERE subscription_id = ${args.subscriptionId} - `.catch((e) => { - this.loggingService.info( - `Error deleting subscription notification types: ${asError(e).message}`, - ); - throw new UnprocessableEntityException( - 'Error deleting subscription notification types', - ); - }); - } - - private async insertSubscriptionTypes(args: { - sql: postgres.TransactionSql; - subscriptionId: number; - notificationTypes: Array; - }): Promise { - await args.sql` - INSERT INTO notification_subscription_notification_types (subscription_id, notification_type_id) - SELECT ${args.subscriptionId}, nt.id - FROM UNNEST(${args.notificationTypes}::text[]) AS nt_name - JOIN notification_types nt ON nt.name = nt_name - `.catch((e) => { - this.loggingService.info( - `Error inserting subscription notification types: ${asError(e).message}`, - ); - throw new UnprocessableEntityException( - 'Error inserting subscription notification types', - ); - }); - } - /** - * Gets notification preferences for given account for the device/Safe. + * Gets notification preferences for given account/device for the given Safe. * * @param args.account Account address * @param args.deviceUuid Device UUID * @param args.chainId Chain ID * @param args.safeAddress Safe address * - * @returns Notification preferences for the device/Safe + * @returns List of {@link DomainNotificationType} notifications subscribed to */ async getSafeSubscription(args: { account: `0x${string}`; deviceUuid: Uuid; chainId: string; safeAddress: `0x${string}`; - }): Promise> { + }): Promise> { const account = await this.accountsDatasource.getAccount(args.account); - return this.sql.begin(async (sql) => { - const subscription = await this.getAccountSubscription({ - sql, - accountId: account.id, - deviceUuid: args.deviceUuid, - chainId: args.chainId, - safeAddress: args.safeAddress, - }); - - const notificationTypes = await this.getNotificationTypes({ - sql, - subscriptionId: subscription.id, - }); - - return this.mapNotificationTypes(notificationTypes); - }); - } - - private async getAccountSubscription(args: { - sql: postgres.TransactionSql; - accountId: number; - deviceUuid: Uuid; - chainId: string; - safeAddress: `0x${string}`; - }): Promise { - const [subscription] = await args.sql<[NotificationSubscription]>` - SELECT * + const notificationTypes = await this.sql< + Array<{ name: DomainNotificationType }> + >` + SELECT nt.name FROM notification_subscriptions ns - JOIN notification_channel_configurations ncc - ON ns.id = ncc.notification_subscription_id - WHERE ns.account_id = ${args.accountId} - AND ns.chain_id = ${args.chainId} - AND ns.safe_address = ${args.safeAddress} - AND ncc.device_uuid = ${args.deviceUuid}; + JOIN notification_devices nd ON ns.device_id = nd.id + JOIN notification_subscription_notification_types nsnt ON ns.id = nsnt.notification_subscription_id + JOIN notification_types nt ON nsnt.notification_type_id = nt.id + WHERE ns.account_id = ${account.id} + AND ns.chain_id = ${args.chainId} + AND ns.safe_address = ${args.safeAddress} + AND nd.device_uuid = ${args.deviceUuid} `.catch((e) => { - this.loggingService.info( - `Error getting account subscription: ${asError(e).message}`, - ); - return []; - }); - - if (!subscription) { - throw new NotFoundException('Error getting account subscription'); - } - - return subscription; - } - - private async getNotificationTypes(args: { - sql: postgres.TransactionSql; - subscriptionId: number; - }): Promise> { - const types = await args.sql>` - SELECT nt.name - FROM notification_subscription_notification_types nsnt - JOIN notification_types nt ON nsnt.notification_type_id = nt.id - WHERE nsnt.subscription_id = ${args.subscriptionId}; - `.catch((e) => { - this.loggingService.info( - `Error getting notification types: ${asError(e).message}`, - ); - return []; + const error = 'Error getting subscription or notification types'; + this.loggingService.info(`${error}: ${asError(e).message}`); + throw new NotFoundException(error); }); - if (types.length === 0) { - throw new NotFoundException('Error getting notification types'); - } - - return types; - } - - private mapNotificationTypes( - notificationTypes: Array, - ): Record { - return Object.values(DomainNotificationType).reduce< - Record - >( - (acc, type) => { - acc[type] = notificationTypes.some((row) => row.name === type); - return acc; - }, - {} as Record, - ); + return notificationTypes.map((notificationType) => notificationType.name); } /** @@ -333,223 +156,79 @@ export class NotificationsDatasource implements INotificationsDatasource { */ async getSubscribersWithTokensBySafe(args: { chainId: string; - safeAddress: `0x${string}`; + safeAddress: string; }): Promise< Array<{ subscriber: `0x${string}`; cloudMessagingToken: string; }> > { - return this.sql.begin(async (sql) => { - const subscriptions = await this.getSafeSubscriptions({ - sql, - chainId: args.chainId, - safeAddress: args.safeAddress, - }); - - return Promise.all( - subscriptions.map(async (subscription) => { - const [account, config] = await Promise.all([ - this.getAccountById({ sql, accountId: subscription.account_id }), - this.getChannelConfig({ sql, subscriptionId: subscription.id }), - ]); - - return { - subscriber: account.address, - cloudMessagingToken: config.cloud_messaging_token, - }; - }), - ); - }); - } - - private async getSafeSubscriptions(args: { - sql: postgres.TransactionSql; - chainId: string; - safeAddress: `0x${string}`; - }): Promise> { - const subscriptions = await args.sql>` - SELECT * - FROM notification_subscriptions - WHERE chain_id = ${args.chainId} AND safe_address = ${args.safeAddress}; - `.catch((e) => { - this.loggingService.info( - `Error getting Safe subscriptions: ${asError(e).message}`, - ); - return []; - }); - - if (subscriptions.length === 0) { - throw new NotFoundException('Error getting Safe subscriptions'); - } - - return subscriptions; - } - - private async getAccountById(args: { - sql: postgres.TransactionSql; - accountId: number; - }): Promise { - const [account] = await args.sql<[Account]>` - SELECT * - FROM accounts - WHERE id = ${args.accountId} + const subscribers = await this.sql< + Array<{ address: `0x${string}`; cloud_messaging_token: string }> + >` + SELECT a.address, nd.cloud_messaging_token + FROM notification_subscriptions ns + JOIN accounts a ON ns.account_id = a.id + JOIN notification_devices nd ON ns.device_id = nd.id + WHERE ns.chain_id = ${args.chainId} + AND ns.safe_address = ${args.safeAddress} `.catch((e) => { - this.loggingService.info(`Error getting account: ${asError(e).message}`); - return []; + const error = 'Error getting subscribers with tokens'; + this.loggingService.info(`${error}: ${asError(e).message}`); + throw new NotFoundException(error); }); - if (!account) { - throw new NotFoundException('Error getting account'); - } - - return account; - } - - private async getChannelConfig(args: { - sql: postgres.TransactionSql; - subscriptionId: number; - }): Promise { - const [config] = await args.sql<[NotificationChannelConfig]>` - SELECT * - FROM notification_channel_configurations - WHERE notification_subscription_id = ${args.subscriptionId}; - `.catch((e) => { - this.loggingService.info( - `Error getting channel configuration: ${asError(e).message}`, - ); - return []; + return subscribers.map((subscriber) => { + return { + subscriber: subscriber.address, + cloudMessagingToken: subscriber.cloud_messaging_token, + }; }); - - if (!config) { - throw new NotFoundException('Error getting channel configuration'); - } - - return config; } /** - * Deletes the subscription for the given account/Safe. + * Deletes the Safe subscription for the given account/device. * * @param args.account Account address + * @param args.deviceUuid Device UUID * @param args.chainId Chain ID * @param args.safeAddress Safe address */ async deleteSubscription(args: { account: `0x${string}`; + deviceUuid: Uuid; chainId: string; safeAddress: `0x${string}`; }): Promise { - await this.sql.begin(async (sql) => { - const subscription = await this.getAccountSubscriptions({ - sql, - ...args, - }); - - return this.deleteSubscriptionById({ - sql, - subscriptionId: subscription.id, - }); - }); - } - - private async getAccountSubscriptions(args: { - sql: postgres.TransactionSql; - account: `0x${string}`; - chainId: string; - safeAddress: `0x${string}`; - }): Promise { - const [subscription] = await args.sql<[NotificationSubscription]>` - SELECT notification_subscriptions.id - FROM notification_subscriptions - JOIN accounts ON notification_subscriptions.account_id = accounts.id - WHERE accounts.address = ${args.account} AND notification_subscriptions.safe_address = ${args.safeAddress} AND notification_subscriptions.chain_id = ${args.chainId} - `.catch((e) => { - this.loggingService.info( - `Error getting subscription for account/Safe: ${asError(e).message}`, - ); - return []; - }); - - if (!subscription) { - throw new NotFoundException( - 'Error getting subscription for account/Safe', - ); - } - - return subscription; - } - - private async deleteSubscriptionById(args: { - sql: postgres.TransactionSql; - subscriptionId: number; - }): Promise { - await args.sql` - DELETE FROM notification_subscriptions - WHERE id = ${args.subscriptionId} + await this.sql` + DELETE FROM notification_subscriptions ns + USING accounts a, notification_devices nd + WHERE ns.account_id = a.id + AND ns.device_id = nd.id + AND a.address = ${args.account} + AND nd.device_uuid = ${args.deviceUuid} + AND ns.chain_id = ${args.chainId} + AND ns.safe_address = ${args.safeAddress} `.catch((e) => { - this.loggingService.info( - `Error deleting subscription: ${asError(e).message}`, - ); - throw new UnprocessableEntityException('Error deleting subscription'); + const error = 'Error deleting subscription'; + this.loggingService.info(`${error}: ${asError(e).message}`); + throw new UnprocessableEntityException(error); }); } /** - * Deletes the device and all its subscriptions. + * Deletes subscriptions for the given device UUID. + * * @param deviceUuid Device UUID */ async deleteDevice(deviceUuid: Uuid): Promise { - await this.sql.begin(async (sql) => { - const configs = await this.getChannelConfigsByDevice({ - sql, - deviceUuid, - }); - const subscriptionIds = configs.map( - (row) => row.notification_subscription_id, - ); - - await this.deleteSubscriptions({ - sql, - subscriptionIds, - }); - }); - } - - private async getChannelConfigsByDevice(args: { - sql: postgres.TransactionSql; - deviceUuid: Uuid; - }): Promise> { - const configs = await args.sql>` - SELECT DISTINCT * - FROM notification_channel_configurations - WHERE device_uuid = ${args.deviceUuid} - `.catch((e) => { - this.loggingService.info( - `Error getting channel configurations: ${asError(e).message}`, - ); - return []; - }); - - if (configs.length === 0) { - throw new NotFoundException('Error getting channel configurations'); - } - - return configs; - } - - private async deleteSubscriptions(args: { - sql: postgres.TransactionSql; - subscriptionIds: Array; - }): Promise { - await args.sql` - DELETE FROM notification_subscriptions - WHERE id = ANY(${args.subscriptionIds}) + await this.sql` + DELETE FROM notification_devices + WHERE device_uuid = ${deviceUuid} `.catch((e) => { - this.loggingService.info( - `Error deleting subscriptions: ${asError(e).message}`, - ); - throw new UnprocessableEntityException('Error deleting subscriptions'); + const error = 'Error deleting device'; + this.loggingService.info(`${error}: ${asError(e).message}`); + throw new UnprocessableEntityException(error); }); } } diff --git a/src/domain/interfaces/notifications.datasource.interface.ts b/src/domain/interfaces/notifications.datasource.interface.ts index 61df5600e6..ba29ecdaa4 100644 --- a/src/domain/interfaces/notifications.datasource.interface.ts +++ b/src/domain/interfaces/notifications.datasource.interface.ts @@ -12,13 +12,24 @@ export interface INotificationsDatasource { getSafeSubscription(args: { account: `0x${string}`; deviceUuid: Uuid; - chainId: `0x${string}`; + chainId: string; safeAddress: `0x${string}`; - }): Promise>; + }): Promise>; + + getSubscribersWithTokensBySafe(args: { + chainId: string; + safeAddress: `0x${string}`; + }): Promise< + Array<{ + subscriber: `0x${string}`; + cloudMessagingToken: string; + }> + >; deleteSubscription(args: { account: `0x${string}`; - chainId: `0x${string}`; + deviceUuid: Uuid; + chainId: string; safeAddress: `0x${string}`; }): Promise; From 1db3af7ee2e552afcbbe8e0fcb33536c8c57eb77 Mon Sep 17 00:00:00 2001 From: iamacook Date: Wed, 24 Jul 2024 08:34:57 +0200 Subject: [PATCH 16/37] Remove unnecessary `account_id` from `notification_devices` table --- migrations/00005_notifications/index.sql | 23 +- .../__tests__/00005_notifications.spec.ts | 369 ++++++++++-------- .../notifications.datasource.spec.ts | 4 - .../notifications/notifications.datasource.ts | 4 +- 4 files changed, 226 insertions(+), 174 deletions(-) diff --git a/migrations/00005_notifications/index.sql b/migrations/00005_notifications/index.sql index 8cc9aaa947..f5db3dfb78 100644 --- a/migrations/00005_notifications/index.sql +++ b/migrations/00005_notifications/index.sql @@ -5,13 +5,11 @@ DROP TABLE IF EXISTS notification_devices, notification_channels, notification_t --------------------------------------------------- CREATE TABLE notification_devices ( id SERIAL PRIMARY KEY, - account_id INT NOT NULL, device_type VARCHAR(255) CHECK (device_type IN ('ANDROID', 'IOS', 'WEB')) NOT NULL, device_uuid UUID NOT NULL UNIQUE, cloud_messaging_token VARCHAR(255) NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), - FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() ); -- Update updated_at when device is updated to track validity of token @@ -25,6 +23,17 @@ CREATE TABLE notification_channels ( name VARCHAR(255) NOT NULL UNIQUE ); +-- Function to delete orphaned devices (called at bottom as depends on notification_subscriptions) +CREATE OR REPLACE FUNCTION delete_orphaned_devices() +RETURNS TRIGGER AS $$ +BEGIN + DELETE FROM notification_devices + WHERE id NOT IN (SELECT device_id FROM notification_subscriptions); + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + ---------------------------------------------------- -- Notification channels, e.g. PUSH_NOTIFICATIONS -- ---------------------------------------------------- @@ -80,4 +89,10 @@ CREATE TABLE notification_subscription_notification_types ( FOREIGN KEY (notification_subscription_id) REFERENCES notification_subscriptions(id) ON DELETE CASCADE, FOREIGN KEY (notification_type_id) REFERENCES notification_types(id) ON DELETE CASCADE, UNIQUE(notification_subscription_id, notification_type_id) -); \ No newline at end of file +); + +-- Delete orphaned devices after a subscription is deleted +CREATE TRIGGER after_delete_notification_subscriptions +AFTER DELETE ON notification_subscriptions +FOR EACH ROW +EXECUTE FUNCTION delete_orphaned_devices(); \ No newline at end of file diff --git a/migrations/__tests__/00005_notifications.spec.ts b/migrations/__tests__/00005_notifications.spec.ts index 78a3f11c4c..61a2514f45 100644 --- a/migrations/__tests__/00005_notifications.spec.ts +++ b/migrations/__tests__/00005_notifications.spec.ts @@ -112,7 +112,6 @@ describe('Migration 00005_notifications', () => { notification_devices: { columns: expect.arrayContaining([ { column_name: 'id' }, - { column_name: 'account_id' }, { column_name: 'device_type' }, { column_name: 'device_uuid' }, { column_name: 'cloud_messaging_token' }, @@ -194,32 +193,22 @@ describe('Migration 00005_notifications', () => { }); it('should upsert the updated_at timestamp in notification_devices', async () => { - const address = getAddress(faker.finance.ethereumAddress()); const deviceType = faker.helpers.arrayElement(Object.values(DeviceType)); const deviceUuid = faker.string.uuid() as Uuid; const cloudMessagingToken = faker.string.alphanumeric(); const afterMigration = await migrator.test({ migration: '00005_notifications', after: async (sql: postgres.Sql) => { - // Create account - const [account] = await sql< - [ - { - id: number; - }, - ] - >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`; // Create device return sql< [NotificationDevicesRow] - >`INSERT INTO notification_devices (account_id, device_type, device_uuid, cloud_messaging_token) VALUES (${account.id}, ${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`; + >`INSERT INTO notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`; }, }); expect(afterMigration.after).toStrictEqual([ { id: 1, - account_id: 1, device_type: deviceType, device_uuid: deviceUuid, cloud_messaging_token: cloudMessagingToken, @@ -237,7 +226,6 @@ describe('Migration 00005_notifications', () => { expect(afterUpdate).toStrictEqual([ { id: afterMigration.after[0].id, - account_id: afterMigration.after[0].account_id, device_type: afterMigration.after[0].device_type, device_uuid: newDeviceUuid, cloud_messaging_token: afterMigration.after[0].cloud_messaging_token, @@ -253,88 +241,82 @@ describe('Migration 00005_notifications', () => { }); it('should only allow an ANDROID, IOS, or WEB as device_type in notification_devices', async () => { - const address = getAddress(faker.finance.ethereumAddress()); const deviceType = faker.lorem.word() as DeviceType; const deviceUuid = faker.string.uuid() as Uuid; const cloudMessagingToken = faker.string.alphanumeric(); - const afterMigration = await migrator.test({ + await migrator.test({ migration: '00005_notifications', - after: (sql: postgres.Sql) => { - // Create account - return sql< - [ - { - id: number; - }, - ] - >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`; - }, + after: () => Promise.resolve(), }); // Create device with invalid device_type await expect( - sql`INSERT INTO notification_devices (account_id, device_type, device_uuid, cloud_messaging_token) VALUES (${afterMigration.after[0].id}, ${deviceType}, ${deviceUuid}, ${cloudMessagingToken})`, + sql`INSERT INTO notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken})`, ).rejects.toThrow( 'new row for relation "notification_devices" violates check constraint "notification_devices_device_type_check"', ); }); it('should not allow a duplicate device_uuid in notification_devices', async () => { - const address = getAddress(faker.finance.ethereumAddress()); const deviceType = faker.helpers.arrayElement(Object.values(DeviceType)); const deviceUuid = faker.string.uuid() as Uuid; const cloudMessagingToken = faker.string.alphanumeric(); - const afterMigration = await migrator.test({ + await migrator.test({ migration: '00005_notifications', after: async (sql: postgres.Sql) => { - // Create account - const [account] = await sql< - [ - { - id: number; - }, - ] - >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`; // Create device return sql< [NotificationDevicesRow] - >`INSERT INTO notification_devices (account_id, device_type, device_uuid, cloud_messaging_token) VALUES (${account.id}, ${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`; + >`INSERT INTO notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`; }, }); // Create device with duplicate device_uuid await expect( - sql`INSERT INTO notification_devices (account_id, device_type, device_uuid, cloud_messaging_token) VALUES (${afterMigration.after[0].id}, ${deviceType}, ${deviceUuid}, ${cloudMessagingToken})`, + sql`INSERT INTO notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken})`, ).rejects.toThrow( 'duplicate key value violates unique constraint "notification_devices_device_uuid_key"', ); }); - it('should delete the device if the account is deleted', async () => { + it('should delete orphaned devices if there are no subscriptions associated with them', async () => { const address = getAddress(faker.finance.ethereumAddress()); + const safeAddress = getAddress(faker.finance.ethereumAddress()); + const chainId = faker.string.numeric(); const deviceType = faker.helpers.arrayElement(Object.values(DeviceType)); const deviceUuid = faker.string.uuid() as Uuid; const cloudMessagingToken = faker.string.alphanumeric(); const afterMigration = await migrator.test({ migration: '00005_notifications', after: async (sql: postgres.Sql) => { - // Create account - const [account] = await sql< - [ - { - id: number; - }, - ] - >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`; - // Create device - const [device] = await sql< - [NotificationDevicesRow] - >`INSERT INTO notification_devices (account_id, device_type, device_uuid, cloud_messaging_token) VALUES (${account.id}, ${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`; - return { account, device }; + const [[device], [channel], [account]] = await Promise.all([ + // Create device + sql< + [NotificationDevicesRow] + >`INSERT INTO notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, + // Get all notification channels + sql< + Array + >`SELECT * FROM notification_channels`, + // Create account + sql< + [ + { + id: number; + }, + ] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`, + ]); + // Create subscription + const [subscription] = await sql< + [NotificationSubscriptionsRow] + >`INSERT INTO notification_subscriptions (account_id, device_id, chain_id, safe_address, notification_channel_id) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress}, ${channel.id}) RETURNING *`; + return { device, subscription }; }, }); - // Delete account - await sql`DELETE FROM accounts WHERE id = ${afterMigration.after.account.id}`; + + // Delete subscription + await sql`DELETE FROM notification_subscriptions WHERE id = ${afterMigration.after.subscription.id}`; // Assert that device was deleted await expect( @@ -342,6 +324,65 @@ describe('Migration 00005_notifications', () => { ).resolves.toStrictEqual([]); }); + it('should not delete devices if there are remaining subscriptions associated with them', async () => { + const address = getAddress(faker.finance.ethereumAddress()); + const safeAddress1 = getAddress(faker.finance.ethereumAddress()); + const safeAddress2 = getAddress(faker.finance.ethereumAddress()); + const chainId = faker.string.numeric(); + const deviceType = faker.helpers.arrayElement(Object.values(DeviceType)); + const deviceUuid = faker.string.uuid() as Uuid; + const cloudMessagingToken = faker.string.alphanumeric(); + const afterMigration = await migrator.test({ + migration: '00005_notifications', + after: async (sql: postgres.Sql) => { + const [[device], [channel], [account]] = await Promise.all([ + // Create device + sql< + [NotificationDevicesRow] + >`INSERT INTO notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, + // Get all notification channels + sql< + Array + >`SELECT * FROM notification_channels`, + // Create account + sql< + [ + { + id: number; + }, + ] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`, + ]); + // Create first subscription + await sql< + [NotificationSubscriptionsRow] + >`INSERT INTO notification_subscriptions (account_id, device_id, chain_id, safe_address, notification_channel_id) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress1}, ${channel.id}) RETURNING *`; + // Create second subscription + const [subscription2] = await sql< + [NotificationSubscriptionsRow] + >`INSERT INTO notification_subscriptions (account_id, device_id, chain_id, safe_address, notification_channel_id) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress2}, ${channel.id}) RETURNING *`; + return { device, subscription2 }; + }, + }); + + // Delete subscription + await sql`DELETE FROM notification_subscriptions WHERE id = ${afterMigration.after.subscription2.id}`; + + // Assert that device was deleted + await expect( + sql`SELECT * FROM notification_devices WHERE id = ${afterMigration.after.device.id}`, + ).resolves.toStrictEqual([ + { + id: 1, + device_type: deviceType, + device_uuid: deviceUuid, + cloud_messaging_token: cloudMessagingToken, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }, + ]); + }); + it("shouldn't allow a duplicate name in notification_channels", async () => { const afterMigration = await migrator.test({ migration: '00005_notifications', @@ -390,23 +431,22 @@ describe('Migration 00005_notifications', () => { const afterMigration = await migrator.test({ migration: '00005_notifications', after: async (sql: postgres.Sql) => { - // Create account - const [account] = await sql< - [ - { - id: number; - }, - ] - >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`; - const [[device], [channel]] = await Promise.all([ + const [[device], [channel], [account]] = await Promise.all([ // Create device sql< [NotificationDevicesRow] - >`INSERT INTO notification_devices (account_id, device_type, device_uuid, cloud_messaging_token) VALUES (${account.id}, ${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, + >`INSERT INTO notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, // Get all notification channels sql< Array >`SELECT * FROM notification_channels`, + sql< + [ + { + id: number; + }, + ] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`, ]); // Create subscription const [subscription] = await sql< @@ -434,23 +474,23 @@ describe('Migration 00005_notifications', () => { const afterMigration = await migrator.test({ migration: '00005_notifications', after: async (sql: postgres.Sql) => { - // Create account - const [account] = await sql< - [ - { - id: number; - }, - ] - >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`; - const [[device], [channel]] = await Promise.all([ + const [[device], [channel], [account]] = await Promise.all([ // Create device sql< [NotificationDevicesRow] - >`INSERT INTO notification_devices (account_id, device_type, device_uuid, cloud_messaging_token) VALUES (${account.id}, ${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, + >`INSERT INTO notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, // Get all notification channels sql< Array >`SELECT * FROM notification_channels`, + // Create account + sql< + [ + { + id: number; + }, + ] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`, ]); // Create subscription const [subscription] = await sql< @@ -479,23 +519,23 @@ describe('Migration 00005_notifications', () => { const afterMigration = await migrator.test({ migration: '00005_notifications', after: async (sql: postgres.Sql) => { - // Create account - const [account] = await sql< - [ - { - id: number; - }, - ] - >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`; - const [[device], [channel]] = await Promise.all([ + const [[device], [channel], [account]] = await Promise.all([ // Create device sql< [NotificationDevicesRow] - >`INSERT INTO notification_devices (account_id, device_type, device_uuid, cloud_messaging_token) VALUES (${account.id}, ${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, + >`INSERT INTO notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, // Get all notification channels sql< Array >`SELECT * FROM notification_channels`, + // Create account + sql< + [ + { + id: number; + }, + ] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`, ]); // Create subscription const [subscription] = await sql< @@ -523,23 +563,22 @@ describe('Migration 00005_notifications', () => { const afterMigration = await migrator.test({ migration: '00005_notifications', after: async (sql: postgres.Sql) => { - // Create account - const [account] = await sql< - [ - { - id: number; - }, - ] - >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`; - const [[device], [channel]] = await Promise.all([ + const [[device], [channel], [account]] = await Promise.all([ // Create device sql< [NotificationDevicesRow] - >`INSERT INTO notification_devices (account_id, device_type, device_uuid, cloud_messaging_token) VALUES (${account.id}, ${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, + >`INSERT INTO notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, // Get all notification channels sql< Array >`SELECT * FROM notification_channels`, + sql< + [ + { + id: number; + }, + ] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`, ]); // Create subscription return sql< @@ -566,23 +605,23 @@ describe('Migration 00005_notifications', () => { const afterMigration = await migrator.test({ migration: '00005_notifications', after: async (sql: postgres.Sql) => { - // Create account - const [account] = await sql< - [ - { - id: number; - }, - ] - >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`; - const [[device], [channel]] = await Promise.all([ + const [[device], [channel], [account]] = await Promise.all([ // Create device sql< [NotificationDevicesRow] - >`INSERT INTO notification_devices (account_id, device_type, device_uuid, cloud_messaging_token) VALUES (${account.id}, ${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, + >`INSERT INTO notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, // Get all notification channels sql< Array >`SELECT * FROM notification_channels`, + // Create account + sql< + [ + { + id: number; + }, + ] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`, ]); // Create subscription return sql< @@ -640,26 +679,26 @@ describe('Migration 00005_notifications', () => { const afterMigration = await migrator.test({ migration: '00005_notifications', after: async (sql: postgres.Sql) => { - // Create account - const [account] = await sql< - [ - { - id: number; - }, - ] - >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`; - const [[device], [channel], [notificationType]] = await Promise.all([ - // Create device - sql< - [NotificationDevicesRow] - >`INSERT INTO notification_devices (account_id, device_type, device_uuid, cloud_messaging_token) VALUES (${account.id}, ${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, - // Get all notification channels - sql< - Array - >`SELECT * FROM notification_channels`, - // Get all notification types - sql>`SELECT * FROM notification_types`, - ]); + const [[device], [channel], [notificationType], [account]] = + await Promise.all([ + // Create device + sql< + [NotificationDevicesRow] + >`INSERT INTO notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, + // Get all notification channels + sql< + Array + >`SELECT * FROM notification_channels`, + // Get all notification types + sql>`SELECT * FROM notification_types`, + sql< + [ + { + id: number; + }, + ] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`, + ]); // Create subscription const [subscription] = await sql< [NotificationSubscriptionsRow] @@ -690,26 +729,27 @@ describe('Migration 00005_notifications', () => { const afterMigration = await migrator.test({ migration: '00005_notifications', after: async (sql: postgres.Sql) => { - // Create account - const [account] = await sql< - [ - { - id: number; - }, - ] - >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`; - const [[device], [channel], [notificationType]] = await Promise.all([ - // Create device - sql< - [NotificationDevicesRow] - >`INSERT INTO notification_devices (account_id, device_type, device_uuid, cloud_messaging_token) VALUES (${account.id}, ${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, - // Get all notification channels - sql< - Array - >`SELECT * FROM notification_channels`, - // Get all notification types - sql>`SELECT * FROM notification_types`, - ]); + const [[device], [channel], [notificationType], [account]] = + await Promise.all([ + // Create device + sql< + [NotificationDevicesRow] + >`INSERT INTO notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, + // Get all notification channels + sql< + Array + >`SELECT * FROM notification_channels`, + // Get all notification types + sql>`SELECT * FROM notification_types`, + // Create account + sql< + [ + { + id: number; + }, + ] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`, + ]); // Create subscription const [subscription] = await sql< [NotificationSubscriptionsRow] @@ -740,26 +780,27 @@ describe('Migration 00005_notifications', () => { const afterMigration = await migrator.test({ migration: '00005_notifications', after: async (sql: postgres.Sql) => { - // Create account - const [account] = await sql< - [ - { - id: number; - }, - ] - >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`; - const [[device], [channel], [notificationType]] = await Promise.all([ - // Create device - sql< - [NotificationDevicesRow] - >`INSERT INTO notification_devices (account_id, device_type, device_uuid, cloud_messaging_token) VALUES (${account.id}, ${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, - // Get all notification channels - sql< - Array - >`SELECT * FROM notification_channels`, - // Get all notification types - sql>`SELECT * FROM notification_types`, - ]); + const [[device], [channel], [notificationType], [account]] = + await Promise.all([ + // Create device + sql< + [NotificationDevicesRow] + >`INSERT INTO notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, + // Get all notification channels + sql< + Array + >`SELECT * FROM notification_channels`, + // Get all notification types + sql>`SELECT * FROM notification_types`, + // Create account + sql< + [ + { + id: number; + }, + ] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`, + ]); // Create subscription const [subscription] = await sql< [NotificationSubscriptionsRow] diff --git a/src/datasources/accounts/notifications/notifications.datasource.spec.ts b/src/datasources/accounts/notifications/notifications.datasource.spec.ts index cc9e1e8270..a330352073 100644 --- a/src/datasources/accounts/notifications/notifications.datasource.spec.ts +++ b/src/datasources/accounts/notifications/notifications.datasource.spec.ts @@ -101,7 +101,6 @@ describe('NotificationsDatasource', () => { expect(devices).toStrictEqual([ { id: 1, - account_id: accounts[0].id, device_type: upsertSubscriptionsDto.deviceType, device_uuid: actual.deviceUuid, cloud_messaging_token: upsertSubscriptionsDto.cloudMessagingToken, @@ -175,7 +174,6 @@ describe('NotificationsDatasource', () => { ).resolves.toStrictEqual([ { id: 1, - account_id: 1, device_type: upsertSubscriptionsDto.deviceType, device_uuid: expect.any(String), cloud_messaging_token: newCloudMessagingToken, @@ -273,7 +271,6 @@ describe('NotificationsDatasource', () => { expect(devices).toStrictEqual([ { id: 1, - account_id: accounts[0].id, device_type: upsertSubscriptionsDto.deviceType, device_uuid: upsertSubscriptionsDto.deviceUuid, cloud_messaging_token: upsertSubscriptionsDto.cloudMessagingToken, @@ -282,7 +279,6 @@ describe('NotificationsDatasource', () => { }, { id: 2, - account_id: accounts[0].id, device_type: upsertSubscriptionsDto.deviceType, device_uuid: secondDeviceUuid, cloud_messaging_token: upsertSubscriptionsDto.cloudMessagingToken, diff --git a/src/datasources/accounts/notifications/notifications.datasource.ts b/src/datasources/accounts/notifications/notifications.datasource.ts index 1dbc27301e..777dc74c54 100644 --- a/src/datasources/accounts/notifications/notifications.datasource.ts +++ b/src/datasources/accounts/notifications/notifications.datasource.ts @@ -56,8 +56,8 @@ export class NotificationsDatasource implements INotificationsDatasource { // Insert (or update the cloud messaging token of) a device const [device] = await sql<[{ id: number }]>` - INSERT INTO notification_devices (account_id, device_type, device_uuid, cloud_messaging_token) - VALUES (${account.id}, ${args.deviceType}, ${deviceUuid}, ${args.cloudMessagingToken}) + INSERT INTO notification_devices (device_type, device_uuid, cloud_messaging_token) + VALUES (${args.deviceType}, ${deviceUuid}, ${args.cloudMessagingToken}) ON CONFLICT (device_uuid) DO UPDATE SET cloud_messaging_token = EXCLUDED.cloud_messaging_token, From bab3f52c427419f06a1140ae7b2a9655eb7a6d53 Mon Sep 17 00:00:00 2001 From: iamacook Date: Wed, 24 Jul 2024 08:40:42 +0200 Subject: [PATCH 17/37] Remove unused entities --- .../entities/notification-channel-config.entity.ts | 11 ----------- .../entities/notification-channel.entity.ts | 6 ------ .../entities/notification-subscription.entity.ts | 8 -------- .../entities/notification-type.entity.ts | 6 ------ 4 files changed, 31 deletions(-) delete mode 100644 src/datasources/accounts/notifications/entities/notification-channel-config.entity.ts delete mode 100644 src/datasources/accounts/notifications/entities/notification-channel.entity.ts delete mode 100644 src/datasources/accounts/notifications/entities/notification-subscription.entity.ts delete mode 100644 src/datasources/accounts/notifications/entities/notification-type.entity.ts diff --git a/src/datasources/accounts/notifications/entities/notification-channel-config.entity.ts b/src/datasources/accounts/notifications/entities/notification-channel-config.entity.ts deleted file mode 100644 index acc6ecf4e3..0000000000 --- a/src/datasources/accounts/notifications/entities/notification-channel-config.entity.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; - -export type NotificationChannelConfig = { - id: number; - notification_subscription_id: number; - notification_channel_id: number; - device_uuid: Uuid; - cloud_messaging_token: string; - created_at: Date; - updated_at: Date; -}; diff --git a/src/datasources/accounts/notifications/entities/notification-channel.entity.ts b/src/datasources/accounts/notifications/entities/notification-channel.entity.ts deleted file mode 100644 index 307129b42b..0000000000 --- a/src/datasources/accounts/notifications/entities/notification-channel.entity.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { NotificationChannel as DomainNotificationChannel } from '@/domain/notifications/entities-v2/notification-channel.entity'; - -export type NotificationChannel = { - id: number; - name: DomainNotificationChannel; -}; diff --git a/src/datasources/accounts/notifications/entities/notification-subscription.entity.ts b/src/datasources/accounts/notifications/entities/notification-subscription.entity.ts deleted file mode 100644 index 1d5912b4ab..0000000000 --- a/src/datasources/accounts/notifications/entities/notification-subscription.entity.ts +++ /dev/null @@ -1,8 +0,0 @@ -export type NotificationSubscription = { - id: number; - account_id: number; - chain_id: string; - safe_address: `0x${string}`; - created_at: Date; - updated_at: Date; -}; diff --git a/src/datasources/accounts/notifications/entities/notification-type.entity.ts b/src/datasources/accounts/notifications/entities/notification-type.entity.ts deleted file mode 100644 index 8ec3023cb9..0000000000 --- a/src/datasources/accounts/notifications/entities/notification-type.entity.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { NotificationType as DomainNotificationType } from '@/domain/notifications/entities-v2/notification-type.entity'; - -export type NotificationType = { - id: number; - name: DomainNotificationType; -}; From 19900061b57560d9dde756cb76babc0a2493cd43 Mon Sep 17 00:00:00 2001 From: iamacook Date: Wed, 24 Jul 2024 15:23:49 +0200 Subject: [PATCH 18/37] Merge channel/device tables and move orphan logic to domain --- migrations/00005_notifications/index.sql | 62 +-- .../__tests__/00005_notifications.spec.ts | 390 +++++------------- .../notifications.datasource.spec.ts | 126 ++++-- .../notifications/notifications.datasource.ts | 98 +++-- .../notifications.datasource.interface.ts | 2 +- .../notification-channel.entity.ts | 3 - 6 files changed, 259 insertions(+), 422 deletions(-) delete mode 100644 src/domain/notifications/entities-v2/notification-channel.entity.ts diff --git a/migrations/00005_notifications/index.sql b/migrations/00005_notifications/index.sql index f5db3dfb78..9478d9de27 100644 --- a/migrations/00005_notifications/index.sql +++ b/migrations/00005_notifications/index.sql @@ -1,9 +1,9 @@ -DROP TABLE IF EXISTS notification_devices, notification_channels, notification_types, notification_subscriptions, notification_subscription_notification_types CASCADE; +DROP TABLE IF EXISTS push_notification_devices, notification_types, notification_subscriptions, notification_subscription_notification_types CASCADE; ---------------------------------------------------- --- Notification devices: 'ANDROID', 'IOS', 'WEB' -- ---------------------------------------------------- -CREATE TABLE notification_devices ( +-------------------------------------------------------- +-- Push notification devices: 'ANDROID', 'IOS', 'WEB' -- +-------------------------------------------------------- +CREATE TABLE push_notification_devices ( id SERIAL PRIMARY KEY, device_type VARCHAR(255) CHECK (device_type IN ('ANDROID', 'IOS', 'WEB')) NOT NULL, device_uuid UUID NOT NULL UNIQUE, @@ -13,33 +13,11 @@ CREATE TABLE notification_devices ( ); -- Update updated_at when device is updated to track validity of token -CREATE OR REPLACE TRIGGER update_notification_devices_updated_at - BEFORE UPDATE ON notification_devices +CREATE OR REPLACE TRIGGER update_push_notification_devices_updated_at + BEFORE UPDATE ON push_notification_devices FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -CREATE TABLE notification_channels ( - id SERIAL PRIMARY KEY, - name VARCHAR(255) NOT NULL UNIQUE -); - --- Function to delete orphaned devices (called at bottom as depends on notification_subscriptions) -CREATE OR REPLACE FUNCTION delete_orphaned_devices() -RETURNS TRIGGER AS $$ -BEGIN - DELETE FROM notification_devices - WHERE id NOT IN (SELECT device_id FROM notification_subscriptions); - - RETURN NULL; -END; -$$ LANGUAGE plpgsql; - ----------------------------------------------------- --- Notification channels, e.g. PUSH_NOTIFICATIONS -- ----------------------------------------------------- -INSERT INTO notification_channels (name) VALUES - ('PUSH_NOTIFICATIONS'); - -------------------------------------------- -- Notification types, e.g. INCOMING_TOKEN -- -------------------------------------------- @@ -57,22 +35,20 @@ INSERT INTO notification_types (name) VALUES ('MESSAGE_CONFIRMATION_REQUEST'), -- MESSAGE_CREATED ('MODULE_TRANSACTION'); ------------------------------------------------------------ --- Safe subscriptions for a given account-device-channel -- ------------------------------------------------------------ +--------------------------------------------------- +-- Safe subscriptions for a given account/device -- +--------------------------------------------------- CREATE TABLE notification_subscriptions ( id SERIAL PRIMARY KEY, account_id INT NOT NULL, - device_id INT NOT NULL, + push_notification_device_id INT NOT NULL, chain_id VARCHAR(255) NOT NULL, safe_address VARCHAR(42) NOT NULL, - notification_channel_id INT NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE, - FOREIGN KEY (device_id) REFERENCES notification_devices(id) ON DELETE CASCADE, - FOREIGN KEY (notification_channel_id) REFERENCES notification_channels(id) ON DELETE CASCADE, - UNIQUE(account_id, chain_id, safe_address, device_id, notification_channel_id) + FOREIGN KEY (push_notification_device_id) REFERENCES push_notification_devices(id) ON DELETE CASCADE, + UNIQUE(account_id, chain_id, safe_address, push_notification_device_id) ); -- Update updated_at when a notification subscription is updated @@ -81,7 +57,9 @@ CREATE OR REPLACE TRIGGER update_notification_subscriptions_updated_at FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); --- Join table for subscriptions/notification types +---------------------------------------------------- +-- Join table for subscription/notification types -- +---------------------------------------------------- CREATE TABLE notification_subscription_notification_types ( id SERIAL PRIMARY KEY, notification_subscription_id INT NOT NULL, @@ -89,10 +67,4 @@ CREATE TABLE notification_subscription_notification_types ( FOREIGN KEY (notification_subscription_id) REFERENCES notification_subscriptions(id) ON DELETE CASCADE, FOREIGN KEY (notification_type_id) REFERENCES notification_types(id) ON DELETE CASCADE, UNIQUE(notification_subscription_id, notification_type_id) -); - --- Delete orphaned devices after a subscription is deleted -CREATE TRIGGER after_delete_notification_subscriptions -AFTER DELETE ON notification_subscriptions -FOR EACH ROW -EXECUTE FUNCTION delete_orphaned_devices(); \ No newline at end of file +); \ No newline at end of file diff --git a/migrations/__tests__/00005_notifications.spec.ts b/migrations/__tests__/00005_notifications.spec.ts index 61a2514f45..57dd827aaa 100644 --- a/migrations/__tests__/00005_notifications.spec.ts +++ b/migrations/__tests__/00005_notifications.spec.ts @@ -6,7 +6,7 @@ import { faker } from '@faker-js/faker'; import postgres from 'postgres'; import { getAddress } from 'viem'; -type NotificationDevicesRow = { +type PushNotificationDevicesRow = { id: number; account_id: number; device_type: 'ANDROID' | 'IOS' | 'WEB'; @@ -16,11 +16,6 @@ type NotificationDevicesRow = { updated_at: Date; }; -type NotificationChannelsRow = { - id: number; - name: 'PUSH_NOTIFICATIONS'; -}; - type NotificationTypesRow = { id: number; name: @@ -36,10 +31,9 @@ type NotificationTypesRow = { type NotificationSubscriptionsRow = { id: number; account_id: number; - device_id: NotificationDevicesRow['id']; + push_notification_device_id: PushNotificationDevicesRow['id']; chain_id: string; safe_address: `0x${string}`; - notification_channel_id: NotificationChannelsRow['id']; created_at: Date; updated_at: Date; }; @@ -69,19 +63,12 @@ describe('Migration 00005_notifications', () => { migration: '00005_notifications', after: async (sql: postgres.Sql) => { return { - notification_devices: { + push_notification_devices: { columns: - await sql`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'notification_devices'`, + await sql`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'push_notification_devices'`, rows: await sql< - Array - >`SELECT * FROM notification_devices`, - }, - notification_channels: { - columns: - await sql`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'notification_channels'`, - rows: await sql< - Array - >`SELECT * FROM notification_channels`, + Array + >`SELECT * FROM push_notification_devices`, }, notification_types: { columns: @@ -109,7 +96,7 @@ describe('Migration 00005_notifications', () => { }); expect(result.after).toStrictEqual({ - notification_devices: { + push_notification_devices: { columns: expect.arrayContaining([ { column_name: 'id' }, { column_name: 'device_type' }, @@ -120,18 +107,6 @@ describe('Migration 00005_notifications', () => { ]), rows: [], }, - notification_channels: { - columns: expect.arrayContaining([ - { column_name: 'id' }, - { column_name: 'name' }, - ]), - rows: [ - { - id: expect.any(Number), - name: 'PUSH_NOTIFICATIONS', - }, - ], - }, notification_types: { columns: expect.arrayContaining([ { column_name: 'id' }, @@ -172,10 +147,9 @@ describe('Migration 00005_notifications', () => { columns: expect.arrayContaining([ { column_name: 'id' }, { column_name: 'account_id' }, - { column_name: 'device_id' }, + { column_name: 'push_notification_device_id' }, { column_name: 'chain_id' }, { column_name: 'safe_address' }, - { column_name: 'notification_channel_id' }, { column_name: 'created_at' }, { column_name: 'updated_at' }, ]), @@ -192,7 +166,7 @@ describe('Migration 00005_notifications', () => { }); }); - it('should upsert the updated_at timestamp in notification_devices', async () => { + it('should upsert the updated_at timestamp in push_notification_devices', async () => { const deviceType = faker.helpers.arrayElement(Object.values(DeviceType)); const deviceUuid = faker.string.uuid() as Uuid; const cloudMessagingToken = faker.string.alphanumeric(); @@ -201,8 +175,8 @@ describe('Migration 00005_notifications', () => { after: async (sql: postgres.Sql) => { // Create device return sql< - [NotificationDevicesRow] - >`INSERT INTO notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`; + [PushNotificationDevicesRow] + >`INSERT INTO push_notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`; }, }); @@ -220,8 +194,8 @@ describe('Migration 00005_notifications', () => { const newDeviceUuid = faker.string.uuid() as Uuid; // Update device with new device_uuid const afterUpdate = await sql< - [NotificationDevicesRow] - >`UPDATE notification_devices SET device_uuid = ${newDeviceUuid} WHERE device_uuid = ${deviceUuid} RETURNING *`; + [PushNotificationDevicesRow] + >`UPDATE push_notification_devices SET device_uuid = ${newDeviceUuid} WHERE device_uuid = ${deviceUuid} RETURNING *`; expect(afterUpdate).toStrictEqual([ { @@ -240,7 +214,7 @@ describe('Migration 00005_notifications', () => { ); }); - it('should only allow an ANDROID, IOS, or WEB as device_type in notification_devices', async () => { + it('should only allow an ANDROID, IOS, or WEB as device_type in push_notification_devices', async () => { const deviceType = faker.lorem.word() as DeviceType; const deviceUuid = faker.string.uuid() as Uuid; const cloudMessagingToken = faker.string.alphanumeric(); @@ -251,13 +225,13 @@ describe('Migration 00005_notifications', () => { // Create device with invalid device_type await expect( - sql`INSERT INTO notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken})`, + sql`INSERT INTO push_notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken})`, ).rejects.toThrow( - 'new row for relation "notification_devices" violates check constraint "notification_devices_device_type_check"', + 'new row for relation "push_notification_devices" violates check constraint "push_notification_devices_device_type_check"', ); }); - it('should not allow a duplicate device_uuid in notification_devices', async () => { + it('should not allow a duplicate device_uuid in push_notification_devices', async () => { const deviceType = faker.helpers.arrayElement(Object.values(DeviceType)); const deviceUuid = faker.string.uuid() as Uuid; const cloudMessagingToken = faker.string.alphanumeric(); @@ -266,139 +240,16 @@ describe('Migration 00005_notifications', () => { after: async (sql: postgres.Sql) => { // Create device return sql< - [NotificationDevicesRow] - >`INSERT INTO notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`; + [PushNotificationDevicesRow] + >`INSERT INTO push_notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`; }, }); // Create device with duplicate device_uuid await expect( - sql`INSERT INTO notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken})`, + sql`INSERT INTO push_notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken})`, ).rejects.toThrow( - 'duplicate key value violates unique constraint "notification_devices_device_uuid_key"', - ); - }); - - it('should delete orphaned devices if there are no subscriptions associated with them', async () => { - const address = getAddress(faker.finance.ethereumAddress()); - const safeAddress = getAddress(faker.finance.ethereumAddress()); - const chainId = faker.string.numeric(); - const deviceType = faker.helpers.arrayElement(Object.values(DeviceType)); - const deviceUuid = faker.string.uuid() as Uuid; - const cloudMessagingToken = faker.string.alphanumeric(); - const afterMigration = await migrator.test({ - migration: '00005_notifications', - after: async (sql: postgres.Sql) => { - const [[device], [channel], [account]] = await Promise.all([ - // Create device - sql< - [NotificationDevicesRow] - >`INSERT INTO notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, - // Get all notification channels - sql< - Array - >`SELECT * FROM notification_channels`, - // Create account - sql< - [ - { - id: number; - }, - ] - >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`, - ]); - // Create subscription - const [subscription] = await sql< - [NotificationSubscriptionsRow] - >`INSERT INTO notification_subscriptions (account_id, device_id, chain_id, safe_address, notification_channel_id) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress}, ${channel.id}) RETURNING *`; - return { device, subscription }; - }, - }); - - // Delete subscription - await sql`DELETE FROM notification_subscriptions WHERE id = ${afterMigration.after.subscription.id}`; - - // Assert that device was deleted - await expect( - sql`SELECT * FROM notification_devices WHERE id = ${afterMigration.after.device.id}`, - ).resolves.toStrictEqual([]); - }); - - it('should not delete devices if there are remaining subscriptions associated with them', async () => { - const address = getAddress(faker.finance.ethereumAddress()); - const safeAddress1 = getAddress(faker.finance.ethereumAddress()); - const safeAddress2 = getAddress(faker.finance.ethereumAddress()); - const chainId = faker.string.numeric(); - const deviceType = faker.helpers.arrayElement(Object.values(DeviceType)); - const deviceUuid = faker.string.uuid() as Uuid; - const cloudMessagingToken = faker.string.alphanumeric(); - const afterMigration = await migrator.test({ - migration: '00005_notifications', - after: async (sql: postgres.Sql) => { - const [[device], [channel], [account]] = await Promise.all([ - // Create device - sql< - [NotificationDevicesRow] - >`INSERT INTO notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, - // Get all notification channels - sql< - Array - >`SELECT * FROM notification_channels`, - // Create account - sql< - [ - { - id: number; - }, - ] - >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`, - ]); - // Create first subscription - await sql< - [NotificationSubscriptionsRow] - >`INSERT INTO notification_subscriptions (account_id, device_id, chain_id, safe_address, notification_channel_id) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress1}, ${channel.id}) RETURNING *`; - // Create second subscription - const [subscription2] = await sql< - [NotificationSubscriptionsRow] - >`INSERT INTO notification_subscriptions (account_id, device_id, chain_id, safe_address, notification_channel_id) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress2}, ${channel.id}) RETURNING *`; - return { device, subscription2 }; - }, - }); - - // Delete subscription - await sql`DELETE FROM notification_subscriptions WHERE id = ${afterMigration.after.subscription2.id}`; - - // Assert that device was deleted - await expect( - sql`SELECT * FROM notification_devices WHERE id = ${afterMigration.after.device.id}`, - ).resolves.toStrictEqual([ - { - id: 1, - device_type: deviceType, - device_uuid: deviceUuid, - cloud_messaging_token: cloudMessagingToken, - created_at: expect.any(Date), - updated_at: expect.any(Date), - }, - ]); - }); - - it("shouldn't allow a duplicate name in notification_channels", async () => { - const afterMigration = await migrator.test({ - migration: '00005_notifications', - after: (sql: postgres.Sql) => { - // Get all notification channels - return sql< - Array - >`SELECT * FROM notification_channels`; - }, - }); - - // Create channel with duplicate name - await expect( - sql`INSERT INTO notification_channels (name) VALUES (${afterMigration.after[0].name})`, - ).rejects.toThrow( - 'duplicate key value violates unique constraint "notification_channels_name_key"', + 'duplicate key value violates unique constraint "push_notification_devices_device_uuid_key"', ); }); @@ -431,15 +282,12 @@ describe('Migration 00005_notifications', () => { const afterMigration = await migrator.test({ migration: '00005_notifications', after: async (sql: postgres.Sql) => { - const [[device], [channel], [account]] = await Promise.all([ + const [[device], [account]] = await Promise.all([ // Create device sql< - [NotificationDevicesRow] - >`INSERT INTO notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, - // Get all notification channels - sql< - Array - >`SELECT * FROM notification_channels`, + [PushNotificationDevicesRow] + >`INSERT INTO push_notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, + // Create account sql< [ { @@ -451,7 +299,7 @@ describe('Migration 00005_notifications', () => { // Create subscription const [subscription] = await sql< [NotificationSubscriptionsRow] - >`INSERT INTO notification_subscriptions (account_id, device_id, chain_id, safe_address, notification_channel_id) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress}, ${channel.id}) RETURNING *`; + >`INSERT INTO notification_subscriptions (account_id, push_notification_device_id, chain_id, safe_address) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress}) RETURNING *`; return { account, subscription }; }, }); @@ -474,15 +322,11 @@ describe('Migration 00005_notifications', () => { const afterMigration = await migrator.test({ migration: '00005_notifications', after: async (sql: postgres.Sql) => { - const [[device], [channel], [account]] = await Promise.all([ + const [[device], [account]] = await Promise.all([ // Create device sql< - [NotificationDevicesRow] - >`INSERT INTO notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, - // Get all notification channels - sql< - Array - >`SELECT * FROM notification_channels`, + [PushNotificationDevicesRow] + >`INSERT INTO push_notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, // Create account sql< [ @@ -495,13 +339,13 @@ describe('Migration 00005_notifications', () => { // Create subscription const [subscription] = await sql< [NotificationSubscriptionsRow] - >`INSERT INTO notification_subscriptions (account_id, device_id, chain_id, safe_address, notification_channel_id) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress}, ${channel.id}) RETURNING *`; + >`INSERT INTO notification_subscriptions (account_id, push_notification_device_id, chain_id, safe_address) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress}) RETURNING *`; return { device, subscription }; }, }); // Delete device - await sql`DELETE FROM notification_devices WHERE id = ${afterMigration.after.device.id}`; + await sql`DELETE FROM push_notification_devices WHERE id = ${afterMigration.after.device.id}`; // Assert that subscription was deleted await expect( @@ -509,7 +353,7 @@ describe('Migration 00005_notifications', () => { ).resolves.toStrictEqual([]); }); - it('should delete the subscription if the channel is deleted', async () => { + it('should delete the subscription if the device is deleted', async () => { const address = getAddress(faker.finance.ethereumAddress()); const safeAddress = getAddress(faker.finance.ethereumAddress()); const chainId = faker.string.numeric(); @@ -519,15 +363,11 @@ describe('Migration 00005_notifications', () => { const afterMigration = await migrator.test({ migration: '00005_notifications', after: async (sql: postgres.Sql) => { - const [[device], [channel], [account]] = await Promise.all([ + const [[device], [account]] = await Promise.all([ // Create device sql< - [NotificationDevicesRow] - >`INSERT INTO notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, - // Get all notification channels - sql< - Array - >`SELECT * FROM notification_channels`, + [PushNotificationDevicesRow] + >`INSERT INTO push_notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, // Create account sql< [ @@ -540,12 +380,12 @@ describe('Migration 00005_notifications', () => { // Create subscription const [subscription] = await sql< [NotificationSubscriptionsRow] - >`INSERT INTO notification_subscriptions (account_id, device_id, chain_id, safe_address, notification_channel_id) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress}, ${channel.id}) RETURNING *`; - return { channel, subscription }; + >`INSERT INTO notification_subscriptions (account_id, push_notification_device_id, chain_id, safe_address) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress}) RETURNING *`; + return { device, subscription }; }, }); // Delete channel - await sql`DELETE FROM notification_channels WHERE id = ${afterMigration.after.channel.id}`; + await sql`DELETE FROM push_notification_devices WHERE id = ${afterMigration.after.device.id}`; // Assert that subscription was deleted await expect( @@ -553,7 +393,7 @@ describe('Migration 00005_notifications', () => { ).resolves.toStrictEqual([]); }); - it('should prevent duplicate subscriptions (account, chain, Safe, device and channel) in notification_subscriptions', async () => { + it('should prevent duplicate subscriptions (account, chain, Safe address and device) in notification_subscriptions', async () => { const address = getAddress(faker.finance.ethereumAddress()); const safeAddress = getAddress(faker.finance.ethereumAddress()); const chainId = faker.string.numeric(); @@ -563,15 +403,12 @@ describe('Migration 00005_notifications', () => { const afterMigration = await migrator.test({ migration: '00005_notifications', after: async (sql: postgres.Sql) => { - const [[device], [channel], [account]] = await Promise.all([ + const [[device], [account]] = await Promise.all([ // Create device sql< - [NotificationDevicesRow] - >`INSERT INTO notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, - // Get all notification channels - sql< - Array - >`SELECT * FROM notification_channels`, + [PushNotificationDevicesRow] + >`INSERT INTO push_notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, + // Create account sql< [ { @@ -583,13 +420,13 @@ describe('Migration 00005_notifications', () => { // Create subscription return sql< [NotificationSubscriptionsRow] - >`INSERT INTO notification_subscriptions (account_id, device_id, chain_id, safe_address, notification_channel_id) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress}, ${channel.id}) RETURNING *`; + >`INSERT INTO notification_subscriptions (account_id, push_notification_device_id, chain_id, safe_address) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress}) RETURNING *`; }, }); // Create duplicate subscription await expect( - sql`INSERT INTO notification_subscriptions (account_id, device_id, chain_id, safe_address, notification_channel_id) VALUES (${afterMigration.after[0].account_id}, ${afterMigration.after[0].device_id}, ${chainId}, ${safeAddress}, ${afterMigration.after[0].notification_channel_id})`, + sql`INSERT INTO notification_subscriptions (account_id, push_notification_device_id, chain_id, safe_address) VALUES (${afterMigration.after[0].account_id}, ${afterMigration.after[0].push_notification_device_id}, ${chainId}, ${safeAddress})`, ).rejects.toThrow( 'duplicate key value violates unique constraint "notification_subscriptions_account_id_chain_id_safe_address_key"', ); @@ -605,15 +442,11 @@ describe('Migration 00005_notifications', () => { const afterMigration = await migrator.test({ migration: '00005_notifications', after: async (sql: postgres.Sql) => { - const [[device], [channel], [account]] = await Promise.all([ + const [[device], [account]] = await Promise.all([ // Create device sql< - [NotificationDevicesRow] - >`INSERT INTO notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, - // Get all notification channels - sql< - Array - >`SELECT * FROM notification_channels`, + [PushNotificationDevicesRow] + >`INSERT INTO push_notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, // Create account sql< [ @@ -626,7 +459,7 @@ describe('Migration 00005_notifications', () => { // Create subscription return sql< [NotificationSubscriptionsRow] - >`INSERT INTO notification_subscriptions (account_id, device_id, chain_id, safe_address, notification_channel_id) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress}, ${channel.id}) RETURNING *`; + >`INSERT INTO notification_subscriptions (account_id, push_notification_device_id, chain_id, safe_address) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress}) RETURNING *`; }, }); @@ -634,10 +467,9 @@ describe('Migration 00005_notifications', () => { { id: 1, account_id: 1, - device_id: 1, + push_notification_device_id: 1, chain_id: chainId, safe_address: safeAddress, - notification_channel_id: 1, created_at: expect.any(Date), updated_at: expect.any(Date), }, @@ -646,18 +478,17 @@ describe('Migration 00005_notifications', () => { const newSafeAddress = getAddress(faker.finance.ethereumAddress()); // Update subscription with new safe_address const afterUpdate = await sql< - [NotificationDevicesRow] + [PushNotificationDevicesRow] >`UPDATE notification_subscriptions SET safe_address = ${newSafeAddress} WHERE id = ${afterMigration.after[0].id} RETURNING *`; expect(afterUpdate).toStrictEqual([ { id: afterMigration.after[0].id, account_id: afterMigration.after[0].account_id, - device_id: afterMigration.after[0].device_id, + push_notification_device_id: + afterMigration.after[0].push_notification_device_id, chain_id: afterMigration.after[0].chain_id, safe_address: newSafeAddress, - notification_channel_id: - afterMigration.after[0].notification_channel_id, // created_at should have remained the same created_at: afterMigration.after[0].created_at, updated_at: expect.any(Date), @@ -679,30 +510,25 @@ describe('Migration 00005_notifications', () => { const afterMigration = await migrator.test({ migration: '00005_notifications', after: async (sql: postgres.Sql) => { - const [[device], [channel], [notificationType], [account]] = - await Promise.all([ - // Create device - sql< - [NotificationDevicesRow] - >`INSERT INTO notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, - // Get all notification channels - sql< - Array - >`SELECT * FROM notification_channels`, - // Get all notification types - sql>`SELECT * FROM notification_types`, - sql< - [ - { - id: number; - }, - ] - >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`, - ]); + const [[device], [notificationType], [account]] = await Promise.all([ + // Create device + sql< + [PushNotificationDevicesRow] + >`INSERT INTO push_notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, + // Get all notification types + sql>`SELECT * FROM notification_types`, + sql< + [ + { + id: number; + }, + ] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`, + ]); // Create subscription const [subscription] = await sql< [NotificationSubscriptionsRow] - >`INSERT INTO notification_subscriptions (account_id, device_id, chain_id, safe_address, notification_channel_id) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress}, ${channel.id}) RETURNING *`; + >`INSERT INTO notification_subscriptions (account_id, push_notification_device_id, chain_id, safe_address) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress}) RETURNING *`; // Subscribe to notification type const [subscribedNotificationType] = await sql< [NotificationSubscriptionNotificationTypesRow] @@ -729,31 +555,26 @@ describe('Migration 00005_notifications', () => { const afterMigration = await migrator.test({ migration: '00005_notifications', after: async (sql: postgres.Sql) => { - const [[device], [channel], [notificationType], [account]] = - await Promise.all([ - // Create device - sql< - [NotificationDevicesRow] - >`INSERT INTO notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, - // Get all notification channels - sql< - Array - >`SELECT * FROM notification_channels`, - // Get all notification types - sql>`SELECT * FROM notification_types`, - // Create account - sql< - [ - { - id: number; - }, - ] - >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`, - ]); + const [[device], [notificationType], [account]] = await Promise.all([ + // Create device + sql< + [PushNotificationDevicesRow] + >`INSERT INTO push_notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, + // Get all notification types + sql>`SELECT * FROM notification_types`, + // Create account + sql< + [ + { + id: number; + }, + ] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`, + ]); // Create subscription const [subscription] = await sql< [NotificationSubscriptionsRow] - >`INSERT INTO notification_subscriptions (account_id, device_id, chain_id, safe_address, notification_channel_id) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress}, ${channel.id}) RETURNING *`; + >`INSERT INTO notification_subscriptions (account_id, push_notification_device_id, chain_id, safe_address) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress}) RETURNING *`; // Subscribe to notification type const [subscribedNotificationType] = await sql< [NotificationSubscriptionNotificationTypesRow] @@ -780,31 +601,26 @@ describe('Migration 00005_notifications', () => { const afterMigration = await migrator.test({ migration: '00005_notifications', after: async (sql: postgres.Sql) => { - const [[device], [channel], [notificationType], [account]] = - await Promise.all([ - // Create device - sql< - [NotificationDevicesRow] - >`INSERT INTO notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, - // Get all notification channels - sql< - Array - >`SELECT * FROM notification_channels`, - // Get all notification types - sql>`SELECT * FROM notification_types`, - // Create account - sql< - [ - { - id: number; - }, - ] - >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`, - ]); + const [[device], [notificationType], [account]] = await Promise.all([ + // Create device + sql< + [PushNotificationDevicesRow] + >`INSERT INTO push_notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, + // Get all notification types + sql>`SELECT * FROM notification_types`, + // Create account + sql< + [ + { + id: number; + }, + ] + >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`, + ]); // Create subscription const [subscription] = await sql< [NotificationSubscriptionsRow] - >`INSERT INTO notification_subscriptions (account_id, device_id, chain_id, safe_address, notification_channel_id) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress}, ${channel.id}) RETURNING *`; + >`INSERT INTO notification_subscriptions (account_id, push_notification_device_id, chain_id, safe_address) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress}) RETURNING *`; // Subscribe to notification type return sql< [NotificationSubscriptionNotificationTypesRow] diff --git a/src/datasources/accounts/notifications/notifications.datasource.spec.ts b/src/datasources/accounts/notifications/notifications.datasource.spec.ts index a330352073..80467610a1 100644 --- a/src/datasources/accounts/notifications/notifications.datasource.spec.ts +++ b/src/datasources/accounts/notifications/notifications.datasource.spec.ts @@ -5,7 +5,6 @@ import { upsertSubscriptionsDtoBuilder } from '@/datasources/accounts/notificati import { NotificationsDatasource } from '@/datasources/accounts/notifications/notifications.datasource'; import { FakeCacheService } from '@/datasources/cache/__tests__/fake.cache.service'; import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; -import { NotificationChannel } from '@/domain/notifications/entities-v2/notification-channel.entity'; import { NotificationType } from '@/domain/notifications/entities-v2/notification-type.entity'; import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; import { ILoggingService } from '@/logging/logging.interface'; @@ -53,8 +52,8 @@ describe('NotificationsDatasource', () => { }); afterEach(async () => { - // Don't truncate notification_channels or notification_types as they have predefined rows - await sql`TRUNCATE TABLE accounts, notification_devices, notification_subscriptions, notification_subscription_notification_types RESTART IDENTITY CASCADE`; + // Don't truncate notification_types as it has predefined rows + await sql`TRUNCATE TABLE accounts, push_notification_devices, notification_subscriptions, notification_subscription_notification_types RESTART IDENTITY CASCADE`; }); afterAll(async () => { @@ -75,8 +74,7 @@ describe('NotificationsDatasource', () => { // Ensure correct database structure await Promise.all([ sql`SELECT * FROM accounts`, - sql`SELECT * FROM notification_devices`, - sql`SELECT * FROM notification_channels`, + sql`SELECT * FROM push_notification_devices`, sql`SELECT * FROM notification_types`, sql`SELECT * FROM notification_subscriptions`, sql`SELECT * FROM notification_subscription_notification_types`, @@ -84,7 +82,6 @@ describe('NotificationsDatasource', () => { ([ accounts, devices, - channels, types, subscriptions, subscribedNotifications, @@ -108,12 +105,6 @@ describe('NotificationsDatasource', () => { updated_at: expect.any(Date), }, ]); - expect(channels).toStrictEqual([ - { - id: 1, - name: NotificationChannel.PUSH_NOTIFICATIONS, - }, - ]); expect(types).toStrictEqual( Object.values(NotificationType).map((type) => { return { @@ -127,10 +118,9 @@ describe('NotificationsDatasource', () => { return { id: i + 1, account_id: accounts[0].id, - device_id: devices[0].id, + push_notification_device_id: devices[0].id, chain_id: safe.chainId, safe_address: safe.address, - notification_channel_id: 1, created_at: expect.any(Date), updated_at: expect.any(Date), }; @@ -154,29 +144,28 @@ describe('NotificationsDatasource', () => { ); }); - it('should always update the cloud messaging token', async () => { + it('should always update the deviceType/cloudMessagingToken', async () => { const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); - const newCloudMessagingToken = - upsertSubscriptionsDtoBuilder().build().cloudMessagingToken; + const secondSubscriptionsDto = upsertSubscriptionsDtoBuilder() + .with('account', upsertSubscriptionsDto.account) + .with('deviceUuid', upsertSubscriptionsDto.deviceUuid) + .build(); await accountsDatasource.createAccount(upsertSubscriptionsDto.account); await target.upsertSubscriptions(upsertSubscriptionsDto); // Insert should not throw despite it being the same device UUID await expect( - target.upsertSubscriptions({ - ...upsertSubscriptionsDto, - cloudMessagingToken: newCloudMessagingToken, - }), + target.upsertSubscriptions(secondSubscriptionsDto), ).resolves.not.toThrow(); // Device UUID should have updated await expect( - sql`SELECT * FROM notification_devices`, + sql`SELECT * FROM push_notification_devices`, ).resolves.toStrictEqual([ { id: 1, - device_type: upsertSubscriptionsDto.deviceType, + device_type: secondSubscriptionsDto.deviceType, device_uuid: expect.any(String), - cloud_messaging_token: newCloudMessagingToken, + cloud_messaging_token: secondSubscriptionsDto.cloudMessagingToken, created_at: expect.any(Date), updated_at: expect.any(Date), }, @@ -245,8 +234,7 @@ describe('NotificationsDatasource', () => { // Ensure correct database structure await Promise.all([ sql`SELECT * FROM accounts`, - sql`SELECT * FROM notification_devices`, - sql`SELECT * FROM notification_channels`, + sql`SELECT * FROM push_notification_devices`, sql`SELECT * FROM notification_types`, sql`SELECT * FROM notification_subscriptions`, sql`SELECT * FROM notification_subscription_notification_types`, @@ -254,7 +242,6 @@ describe('NotificationsDatasource', () => { ([ accounts, devices, - channels, types, subscriptions, subscribedNotifications, @@ -286,12 +273,6 @@ describe('NotificationsDatasource', () => { updated_at: expect.any(Date), }, ]); - expect(channels).toStrictEqual([ - { - id: 1, - name: NotificationChannel.PUSH_NOTIFICATIONS, - }, - ]); expect(types).toStrictEqual( Object.values(NotificationType).map((type) => { return { @@ -306,10 +287,9 @@ describe('NotificationsDatasource', () => { return { id: i + 1, account_id: accounts[0].id, - device_id: devices[0].id, + push_notification_device_id: devices[0].id, chain_id: safe.chainId, safe_address: safe.address, - notification_channel_id: 1, created_at: expect.any(Date), updated_at: expect.any(Date), }; @@ -319,10 +299,9 @@ describe('NotificationsDatasource', () => { return { id: upsertSubscriptionsDto.safes.length + i + 1, account_id: accounts[0].id, - device_id: devices[1].id, + push_notification_device_id: devices[1].id, chain_id: safe.chainId, safe_address: safe.address, - notification_channel_id: 1, created_at: expect.any(Date), updated_at: expect.any(Date), }; @@ -365,10 +344,10 @@ describe('NotificationsDatasource', () => { describe('getSafeSubscription', () => { it('should return a subscription for a Safe', async () => { const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); - const safe = upsertSubscriptionsDto.safes[0]; await accountsDatasource.createAccount(upsertSubscriptionsDto.account); await target.upsertSubscriptions(upsertSubscriptionsDto); + const safe = upsertSubscriptionsDto.safes[0]; await expect( target.getSafeSubscription({ account: upsertSubscriptionsDto.account, @@ -386,7 +365,6 @@ describe('NotificationsDatasource', () => { const secondUpsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() .with('safes', upsertSubscriptionsDto.safes) .build(); - const safe = upsertSubscriptionsDto.safes[0]; await accountsDatasource.createAccount(upsertSubscriptionsDto.account); await accountsDatasource.createAccount( secondUpsertSubscriptionsDto.account, @@ -394,6 +372,7 @@ describe('NotificationsDatasource', () => { await target.upsertSubscriptions(upsertSubscriptionsDto); await target.upsertSubscriptions(secondUpsertSubscriptionsDto); + const safe = upsertSubscriptionsDto.safes[0]; await expect( target.getSubscribersWithTokensBySafe({ chainId: safe.chainId, @@ -413,14 +392,24 @@ describe('NotificationsDatasource', () => { }); describe('deleteSubscription', () => { - it('should delete a subscription', async () => { - const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); - const safe = upsertSubscriptionsDto.safes[0]; + it('should delete a subscription and orphaned device', async () => { + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() + .with('safes', [ + { + chainId: faker.string.numeric(), + address: getAddress(faker.finance.ethereumAddress()), + notificationTypes: faker.helpers.arrayElements( + Object.values(NotificationType), + ), + }, + ]) + .build(); const account = await accountsDatasource.createAccount( upsertSubscriptionsDto.account, ); await target.upsertSubscriptions(upsertSubscriptionsDto); + const safe = upsertSubscriptionsDto.safes[0]; await target.deleteSubscription({ account: upsertSubscriptionsDto.account, deviceUuid: upsertSubscriptionsDto.deviceUuid!, @@ -431,6 +420,9 @@ describe('NotificationsDatasource', () => { await expect( sql`SELECT * FROM notification_subscriptions WHERE account_id = ${account.id} AND chain_id = ${safe.chainId} AND safe_address = ${safe.address}`, ).resolves.toStrictEqual([]); + await expect( + sql`SELECT * FROM push_notification_devices WHERE device_uuid = ${upsertSubscriptionsDto.deviceUuid!}`, + ).resolves.toStrictEqual([]); }); it('should not delete subscriptions of other device UUIDs', async () => { @@ -450,11 +442,11 @@ describe('NotificationsDatasource', () => { ...upsertSubscriptionsDto, deviceUuid: secondDeviceUuid, }; - const safe = upsertSubscriptionsDto.safes[0]; await accountsDatasource.createAccount(upsertSubscriptionsDto.account); await target.upsertSubscriptions(upsertSubscriptionsDto); await target.upsertSubscriptions(secondUpsertSubscriptionsDto); + const safe = upsertSubscriptionsDto.safes[0]; await target.deleteSubscription({ account: upsertSubscriptionsDto.account, deviceUuid: upsertSubscriptionsDto.deviceUuid!, @@ -469,10 +461,54 @@ describe('NotificationsDatasource', () => { { id: 2, account_id: 1, - device_id: 2, + push_notification_device_id: 2, chain_id: safe.chainId, safe_address: safe.address, - notification_channel_id: 1, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }, + ]); + }); + + it('should not delete devices with other subscriptions', async () => { + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() + .with('safes', [ + { + chainId: faker.string.numeric(), + address: getAddress(faker.finance.ethereumAddress()), + notificationTypes: faker.helpers.arrayElements( + Object.values(NotificationType), + ), + }, + { + chainId: faker.string.numeric(), + address: getAddress(faker.finance.ethereumAddress()), + notificationTypes: faker.helpers.arrayElements( + Object.values(NotificationType), + ), + }, + ]) + .build(); + await accountsDatasource.createAccount(upsertSubscriptionsDto.account); + await target.upsertSubscriptions(upsertSubscriptionsDto); + + const safe = upsertSubscriptionsDto.safes[0]; + await target.deleteSubscription({ + account: upsertSubscriptionsDto.account, + deviceUuid: upsertSubscriptionsDto.deviceUuid!, + chainId: safe.chainId, + safeAddress: safe.address, + }); + + // Device should not have been deleted + await expect( + sql`SELECT * FROM push_notification_devices`, + ).resolves.toStrictEqual([ + { + id: 1, + device_type: upsertSubscriptionsDto.deviceType, + device_uuid: upsertSubscriptionsDto.deviceUuid, + cloud_messaging_token: upsertSubscriptionsDto.cloudMessagingToken, created_at: expect.any(Date), updated_at: expect.any(Date), }, diff --git a/src/datasources/accounts/notifications/notifications.datasource.ts b/src/datasources/accounts/notifications/notifications.datasource.ts index 777dc74c54..e1c4213711 100644 --- a/src/datasources/accounts/notifications/notifications.datasource.ts +++ b/src/datasources/accounts/notifications/notifications.datasource.ts @@ -11,7 +11,6 @@ import { UnprocessableEntityException, } from '@nestjs/common'; import postgres from 'postgres'; -import { NotificationChannel as DomainNotificationChannel } from '@/domain/notifications/entities-v2/notification-channel.entity'; import { UpsertSubscriptionsDto } from '@/datasources/accounts/notifications/entities/upsert-subscriptions.dto.entity'; @Injectable() @@ -38,30 +37,23 @@ export class NotificationsDatasource implements INotificationsDatasource { * @returns Device UUID */ async upsertSubscriptions( - args: UpsertSubscriptionsDto, + upsertSubscriptionsDto: UpsertSubscriptionsDto, ): Promise<{ deviceUuid: Uuid }> { - const account = await this.accountsDatasource.getAccount(args.account); - const deviceUuid = args.deviceUuid ?? crypto.randomUUID(); + const account = await this.accountsDatasource.getAccount( + upsertSubscriptionsDto.account, + ); + const deviceUuid = upsertSubscriptionsDto.deviceUuid ?? crypto.randomUUID(); await this.sql.begin(async (sql) => { - // Get the push notifications channel - const [channel] = await sql<[{ id: number }]>` - SELECT id FROM notification_channels - WHERE name = ${DomainNotificationChannel.PUSH_NOTIFICATIONS} - `.catch((e) => { - const error = 'Error getting channel'; - this.loggingService.info(`${error}: ${asError(e).message}`); - throw new NotFoundException(error); - }); - - // Insert (or update the cloud messaging token of) a device + // Insert (or update the type/cloud messaging token of) a device const [device] = await sql<[{ id: number }]>` - INSERT INTO notification_devices (device_type, device_uuid, cloud_messaging_token) - VALUES (${args.deviceType}, ${deviceUuid}, ${args.cloudMessagingToken}) + INSERT INTO push_notification_devices (device_type, device_uuid, cloud_messaging_token) + VALUES (${upsertSubscriptionsDto.deviceType}, ${deviceUuid}, ${upsertSubscriptionsDto.cloudMessagingToken}) ON CONFLICT (device_uuid) DO UPDATE SET cloud_messaging_token = EXCLUDED.cloud_messaging_token, - -- Throws if updated_at is not set + device_type = EXCLUDED.device_type, + -- If updated_at is not set ON CONFLICT, an error is thrown meaning nothing is returned updated_at = NOW() RETURNING id `.catch((e) => { @@ -72,14 +64,14 @@ export class NotificationsDatasource implements INotificationsDatasource { // For each Safe, upsert the subscription and overwrite the subscribed-to notification types await Promise.all( - args.safes.map(async (safe) => { + upsertSubscriptionsDto.safes.map(async (safe) => { try { // 1. Upsert subscription const [subscription] = await sql<[{ id: number }]>` - INSERT INTO notification_subscriptions (account_id, chain_id, safe_address, device_id, notification_channel_id) - VALUES (${account.id}, ${safe.chainId}, ${safe.address}, ${device.id}, ${channel.id}) - ON CONFLICT (account_id, chain_id, safe_address, device_id, notification_channel_id) - -- A field must be set to return the id + INSERT INTO notification_subscriptions (account_id, chain_id, safe_address, push_notification_device_id) + VALUES (${account.id}, ${safe.chainId}, ${safe.address}, ${device.id}) + ON CONFLICT (account_id, chain_id, safe_address, push_notification_device_id) + -- If no value is set ON CONFLICT, an error is thrown meaning nothing is returned DO UPDATE SET updated_at = NOW() RETURNING id `; @@ -130,13 +122,13 @@ export class NotificationsDatasource implements INotificationsDatasource { >` SELECT nt.name FROM notification_subscriptions ns - JOIN notification_devices nd ON ns.device_id = nd.id + JOIN push_notification_devices pnd ON ns.push_notification_device_id = pnd.id JOIN notification_subscription_notification_types nsnt ON ns.id = nsnt.notification_subscription_id JOIN notification_types nt ON nsnt.notification_type_id = nt.id WHERE ns.account_id = ${account.id} AND ns.chain_id = ${args.chainId} AND ns.safe_address = ${args.safeAddress} - AND nd.device_uuid = ${args.deviceUuid} + AND pnd.device_uuid = ${args.deviceUuid} `.catch((e) => { const error = 'Error getting subscription or notification types'; this.loggingService.info(`${error}: ${asError(e).message}`); @@ -166,10 +158,10 @@ export class NotificationsDatasource implements INotificationsDatasource { const subscribers = await this.sql< Array<{ address: `0x${string}`; cloud_messaging_token: string }> >` - SELECT a.address, nd.cloud_messaging_token + SELECT a.address, pnd.cloud_messaging_token FROM notification_subscriptions ns JOIN accounts a ON ns.account_id = a.id - JOIN notification_devices nd ON ns.device_id = nd.id + JOIN push_notification_devices pnd ON ns.push_notification_device_id = pnd.id WHERE ns.chain_id = ${args.chainId} AND ns.safe_address = ${args.safeAddress} `.catch((e) => { @@ -200,19 +192,43 @@ export class NotificationsDatasource implements INotificationsDatasource { chainId: string; safeAddress: `0x${string}`; }): Promise { - await this.sql` - DELETE FROM notification_subscriptions ns - USING accounts a, notification_devices nd - WHERE ns.account_id = a.id - AND ns.device_id = nd.id - AND a.address = ${args.account} - AND nd.device_uuid = ${args.deviceUuid} - AND ns.chain_id = ${args.chainId} - AND ns.safe_address = ${args.safeAddress} - `.catch((e) => { - const error = 'Error deleting subscription'; - this.loggingService.info(`${error}: ${asError(e).message}`); - throw new UnprocessableEntityException(error); + await this.sql.begin(async (sql) => { + try { + // 1. Delete the subscription and return device ID + const [deletedSubscription] = await sql< + [{ push_notification_device_id: number }] + >` + DELETE FROM notification_subscriptions ns + USING accounts a, push_notification_devices pnd + WHERE ns.account_id = a.id + AND ns.push_notification_device_id = pnd.id + AND a.address = ${args.account} + AND pnd.device_uuid = ${args.deviceUuid} + AND ns.chain_id = ${args.chainId} + AND ns.safe_address = ${args.safeAddress} + RETURNING ns.push_notification_device_id; + `; + + // 2. Check if there any remaining subscriptions for device + const remainingSubscriptions = await sql` + SELECT 1 + FROM notification_subscriptions + WHERE push_notification_device_id = ${deletedSubscription.push_notification_device_id} + `; + + // 3. If no subscriptions, delete orphaned device + if (remainingSubscriptions.length === 0) { + // Note: we can't use this.deleteDevice here as we are in a transaction + await sql` + DELETE FROM push_notification_devices + WHERE device_uuid = ${args.deviceUuid} + `; + } + } catch (e) { + const error = 'Error deleting subscription'; + this.loggingService.info(`${error}: ${asError(e).message}`); + throw new NotFoundException(error); + } }); } @@ -223,7 +239,7 @@ export class NotificationsDatasource implements INotificationsDatasource { */ async deleteDevice(deviceUuid: Uuid): Promise { await this.sql` - DELETE FROM notification_devices + DELETE FROM push_notification_devices WHERE device_uuid = ${deviceUuid} `.catch((e) => { const error = 'Error deleting device'; diff --git a/src/domain/interfaces/notifications.datasource.interface.ts b/src/domain/interfaces/notifications.datasource.interface.ts index ba29ecdaa4..01b7886176 100644 --- a/src/domain/interfaces/notifications.datasource.interface.ts +++ b/src/domain/interfaces/notifications.datasource.interface.ts @@ -5,7 +5,7 @@ import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; export const INotificationsDatasource = Symbol('INotificationsDatasource'); export interface INotificationsDatasource { - upsertSubscriptions(args: UpsertSubscriptionsDto): Promise<{ + upsertSubscriptions(upsertSubscriptionsDto: UpsertSubscriptionsDto): Promise<{ deviceUuid: Uuid; }>; diff --git a/src/domain/notifications/entities-v2/notification-channel.entity.ts b/src/domain/notifications/entities-v2/notification-channel.entity.ts deleted file mode 100644 index 6523ad58b4..0000000000 --- a/src/domain/notifications/entities-v2/notification-channel.entity.ts +++ /dev/null @@ -1,3 +0,0 @@ -export enum NotificationChannel { - PUSH_NOTIFICATIONS = 'PUSH_NOTIFICATIONS', -} From e59bf0857c2c54cef6fddbf968ea4f3fcfee9617 Mon Sep 17 00:00:00 2001 From: iamacook Date: Mon, 22 Jul 2024 17:51:48 +0200 Subject: [PATCH 19/37] Add domain logic for push notifications --- .../test.notifications.datasource.module.ts | 23 + .../notifications.datasource.module.ts | 14 + .../notifications.datasource.spec.ts | 2 +- .../notifications/notifications.datasource.ts | 2 +- src/datasources/cache/cache.router.ts | 3 +- .../test.push-notifications-api.module.ts | 20 + .../push-notifications-api.module.ts | 3 +- .../transaction-api.service.spec.ts | 10 +- .../transaction-api.service.ts | 2 + .../hooks/hooks.repository.interface.ts | 2 + src/domain/hooks/hooks.repository.ts | 198 ++++- .../notifications.datasource.interface.ts | 12 +- .../push-notifications-api.interface.ts | 7 +- .../interfaces/transaction-api.interface.ts | 1 + ...upsert-subscriptions.dto.entity.builder.ts | 3 +- .../entities-v2/notification.entity.ts | 50 ++ .../upsert-subscriptions.dto.entity.ts | 1 - .../notifications.repository.v2.interface.ts | 64 ++ .../notifications.repository.v2.ts | 111 +++ src/domain/safe/safe.repository.interface.ts | 1 + src/domain/safe/safe.repository.ts | 1 + .../accounts/accounts.controller.spec.ts | 4 + src/routes/alerts/alerts.controller.spec.ts | 6 + src/routes/auth/auth.controller.spec.ts | 4 + .../zerion-balances.controller.spec.ts | 4 + .../balances/balances.controller.spec.ts | 4 + src/routes/chains/chains.controller.spec.ts | 4 + .../zerion-collectibles.controller.spec.ts | 4 + .../collectibles.controller.spec.ts | 4 + .../community/community.controller.spec.ts | 4 + .../contracts/contracts.controller.spec.ts | 4 + .../delegates/delegates.controller.spec.ts | 4 + .../v2/delegates.v2.controller.spec.ts | 4 + .../estimations.controller.spec.ts | 4 + src/routes/health/health.controller.spec.ts | 4 + .../deleted-multisig-transaction.schema.ts | 4 + .../schemas/executed-transaction.schema.ts | 4 + .../entities/schemas/incoming-ether.schema.ts | 2 + .../entities/schemas/incoming-token.schema.ts | 2 + .../schemas/message-created.schema.ts | 2 + .../schemas/module-transaction.schema.ts | 4 + .../schemas/pending-transaction.schema.ts | 4 + .../hooks/hooks-cache.controller.spec.ts | 43 +- src/routes/hooks/hooks-notifications.spec.ts | 698 ++++++++++++++++++ src/routes/hooks/hooks.controller.spec.ts | 95 +++ .../messages/messages.controller.spec.ts | 4 + .../notifications.controller.spec.ts | 4 + src/routes/owners/owners.controller.spec.ts | 4 + .../recovery/recovery.controller.spec.ts | 4 + src/routes/relay/relay.controller.spec.ts | 4 + src/routes/root/root.controller.spec.ts | 4 + .../safe-apps/safe-apps.controller.spec.ts | 4 + .../safes/safes.controller.nonces.spec.ts | 4 + .../safes/safes.controller.overview.spec.ts | 4 + src/routes/safes/safes.controller.spec.ts | 4 + ...ransaction.transactions.controller.spec.ts | 4 + ...tion-by-id.transactions.controller.spec.ts | 4 + ...rs-by-safe.transactions.controller.spec.ts | 4 + ...ns-by-safe.transactions.controller.spec.ts | 4 + ...ns-by-safe.transactions.controller.spec.ts | 4 + ...ransaction.transactions.controller.spec.ts | 4 + ...ransaction.transactions.controller.spec.ts | 4 + .../transactions-history.controller.spec.ts | 4 + ....imitation-transactions.controller.spec.ts | 4 + .../transactions-view.controller.spec.ts | 4 + 65 files changed, 1475 insertions(+), 51 deletions(-) create mode 100644 src/datasources/accounts/notifications/__tests__/test.notifications.datasource.module.ts create mode 100644 src/datasources/accounts/notifications/notifications.datasource.module.ts create mode 100644 src/datasources/push-notifications-api/__tests__/test.push-notifications-api.module.ts rename src/{datasources/accounts/notifications => domain/notifications/entities-v2}/__tests__/upsert-subscriptions.dto.entity.builder.ts (89%) create mode 100644 src/domain/notifications/entities-v2/notification.entity.ts rename src/{datasources/accounts/notifications/entities => domain/notifications/entities-v2}/upsert-subscriptions.dto.entity.ts (95%) create mode 100644 src/domain/notifications/notifications.repository.v2.interface.ts create mode 100644 src/domain/notifications/notifications.repository.v2.ts create mode 100644 src/routes/hooks/hooks-notifications.spec.ts create mode 100644 src/routes/hooks/hooks.controller.spec.ts diff --git a/src/datasources/accounts/notifications/__tests__/test.notifications.datasource.module.ts b/src/datasources/accounts/notifications/__tests__/test.notifications.datasource.module.ts new file mode 100644 index 0000000000..074c7b0c3f --- /dev/null +++ b/src/datasources/accounts/notifications/__tests__/test.notifications.datasource.module.ts @@ -0,0 +1,23 @@ +import { INotificationsDatasource } from '@/domain/interfaces/notifications.datasource.interface'; +import { Module } from '@nestjs/common'; + +const accountsDatasource: INotificationsDatasource = { + deleteDevice: jest.fn(), + deleteSubscription: jest.fn(), + getSafeSubscription: jest.fn(), + getSubscribersWithTokensBySafe: jest.fn(), + upsertSubscriptions: jest.fn(), +}; + +@Module({ + providers: [ + { + provide: INotificationsDatasource, + useFactory: (): jest.MockedObjectDeep => { + return jest.mocked(accountsDatasource); + }, + }, + ], + exports: [INotificationsDatasource], +}) +export class TestNotificationsDatasourceModule {} diff --git a/src/datasources/accounts/notifications/notifications.datasource.module.ts b/src/datasources/accounts/notifications/notifications.datasource.module.ts new file mode 100644 index 0000000000..88ae4012fa --- /dev/null +++ b/src/datasources/accounts/notifications/notifications.datasource.module.ts @@ -0,0 +1,14 @@ +import { AccountsDatasourceModule } from '@/datasources/accounts/accounts.datasource.module'; +import { NotificationsDatasource } from '@/datasources/accounts/notifications/notifications.datasource'; +import { PostgresDatabaseModule } from '@/datasources/db/postgres-database.module'; +import { INotificationsDatasource } from '@/domain/interfaces/notifications.datasource.interface'; +import { Module } from '@nestjs/common'; + +@Module({ + imports: [PostgresDatabaseModule, AccountsDatasourceModule], + providers: [ + { provide: INotificationsDatasource, useClass: NotificationsDatasource }, + ], + exports: [INotificationsDatasource], +}) +export class NotificationsDatasourceModule {} diff --git a/src/datasources/accounts/notifications/notifications.datasource.spec.ts b/src/datasources/accounts/notifications/notifications.datasource.spec.ts index 80467610a1..b1ae3866be 100644 --- a/src/datasources/accounts/notifications/notifications.datasource.spec.ts +++ b/src/datasources/accounts/notifications/notifications.datasource.spec.ts @@ -1,7 +1,7 @@ import { TestDbFactory } from '@/__tests__/db.factory'; import { IConfigurationService } from '@/config/configuration.service.interface'; import { AccountsDatasource } from '@/datasources/accounts/accounts.datasource'; -import { upsertSubscriptionsDtoBuilder } from '@/datasources/accounts/notifications/__tests__/upsert-subscriptions.dto.entity.builder'; +import { upsertSubscriptionsDtoBuilder } from '@/domain/notifications/entities-v2/__tests__/upsert-subscriptions.dto.entity.builder'; import { NotificationsDatasource } from '@/datasources/accounts/notifications/notifications.datasource'; import { FakeCacheService } from '@/datasources/cache/__tests__/fake.cache.service'; import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; diff --git a/src/datasources/accounts/notifications/notifications.datasource.ts b/src/datasources/accounts/notifications/notifications.datasource.ts index e1c4213711..54016b108b 100644 --- a/src/datasources/accounts/notifications/notifications.datasource.ts +++ b/src/datasources/accounts/notifications/notifications.datasource.ts @@ -11,7 +11,7 @@ import { UnprocessableEntityException, } from '@nestjs/common'; import postgres from 'postgres'; -import { UpsertSubscriptionsDto } from '@/datasources/accounts/notifications/entities/upsert-subscriptions.dto.entity'; +import { UpsertSubscriptionsDto } from '@/domain/notifications/entities-v2/upsert-subscriptions.dto.entity'; @Injectable() export class NotificationsDatasource implements INotificationsDatasource { diff --git a/src/datasources/cache/cache.router.ts b/src/datasources/cache/cache.router.ts index ef45c3a098..e22732edf7 100644 --- a/src/datasources/cache/cache.router.ts +++ b/src/datasources/cache/cache.router.ts @@ -266,12 +266,13 @@ export class CacheRouter { to?: string; value?: string; tokenAddress?: string; + txHash?: string; limit?: number; offset?: number; }): CacheDir { return new CacheDir( CacheRouter.getIncomingTransfersCacheKey(args), - `${args.executionDateGte}_${args.executionDateLte}_${args.to}_${args.value}_${args.tokenAddress}_${args.limit}_${args.offset}`, + `${args.executionDateGte}_${args.executionDateLte}_${args.to}_${args.value}_${args.tokenAddress}_${args.txHash}_${args.limit}_${args.offset}`, ); } diff --git a/src/datasources/push-notifications-api/__tests__/test.push-notifications-api.module.ts b/src/datasources/push-notifications-api/__tests__/test.push-notifications-api.module.ts new file mode 100644 index 0000000000..50b3da5639 --- /dev/null +++ b/src/datasources/push-notifications-api/__tests__/test.push-notifications-api.module.ts @@ -0,0 +1,20 @@ +import { Global, Module } from '@nestjs/common'; +import { IPushNotificationsApi } from '@/domain/interfaces/push-notifications-api.interface'; + +const mockPushNotificationsApi: IPushNotificationsApi = { + enqueueNotification: jest.fn(), +}; + +@Global() +@Module({ + providers: [ + { + provide: IPushNotificationsApi, + useFactory: (): jest.MockedObjectDeep => { + return jest.mocked(mockPushNotificationsApi); + }, + }, + ], + exports: [IPushNotificationsApi], +}) +export class TestPushNotificationsApiModule {} diff --git a/src/datasources/push-notifications-api/push-notifications-api.module.ts b/src/datasources/push-notifications-api/push-notifications-api.module.ts index dfa6e11e26..513c36b716 100644 --- a/src/datasources/push-notifications-api/push-notifications-api.module.ts +++ b/src/datasources/push-notifications-api/push-notifications-api.module.ts @@ -1,11 +1,12 @@ import { CacheModule } from '@/datasources/cache/cache.module'; import { HttpErrorFactory } from '@/datasources/errors/http-error-factory'; +import { JwtModule } from '@/datasources/jwt/jwt.module'; import { FirebaseCloudMessagingApiService } from '@/datasources/push-notifications-api/firebase-cloud-messaging-api.service'; import { IPushNotificationsApi } from '@/domain/interfaces/push-notifications-api.interface'; import { Module } from '@nestjs/common'; @Module({ - imports: [CacheModule], + imports: [CacheModule, JwtModule], providers: [ HttpErrorFactory, { diff --git a/src/datasources/transaction-api/transaction-api.service.spec.ts b/src/datasources/transaction-api/transaction-api.service.spec.ts index f3d765ebbd..a99d38652a 100644 --- a/src/datasources/transaction-api/transaction-api.service.spec.ts +++ b/src/datasources/transaction-api/transaction-api.service.spec.ts @@ -986,6 +986,7 @@ describe('TransactionApi', () => { const to = faker.finance.ethereumAddress(); const value = faker.string.numeric(); const tokenAddress = faker.finance.ethereumAddress(); + const txHash = faker.string.hexadecimal(); const limit = faker.number.int(); const offset = faker.number.int(); const incomingTransfer = erc20TransferBuilder() @@ -997,7 +998,7 @@ describe('TransactionApi', () => { const getIncomingTransfersUrl = `${baseUrl}/api/v1/safes/${safeAddress}/incoming-transfers/`; const cacheDir = new CacheDir( `${chainId}_incoming_transfers_${safeAddress}`, - `${executionDateGte}_${executionDateLte}_${to}_${value}_${tokenAddress}_${limit}_${offset}`, + `${executionDateGte}_${executionDateLte}_${to}_${value}_${tokenAddress}_${txHash}_${limit}_${offset}`, ); networkService.get.mockResolvedValueOnce({ status: 200, @@ -1013,6 +1014,7 @@ describe('TransactionApi', () => { tokenAddress, limit, offset, + txHash, }); expect(actual).toBe(actual); @@ -1031,6 +1033,7 @@ describe('TransactionApi', () => { token_address: tokenAddress, limit, offset, + transaction_hash: txHash, }, }, }); @@ -1047,6 +1050,7 @@ describe('TransactionApi', () => { const to = faker.finance.ethereumAddress(); const value = faker.string.numeric(); const tokenAddress = faker.finance.ethereumAddress(); + const txHash = faker.string.hexadecimal(); const limit = faker.number.int(); const offset = faker.number.int(); const getIncomingTransfersUrl = `${baseUrl}/api/v1/safes/${safeAddress}/incoming-transfers/`; @@ -1056,7 +1060,7 @@ describe('TransactionApi', () => { const expected = new DataSourceError(errorMessage, statusCode); const cacheDir = new CacheDir( `${chainId}_incoming_transfers_${safeAddress}`, - `${executionDateGte}_${executionDateLte}_${to}_${value}_${tokenAddress}_${limit}_${offset}`, + `${executionDateGte}_${executionDateLte}_${to}_${value}_${tokenAddress}_${txHash}_${limit}_${offset}`, ); mockDataSource.get.mockRejectedValueOnce( new NetworkResponseError( @@ -1076,6 +1080,7 @@ describe('TransactionApi', () => { to, value, tokenAddress, + txHash, limit, offset, }), @@ -1094,6 +1099,7 @@ describe('TransactionApi', () => { to, value, token_address: tokenAddress, + transaction_hash: txHash, limit, offset, }, diff --git a/src/datasources/transaction-api/transaction-api.service.ts b/src/datasources/transaction-api/transaction-api.service.ts index c6d02fdd85..0e8b946325 100644 --- a/src/datasources/transaction-api/transaction-api.service.ts +++ b/src/datasources/transaction-api/transaction-api.service.ts @@ -476,6 +476,7 @@ export class TransactionApi implements ITransactionApi { to?: string; value?: string; tokenAddress?: string; + txHash?: string; limit?: number; offset?: number; }): Promise> { @@ -498,6 +499,7 @@ export class TransactionApi implements ITransactionApi { token_address: args.tokenAddress, limit: args.limit, offset: args.offset, + transaction_hash: args.txHash, }, }, expireTimeSeconds: this.defaultExpirationTimeInSeconds, diff --git a/src/domain/hooks/hooks.repository.interface.ts b/src/domain/hooks/hooks.repository.interface.ts index e897eba17a..33857d9795 100644 --- a/src/domain/hooks/hooks.repository.interface.ts +++ b/src/domain/hooks/hooks.repository.interface.ts @@ -4,6 +4,7 @@ import { ChainsRepositoryModule } from '@/domain/chains/chains.repository.interf import { CollectiblesRepositoryModule } from '@/domain/collectibles/collectibles.repository.interface'; import { HooksRepository } from '@/domain/hooks/hooks.repository'; import { MessagesRepositoryModule } from '@/domain/messages/messages.repository.interface'; +import { NotificationsRepositoryV2Module } from '@/domain/notifications/notifications.repository.v2.interface'; import { QueuesRepositoryModule } from '@/domain/queues/queues-repository.interface'; import { SafeAppsRepositoryModule } from '@/domain/safe-apps/safe-apps.repository.interface'; import { SafeRepositoryModule } from '@/domain/safe/safe.repository.interface'; @@ -24,6 +25,7 @@ export interface IHooksRepository { ChainsRepositoryModule, CollectiblesRepositoryModule, MessagesRepositoryModule, + NotificationsRepositoryV2Module, SafeAppsRepositoryModule, SafeRepositoryModule, TransactionsRepositoryModule, diff --git a/src/domain/hooks/hooks.repository.ts b/src/domain/hooks/hooks.repository.ts index 6ad5a18ecb..7156c360cf 100644 --- a/src/domain/hooks/hooks.repository.ts +++ b/src/domain/hooks/hooks.repository.ts @@ -18,6 +18,22 @@ import { ConsumeMessage } from 'amqplib'; import { EventSchema } from '@/routes/hooks/entities/schemas/event.schema'; import { IBlockchainRepository } from '@/domain/blockchain/blockchain.repository.interface'; import { IHooksRepository } from '@/domain/hooks/hooks.repository.interface'; +import { INotificationsRepositoryV2 } from '@/domain/notifications/notifications.repository.v2.interface'; +import { DeletedMultisigTransactionEvent } from '@/routes/hooks/entities/schemas/deleted-multisig-transaction.schema'; +import { ExecutedTransactionEvent } from '@/routes/hooks/entities/schemas/executed-transaction.schema'; +import { IncomingEtherEvent } from '@/routes/hooks/entities/schemas/incoming-ether.schema'; +import { IncomingTokenEvent } from '@/routes/hooks/entities/schemas/incoming-token.schema'; +import { ModuleTransactionEvent } from '@/routes/hooks/entities/schemas/module-transaction.schema'; +import { PendingTransactionEvent } from '@/routes/hooks/entities/schemas/pending-transaction.schema'; +import { MessageCreatedEvent } from '@/routes/hooks/entities/schemas/message-created.schema'; +import { + IncomingEtherNotification, + IncomingTokenNotification, + ConfirmationRequestNotification, + MessageConfirmationNotification, + Notification, + NotificationType, +} from '@/domain/notifications/entities-v2/notification.entity'; @Injectable() export class HooksRepository implements IHooksRepository { @@ -47,6 +63,8 @@ export class HooksRepository implements IHooksRepository { private readonly queuesRepository: IQueuesRepository, @Inject(IConfigurationService) private readonly configurationService: IConfigurationService, + @Inject(INotificationsRepositoryV2) + private readonly notificationsRepository: INotificationsRepositoryV2, ) { this.queueName = this.configurationService.getOrThrow('amqp.queue'); } @@ -67,7 +85,10 @@ export class HooksRepository implements IHooksRepository { } async onEvent(event: Event): Promise { - return this.onEventClearCache(event).finally(() => { + return Promise.allSettled([ + this.onEventClearCache(event), + this.onEventEnqueueNotifications(event), + ]).finally(() => { this.onEventLog(event); }); } @@ -339,6 +360,181 @@ export class HooksRepository implements IHooksRepository { return Promise.all(promises); } + private async onEventEnqueueNotifications(event: Event): Promise { + if ( + // Don't notify about Config events + event.type === ConfigEventType.CHAIN_UPDATE || + event.type === ConfigEventType.SAFE_APPS_UPDATE || + // We already notify about executed multisig/module transactions + event.type === TransactionEventType.OUTGOING_ETHER || + event.type === TransactionEventType.OUTGOING_TOKEN || + // We only notify required confirmations on creation - see PENDING_MULTISIG_TRANSACTION + event.type === TransactionEventType.NEW_CONFIRMATION || + // We only notify required confirmations on required - see MESSAGE_CREATED + event.type === TransactionEventType.MESSAGE_CONFIRMATION || + // You cannot subscribe to Safes-to-be-created + event.type === TransactionEventType.SAFE_CREATED + ) { + return; + } + + const subscriptions = + await this.notificationsRepository.getSubscribersWithTokensBySafe({ + chainId: event.chainId, + safeAddress: event.address, + }); + + // Enqueue notifications for each subscriber relative to event + return await Promise.allSettled( + subscriptions.map(async (subscription) => { + const data = await this.mapEventNotification( + event, + subscription.subscriber, + ); + + if (!data) { + return; + } + + return this.notificationsRepository + .enqueueNotification(subscription.cloudMessagingToken, { + data, + }) + .then(() => { + this.loggingService.info('Notification sent successfully'); + }) + .catch((e) => { + this.loggingService.error( + `Failed to send notification: ${e.reason}`, + ); + }); + }), + ); + } + + private async mapEventNotification( + event: + | DeletedMultisigTransactionEvent + | ExecutedTransactionEvent + | IncomingEtherEvent + | IncomingTokenEvent + | ModuleTransactionEvent + | MessageCreatedEvent + | PendingTransactionEvent, + subscriber: `0x${string}`, + ): Promise { + if ( + event.type === TransactionEventType.INCOMING_ETHER || + event.type === TransactionEventType.INCOMING_TOKEN + ) { + return await this.mapIncomingAssetEventNotification(event); + } else if ( + event.type === TransactionEventType.PENDING_MULTISIG_TRANSACTION + ) { + return await this.mapPendingMultisigTransactionEventNotification( + event, + subscriber, + ); + } else if (event.type === TransactionEventType.MESSAGE_CREATED) { + return await this.mapMessageCreatedEventNotification(event, subscriber); + } else { + return event; + } + } + + private async mapIncomingAssetEventNotification( + event: IncomingEtherEvent | IncomingTokenEvent, + ): Promise { + const incomingTransfers = await this.safeRepository + .getIncomingTransfers({ + chainId: event.chainId, + safeAddress: event.address, + txHash: event.txHash, + }) + .catch(() => null); + + const transfer = incomingTransfers?.results?.find((result) => { + return result.transactionHash === event.txHash; + }); + + // Asset sent to self + if (transfer?.from === event.address) { + return null; + } + + return event; + } + + private async mapPendingMultisigTransactionEventNotification( + event: PendingTransactionEvent, + subscriber: `0x${string}`, + ): Promise { + const safe = await this.safeRepository.getSafe({ + chainId: event.chainId, + address: event.address, + }); + + // Transaction is confirmed and awaiting execution + if (safe.threshold === 1) { + return null; + } + + const transaction = await this.safeRepository.getMultiSigTransaction({ + chainId: event.chainId, + safeTransactionHash: event.safeTxHash, + }); + + const hasSubscriberSigned = transaction.confirmations?.some( + (confirmation) => { + return confirmation.owner === subscriber; + }, + ); + if (hasSubscriberSigned) { + return null; + } + + return { + type: NotificationType.CONFIRMATION_REQUEST, + chainId: event.chainId, + address: event.address, + safeTxHash: event.safeTxHash, + }; + } + + private async mapMessageCreatedEventNotification( + event: MessageCreatedEvent, + subscriber: `0x${string}`, + ): Promise { + const safe = await this.safeRepository.getSafe({ + chainId: event.chainId, + address: event.address, + }); + + // Message is valid + if (safe.threshold === 1) { + return null; + } + + const message = await this.messagesRepository.getMessageByHash({ + chainId: event.chainId, + messageHash: event.messageHash, + }); + + const hasSubscriberSigned = message.confirmations.some((confirmation) => { + return confirmation.owner === subscriber; + }); + if (hasSubscriberSigned) { + return null; + } + + return { + type: NotificationType.MESSAGE_CONFIRMATION_REQUEST, + chainId: event.chainId, + address: event.address, + messageHash: event.messageHash, + }; + } + private onEventLog(event: Event): void { switch (event.type) { case TransactionEventType.PENDING_MULTISIG_TRANSACTION: diff --git a/src/domain/interfaces/notifications.datasource.interface.ts b/src/domain/interfaces/notifications.datasource.interface.ts index 01b7886176..5ac4ecb97c 100644 --- a/src/domain/interfaces/notifications.datasource.interface.ts +++ b/src/domain/interfaces/notifications.datasource.interface.ts @@ -1,4 +1,4 @@ -import { UpsertSubscriptionsDto } from '@/datasources/accounts/notifications/entities/upsert-subscriptions.dto.entity'; +import { UpsertSubscriptionsDto } from '@/domain/notifications/entities-v2/upsert-subscriptions.dto.entity'; import { NotificationType } from '@/domain/notifications/entities-v2/notification-type.entity'; import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; @@ -26,6 +26,16 @@ export interface INotificationsDatasource { }> >; + getSubscribersWithTokensBySafe(args: { + chainId: string; + safeAddress: `0x${string}`; + }): Promise< + Array<{ + subscriber: `0x${string}`; + cloudMessagingToken: string; + }> + >; + deleteSubscription(args: { account: `0x${string}`; deviceUuid: Uuid; diff --git a/src/domain/interfaces/push-notifications-api.interface.ts b/src/domain/interfaces/push-notifications-api.interface.ts index 3877cf0819..b73aa4851a 100644 --- a/src/domain/interfaces/push-notifications-api.interface.ts +++ b/src/domain/interfaces/push-notifications-api.interface.ts @@ -1,5 +1,10 @@ +import { FirebaseNotification } from '@/datasources/push-notifications-api/entities/firebase-notification.entity'; + export const IPushNotificationsApi = Symbol('IPushNotificationsApi'); export interface IPushNotificationsApi { - enqueueNotification(token: string, notification: unknown): Promise; + enqueueNotification( + token: string, + notification: FirebaseNotification, + ): Promise; } diff --git a/src/domain/interfaces/transaction-api.interface.ts b/src/domain/interfaces/transaction-api.interface.ts index 466fdd71f4..3cb8de3cb0 100644 --- a/src/domain/interfaces/transaction-api.interface.ts +++ b/src/domain/interfaces/transaction-api.interface.ts @@ -111,6 +111,7 @@ export interface ITransactionApi { to?: string; value?: string; tokenAddress?: string; + txHash?: string; limit?: number; offset?: number; }): Promise>; diff --git a/src/datasources/accounts/notifications/__tests__/upsert-subscriptions.dto.entity.builder.ts b/src/domain/notifications/entities-v2/__tests__/upsert-subscriptions.dto.entity.builder.ts similarity index 89% rename from src/datasources/accounts/notifications/__tests__/upsert-subscriptions.dto.entity.builder.ts rename to src/domain/notifications/entities-v2/__tests__/upsert-subscriptions.dto.entity.builder.ts index 4be052cd09..c304be1bd1 100644 --- a/src/datasources/accounts/notifications/__tests__/upsert-subscriptions.dto.entity.builder.ts +++ b/src/domain/notifications/entities-v2/__tests__/upsert-subscriptions.dto.entity.builder.ts @@ -1,12 +1,11 @@ import { faker } from '@faker-js/faker'; import { Builder, IBuilder } from '@/__tests__/builder'; import { getAddress } from 'viem'; -import { UpsertSubscriptionsDto } from '@/datasources/accounts/notifications/entities/upsert-subscriptions.dto.entity'; +import { UpsertSubscriptionsDto } from '@/domain/notifications/entities-v2/upsert-subscriptions.dto.entity'; import { DeviceType } from '@/domain/notifications/entities-v2/device-type.entity'; import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; import { NotificationType } from '@/domain/notifications/entities-v2/notification-type.entity'; -// TODO: Move to domain export function upsertSubscriptionsDtoBuilder(): IBuilder { return new Builder() .with('account', getAddress(faker.finance.ethereumAddress())) diff --git a/src/domain/notifications/entities-v2/notification.entity.ts b/src/domain/notifications/entities-v2/notification.entity.ts new file mode 100644 index 0000000000..cc9b4dd321 --- /dev/null +++ b/src/domain/notifications/entities-v2/notification.entity.ts @@ -0,0 +1,50 @@ +import { TransactionEventType } from '@/routes/hooks/entities/event-type.entity'; +import { DeletedMultisigTransactionEvent } from '@/routes/hooks/entities/schemas/deleted-multisig-transaction.schema'; +import { ExecutedTransactionEvent } from '@/routes/hooks/entities/schemas/executed-transaction.schema'; +import { IncomingEtherEvent } from '@/routes/hooks/entities/schemas/incoming-ether.schema'; +import { IncomingTokenEvent } from '@/routes/hooks/entities/schemas/incoming-token.schema'; +import { MessageCreatedEvent } from '@/routes/hooks/entities/schemas/message-created.schema'; +import { ModuleTransactionEvent } from '@/routes/hooks/entities/schemas/module-transaction.schema'; +import { PendingTransactionEvent } from '@/routes/hooks/entities/schemas/pending-transaction.schema'; + +export enum NotificationType { + CONFIRMATION_REQUEST = 'CONFIRMATION_REQUEST', // TransactionEventType.PENDING_MULTISIG_TRANSACTION + DELETED_MULTISIG_TRANSACTION = TransactionEventType.DELETED_MULTISIG_TRANSACTION, + EXECUTED_MULTISIG_TRANSACTION = TransactionEventType.EXECUTED_MULTISIG_TRANSACTION, + INCOMING_ETHER = TransactionEventType.INCOMING_ETHER, + INCOMING_TOKEN = TransactionEventType.INCOMING_TOKEN, + MODULE_TRANSACTION = TransactionEventType.MODULE_TRANSACTION, + MESSAGE_CONFIRMATION_REQUEST = 'MESSAGE_CONFIRMATION_REQUEST', // TransactionEventType.MESSAGE_CREATED +} + +export type ConfirmationRequestNotification = Omit< + PendingTransactionEvent, + 'type' +> & { type: NotificationType.CONFIRMATION_REQUEST }; + +export type DeletedMultisigTransactionNotification = + DeletedMultisigTransactionEvent; + +export type ExecutedMultisigTransactionNotification = ExecutedTransactionEvent; + +export type IncomingEtherNotification = IncomingEtherEvent; + +export type IncomingTokenNotification = IncomingTokenEvent; + +export type ModuleTransactionNotification = ModuleTransactionEvent; + +export type MessageConfirmationNotification = Omit< + MessageCreatedEvent, + 'type' +> & { + type: NotificationType.MESSAGE_CONFIRMATION_REQUEST; +}; + +export type Notification = + | ConfirmationRequestNotification + | DeletedMultisigTransactionNotification + | ExecutedMultisigTransactionNotification + | IncomingEtherNotification + | IncomingTokenNotification + | ModuleTransactionNotification + | MessageConfirmationNotification; diff --git a/src/datasources/accounts/notifications/entities/upsert-subscriptions.dto.entity.ts b/src/domain/notifications/entities-v2/upsert-subscriptions.dto.entity.ts similarity index 95% rename from src/datasources/accounts/notifications/entities/upsert-subscriptions.dto.entity.ts rename to src/domain/notifications/entities-v2/upsert-subscriptions.dto.entity.ts index c30e07ecd4..8abfbf2295 100644 --- a/src/datasources/accounts/notifications/entities/upsert-subscriptions.dto.entity.ts +++ b/src/domain/notifications/entities-v2/upsert-subscriptions.dto.entity.ts @@ -2,7 +2,6 @@ import { DeviceType } from '@/domain/notifications/entities-v2/device-type.entit import { NotificationType } from '@/domain/notifications/entities-v2/notification-type.entity'; import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; -// TODO: Move to domain export type UpsertSubscriptionsDto = { account: `0x${string}`; cloudMessagingToken: string; diff --git a/src/domain/notifications/notifications.repository.v2.interface.ts b/src/domain/notifications/notifications.repository.v2.interface.ts new file mode 100644 index 0000000000..1806352787 --- /dev/null +++ b/src/domain/notifications/notifications.repository.v2.interface.ts @@ -0,0 +1,64 @@ +import { UpsertSubscriptionsDto } from '@/domain/notifications/entities-v2/upsert-subscriptions.dto.entity'; +import { FirebaseNotification } from '@/datasources/push-notifications-api/entities/firebase-notification.entity'; +import { PushNotificationsApiModule } from '@/datasources/push-notifications-api/push-notifications-api.module'; +import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; +import { NotificationsRepositoryV2 } from '@/domain/notifications/notifications.repository.v2'; +import { Module } from '@nestjs/common'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; +import { SafeRepositoryModule } from '@/domain/safe/safe.repository.interface'; +import { DelegatesV2RepositoryModule } from '@/domain/delegate/v2/delegates.v2.repository.interface'; + +export const INotificationsRepositoryV2 = Symbol('INotificationsRepositoryV2'); + +export interface INotificationsRepositoryV2 { + enqueueNotification( + token: string, + notification: FirebaseNotification, + ): Promise; + + upsertSubscriptions(args: UpsertSubscriptionsDto): Promise<{ + deviceUuid: Uuid; + }>; + + getSafeSubscription(args: { + account: `0x${string}`; + deviceUuid: Uuid; + chainId: string; + safeAddress: `0x${string}`; + }): Promise; + + getSubscribersWithTokensBySafe(args: { + chainId: string; + safeAddress: `0x${string}`; + }): Promise< + Array<{ + subscriber: `0x${string}`; + cloudMessagingToken: string; + }> + >; + + deleteSubscription(args: { + account: `0x${string}`; + chainId: string; + safeAddress: `0x${string}`; + }): Promise; + + deleteDevice(deviceUuid: Uuid): Promise; +} + +@Module({ + imports: [ + PushNotificationsApiModule, + NotificationsDatasourceModule, + SafeRepositoryModule, + DelegatesV2RepositoryModule, + ], + providers: [ + { + provide: INotificationsRepositoryV2, + useClass: NotificationsRepositoryV2, + }, + ], + exports: [INotificationsRepositoryV2], +}) +export class NotificationsRepositoryV2Module {} diff --git a/src/domain/notifications/notifications.repository.v2.ts b/src/domain/notifications/notifications.repository.v2.ts new file mode 100644 index 0000000000..5b161a5e98 --- /dev/null +++ b/src/domain/notifications/notifications.repository.v2.ts @@ -0,0 +1,111 @@ +import { UpsertSubscriptionsDto } from '@/domain/notifications/entities-v2/upsert-subscriptions.dto.entity'; +import { FirebaseNotification } from '@/datasources/push-notifications-api/entities/firebase-notification.entity'; +import { INotificationsDatasource } from '@/domain/interfaces/notifications.datasource.interface'; +import { IPushNotificationsApi } from '@/domain/interfaces/push-notifications-api.interface'; +import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; +import { INotificationsRepositoryV2 } from '@/domain/notifications/notifications.repository.v2.interface'; +import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; +import { ISafeRepository } from '@/domain/safe/safe.repository.interface'; +import { IDelegatesV2Repository } from '@/domain/delegate/v2/delegates.v2.repository.interface'; + +@Injectable() +export class NotificationsRepositoryV2 implements INotificationsRepositoryV2 { + constructor( + @Inject(IPushNotificationsApi) + private readonly pushNotificationsApi: IPushNotificationsApi, + @Inject(INotificationsDatasource) + private readonly notificationsDatasource: INotificationsDatasource, + @Inject(ISafeRepository) + private readonly safeRepository: ISafeRepository, + @Inject(IDelegatesV2Repository) + private readonly delegatesRepository: IDelegatesV2Repository, + ) {} + + enqueueNotification( + token: string, + notification: FirebaseNotification, + ): Promise { + return this.pushNotificationsApi.enqueueNotification(token, notification); + } + + async upsertSubscriptions(args: UpsertSubscriptionsDto): Promise<{ + deviceUuid: Uuid; + }> { + const authorizedSafesToSubscribe: UpsertSubscriptionsDto['safes'] = []; + + // Only allow owners or delegates to subscribe to notifications + // We don't Promise.all getSafe/getDelegates to prevent unnecessary calls + for (const safeToSubscribe of args.safes) { + const safe = await this.safeRepository + .getSafe({ + chainId: safeToSubscribe.chainId, + address: safeToSubscribe.address, + }) + .catch(() => null); + + // Upsert owner + if (safe && safe.owners.includes(args.account)) { + authorizedSafesToSubscribe.push(safeToSubscribe); + continue; + } + + const delegates = await this.delegatesRepository + .getDelegates({ + chainId: safeToSubscribe.chainId, + safeAddress: safeToSubscribe.address, + delegate: args.account, + }) + .catch(() => null); + + // Upsert delegate + if ( + delegates && + delegates.results.some(({ delegate }) => delegate === args.account) + ) { + authorizedSafesToSubscribe.push(safeToSubscribe); + } + } + + if (authorizedSafesToSubscribe.length === 0) { + throw new UnauthorizedException(); + } + + return this.notificationsDatasource.upsertSubscriptions({ + ...args, + safes: authorizedSafesToSubscribe, + }); + } + + getSafeSubscription(args: { + account: `0x${string}`; + deviceUuid: Uuid; + chainId: string; + safeAddress: `0x${string}`; + }): Promise { + return this.notificationsDatasource.getSafeSubscription(args); + } + + getSubscribersWithTokensBySafe(args: { + chainId: string; + safeAddress: `0x${string}`; + }): Promise< + Array<{ + subscriber: `0x${string}`; + cloudMessagingToken: string; + }> + > { + return this.notificationsDatasource.getSubscribersWithTokensBySafe(args); + } + + deleteSubscription(args: { + account: `0x${string}`; + chainId: string; + safeAddress: `0x${string}`; + }): Promise { + return this.notificationsDatasource.deleteSubscription(args); + } + + deleteDevice(deviceUuid: Uuid): Promise { + return this.notificationsDatasource.deleteDevice(deviceUuid); + } +} diff --git a/src/domain/safe/safe.repository.interface.ts b/src/domain/safe/safe.repository.interface.ts index 9f0d4f384d..0909ab80df 100644 --- a/src/domain/safe/safe.repository.interface.ts +++ b/src/domain/safe/safe.repository.interface.ts @@ -47,6 +47,7 @@ export interface ISafeRepository { to?: string; value?: string; tokenAddress?: string; + txHash?: string; limit?: number; offset?: number; }): Promise>; diff --git a/src/domain/safe/safe.repository.ts b/src/domain/safe/safe.repository.ts index 213d219650..9f69a2637f 100644 --- a/src/domain/safe/safe.repository.ts +++ b/src/domain/safe/safe.repository.ts @@ -132,6 +132,7 @@ export class SafeRepository implements ISafeRepository { to?: string; value?: string; tokenAddress?: string; + txHash?: string; limit?: number; offset?: number; }): Promise> { diff --git a/src/routes/accounts/accounts.controller.spec.ts b/src/routes/accounts/accounts.controller.spec.ts index 8dbaf22d82..41e2d8cb63 100644 --- a/src/routes/accounts/accounts.controller.spec.ts +++ b/src/routes/accounts/accounts.controller.spec.ts @@ -3,6 +3,8 @@ import { AppModule } from '@/app.module'; import configuration from '@/config/entities/__tests__/configuration'; import { TestAccountsDataSourceModule } from '@/datasources/accounts/__tests__/test.accounts.datasource.module'; import { AccountsDatasourceModule } from '@/datasources/accounts/accounts.datasource.module'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; import { CacheModule } from '@/datasources/cache/cache.module'; import jwtConfiguration from '@/datasources/jwt/configuration/__tests__/jwt.configuration'; @@ -68,6 +70,8 @@ describe('AccountsController', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) .compile(); jwtService = moduleFixture.get(IJwtService); accountDataSource = moduleFixture.get(IAccountsDatasource); diff --git a/src/routes/alerts/alerts.controller.spec.ts b/src/routes/alerts/alerts.controller.spec.ts index e8c9821592..2ae9b36ae0 100644 --- a/src/routes/alerts/alerts.controller.spec.ts +++ b/src/routes/alerts/alerts.controller.spec.ts @@ -55,6 +55,8 @@ import { import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; // The `x-tenderly-signature` header contains a cryptographic signature. The webhook request signature is // a HMAC SHA256 hash of concatenated signing secret, request payload, and timestamp, in this order. @@ -118,6 +120,8 @@ describe('Alerts (Unit)', () => { .useModule(TestEmailApiModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) .compile(); configurationService = moduleFixture.get(IConfigurationService); @@ -885,6 +889,8 @@ describe('Alerts (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) .compile(); app = moduleFixture.createNestApplication(); diff --git a/src/routes/auth/auth.controller.spec.ts b/src/routes/auth/auth.controller.spec.ts index beebacb6f0..fb18f7150b 100644 --- a/src/routes/auth/auth.controller.spec.ts +++ b/src/routes/auth/auth.controller.spec.ts @@ -35,6 +35,8 @@ import { IBlockchainApiManager, } from '@/domain/interfaces/blockchain-api.manager.interface'; import { TestBlockchainApiManagerModule } from '@/datasources/blockchain/__tests__/test.blockchain-api.manager'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; const verifySiweMessageMock = jest.fn(); @@ -62,6 +64,8 @@ describe('AuthController', () => { .useModule(TestEmailApiModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) .compile(); cacheService = moduleFixture.get(CacheService); diff --git a/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts b/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts index 33cc158f46..c8bfff58e1 100644 --- a/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts +++ b/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts @@ -34,6 +34,8 @@ import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; import { sample } from 'lodash'; import { balancesProviderBuilder } from '@/domain/chains/entities/__tests__/balances-provider.builder'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Balances Controller (Unit)', () => { let app: INestApplication; @@ -78,6 +80,8 @@ describe('Balances Controller (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) .compile(); configurationService = moduleFixture.get(IConfigurationService); diff --git a/src/routes/balances/balances.controller.spec.ts b/src/routes/balances/balances.controller.spec.ts index 87d1b52804..272bd710a8 100644 --- a/src/routes/balances/balances.controller.spec.ts +++ b/src/routes/balances/balances.controller.spec.ts @@ -2,6 +2,8 @@ import { TestAppProvider } from '@/__tests__/test-app.provider'; import { AppModule } from '@/app.module'; import { IConfigurationService } from '@/config/configuration.service.interface'; import configuration from '@/config/entities/__tests__/configuration'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; import { CacheModule } from '@/datasources/cache/cache.module'; import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; @@ -57,6 +59,8 @@ describe('Balances Controller (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/chains/chains.controller.spec.ts b/src/routes/chains/chains.controller.spec.ts index a97950a17f..b067126a35 100644 --- a/src/routes/chains/chains.controller.spec.ts +++ b/src/routes/chains/chains.controller.spec.ts @@ -30,6 +30,8 @@ import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Chains Controller (Unit)', () => { let app: INestApplication; @@ -64,6 +66,8 @@ describe('Chains Controller (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/collectibles/__tests__/controllers/zerion-collectibles.controller.spec.ts b/src/routes/collectibles/__tests__/controllers/zerion-collectibles.controller.spec.ts index 30bf63a5b5..29ec0ccfd7 100644 --- a/src/routes/collectibles/__tests__/controllers/zerion-collectibles.controller.spec.ts +++ b/src/routes/collectibles/__tests__/controllers/zerion-collectibles.controller.spec.ts @@ -28,6 +28,8 @@ import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues- import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; import { balancesProviderBuilder } from '@/domain/chains/entities/__tests__/balances-provider.builder'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Zerion Collectibles Controller', () => { let app: INestApplication; @@ -50,6 +52,8 @@ describe('Zerion Collectibles Controller', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/collectibles/collectibles.controller.spec.ts b/src/routes/collectibles/collectibles.controller.spec.ts index c5a27bc2a7..1268cbfba0 100644 --- a/src/routes/collectibles/collectibles.controller.spec.ts +++ b/src/routes/collectibles/collectibles.controller.spec.ts @@ -33,6 +33,8 @@ import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; import { getAddress } from 'viem'; import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Collectibles Controller (Unit)', () => { let app: INestApplication; @@ -62,6 +64,8 @@ describe('Collectibles Controller (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/community/community.controller.spec.ts b/src/routes/community/community.controller.spec.ts index f5769c82e3..8983de9b43 100644 --- a/src/routes/community/community.controller.spec.ts +++ b/src/routes/community/community.controller.spec.ts @@ -42,6 +42,8 @@ import { campaignActivityBuilder, toJson as campaignActivityToJson, } from '@/domain/community/entities/__tests__/campaign-activity.builder'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Community (Unit)', () => { let app: INestApplication; @@ -62,6 +64,8 @@ describe('Community (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/contracts/contracts.controller.spec.ts b/src/routes/contracts/contracts.controller.spec.ts index 7df2881c47..9da239c499 100644 --- a/src/routes/contracts/contracts.controller.spec.ts +++ b/src/routes/contracts/contracts.controller.spec.ts @@ -21,6 +21,8 @@ import { NetworkResponseError } from '@/datasources/network/entities/network.err import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Contracts controller', () => { let app: INestApplication; @@ -41,6 +43,8 @@ describe('Contracts controller', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/delegates/delegates.controller.spec.ts b/src/routes/delegates/delegates.controller.spec.ts index 6580ca3d4e..003e03d100 100644 --- a/src/routes/delegates/delegates.controller.spec.ts +++ b/src/routes/delegates/delegates.controller.spec.ts @@ -28,6 +28,8 @@ import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; describe('Delegates controller', () => { let app: INestApplication; @@ -48,6 +50,8 @@ describe('Delegates controller', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/delegates/v2/delegates.v2.controller.spec.ts b/src/routes/delegates/v2/delegates.v2.controller.spec.ts index 4b18bd6d31..42e66d7c2f 100644 --- a/src/routes/delegates/v2/delegates.v2.controller.spec.ts +++ b/src/routes/delegates/v2/delegates.v2.controller.spec.ts @@ -2,6 +2,8 @@ import { TestAppProvider } from '@/__tests__/test-app.provider'; import { AppModule } from '@/app.module'; import { IConfigurationService } from '@/config/configuration.service.interface'; import configuration from '@/config/entities/__tests__/configuration'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; import { CacheModule } from '@/datasources/cache/cache.module'; import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; @@ -56,6 +58,8 @@ describe('Delegates controller', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/estimations/estimations.controller.spec.ts b/src/routes/estimations/estimations.controller.spec.ts index 10fd962de6..e3a6fe3cae 100644 --- a/src/routes/estimations/estimations.controller.spec.ts +++ b/src/routes/estimations/estimations.controller.spec.ts @@ -29,6 +29,8 @@ import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues- import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; import { getAddress } from 'viem'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Estimations Controller (Unit)', () => { let app: INestApplication; @@ -49,6 +51,8 @@ describe('Estimations Controller (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/health/health.controller.spec.ts b/src/routes/health/health.controller.spec.ts index c9c511bdff..e9c9846628 100644 --- a/src/routes/health/health.controller.spec.ts +++ b/src/routes/health/health.controller.spec.ts @@ -19,6 +19,8 @@ import { QueueReadiness, } from '@/domain/interfaces/queue-readiness.interface'; import { Server } from 'net'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Health Controller tests', () => { let app: INestApplication; @@ -39,6 +41,8 @@ describe('Health Controller tests', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) .compile(); app = await new TestAppProvider().provide(moduleFixture); diff --git a/src/routes/hooks/entities/schemas/deleted-multisig-transaction.schema.ts b/src/routes/hooks/entities/schemas/deleted-multisig-transaction.schema.ts index 74b6358ca1..3a4f8ea331 100644 --- a/src/routes/hooks/entities/schemas/deleted-multisig-transaction.schema.ts +++ b/src/routes/hooks/entities/schemas/deleted-multisig-transaction.schema.ts @@ -8,3 +8,7 @@ export const DeletedMultisigTransactionEventSchema = z.object({ chainId: z.string(), safeTxHash: z.string(), }); + +export type DeletedMultisigTransactionEvent = z.infer< + typeof DeletedMultisigTransactionEventSchema +>; diff --git a/src/routes/hooks/entities/schemas/executed-transaction.schema.ts b/src/routes/hooks/entities/schemas/executed-transaction.schema.ts index a3aedfd705..7a4f72b807 100644 --- a/src/routes/hooks/entities/schemas/executed-transaction.schema.ts +++ b/src/routes/hooks/entities/schemas/executed-transaction.schema.ts @@ -9,3 +9,7 @@ export const ExecutedTransactionEventSchema = z.object({ safeTxHash: z.string(), txHash: z.string(), }); + +export type ExecutedTransactionEvent = z.infer< + typeof ExecutedTransactionEventSchema +>; diff --git a/src/routes/hooks/entities/schemas/incoming-ether.schema.ts b/src/routes/hooks/entities/schemas/incoming-ether.schema.ts index 3368109653..8f89721109 100644 --- a/src/routes/hooks/entities/schemas/incoming-ether.schema.ts +++ b/src/routes/hooks/entities/schemas/incoming-ether.schema.ts @@ -9,3 +9,5 @@ export const IncomingEtherEventSchema = z.object({ txHash: z.string(), value: z.string(), }); + +export type IncomingEtherEvent = z.infer; diff --git a/src/routes/hooks/entities/schemas/incoming-token.schema.ts b/src/routes/hooks/entities/schemas/incoming-token.schema.ts index ab27034233..07fe6d283a 100644 --- a/src/routes/hooks/entities/schemas/incoming-token.schema.ts +++ b/src/routes/hooks/entities/schemas/incoming-token.schema.ts @@ -9,3 +9,5 @@ export const IncomingTokenEventSchema = z.object({ tokenAddress: AddressSchema, txHash: z.string(), }); + +export type IncomingTokenEvent = z.infer; diff --git a/src/routes/hooks/entities/schemas/message-created.schema.ts b/src/routes/hooks/entities/schemas/message-created.schema.ts index 284be87556..8d79876e13 100644 --- a/src/routes/hooks/entities/schemas/message-created.schema.ts +++ b/src/routes/hooks/entities/schemas/message-created.schema.ts @@ -8,3 +8,5 @@ export const MessageCreatedEventSchema = z.object({ chainId: z.string(), messageHash: z.string(), }); + +export type MessageCreatedEvent = z.infer; diff --git a/src/routes/hooks/entities/schemas/module-transaction.schema.ts b/src/routes/hooks/entities/schemas/module-transaction.schema.ts index 8d03cace54..d9e47d4309 100644 --- a/src/routes/hooks/entities/schemas/module-transaction.schema.ts +++ b/src/routes/hooks/entities/schemas/module-transaction.schema.ts @@ -9,3 +9,7 @@ export const ModuleTransactionEventSchema = z.object({ module: AddressSchema, txHash: z.string(), }); + +export type ModuleTransactionEvent = z.infer< + typeof ModuleTransactionEventSchema +>; diff --git a/src/routes/hooks/entities/schemas/pending-transaction.schema.ts b/src/routes/hooks/entities/schemas/pending-transaction.schema.ts index 026dbe9c46..ef886c07ac 100644 --- a/src/routes/hooks/entities/schemas/pending-transaction.schema.ts +++ b/src/routes/hooks/entities/schemas/pending-transaction.schema.ts @@ -8,3 +8,7 @@ export const PendingTransactionEventSchema = z.object({ chainId: z.string(), safeTxHash: z.string(), }); + +export type PendingTransactionEvent = z.infer< + typeof PendingTransactionEventSchema +>; diff --git a/src/routes/hooks/hooks-cache.controller.spec.ts b/src/routes/hooks/hooks-cache.controller.spec.ts index 2b7627494d..c07f077ab1 100644 --- a/src/routes/hooks/hooks-cache.controller.spec.ts +++ b/src/routes/hooks/hooks-cache.controller.spec.ts @@ -28,6 +28,8 @@ import { IBlockchainApiManager } from '@/domain/interfaces/blockchain-api.manage import { safeCreatedEventBuilder } from '@/routes/hooks/entities/__tests__/safe-created.build'; import { ITransactionApiManager } from '@/domain/interfaces/transaction-api.manager.interface'; import { IBalancesApiManager } from '@/domain/interfaces/balances-api.manager.interface'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Post Hook Events for Cache (Unit)', () => { let app: INestApplication; @@ -52,6 +54,8 @@ describe('Post Hook Events for Cache (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) .compile(); app = moduleFixture.createNestApplication(); @@ -78,45 +82,6 @@ describe('Post Hook Events for Cache (Unit)', () => { await app.close(); }); - it('should return 410 if the eventsQueue FF is active and the hook is not CHAIN_UPDATE or SAFE_APPS_UPDATE', async () => { - const defaultConfiguration = configuration(); - const testConfiguration = (): typeof defaultConfiguration => ({ - ...defaultConfiguration, - features: { - ...defaultConfiguration.features, - eventsQueue: true, - }, - }); - - await initApp(testConfiguration); - - const payload = { - type: 'INCOMING_TOKEN', - tokenAddress: faker.finance.ethereumAddress(), - txHash: faker.string.hexadecimal({ length: 32 }), - }; - const safeAddress = faker.finance.ethereumAddress(); - const chainId = faker.string.numeric(); - const data = { - address: safeAddress, - chainId: chainId, - ...payload, - }; - - await request(app.getHttpServer()) - .post(`/hooks/events`) - .set('Authorization', `Basic ${authToken}`) - .send(data) - .expect(410); - }); - - it('should throw an error if authorization is not sent in the request headers', async () => { - await request(app.getHttpServer()) - .post(`/hooks/events`) - .send({}) - .expect(403); - }); - it.each([ { type: 'DELETED_MULTISIG_TRANSACTION', diff --git a/src/routes/hooks/hooks-notifications.spec.ts b/src/routes/hooks/hooks-notifications.spec.ts new file mode 100644 index 0000000000..ae388634d0 --- /dev/null +++ b/src/routes/hooks/hooks-notifications.spec.ts @@ -0,0 +1,698 @@ +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; +import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; +import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; +import configuration from '@/config/entities/__tests__/configuration'; +import { AppModule } from '@/app.module'; +import { CacheModule } from '@/datasources/cache/cache.module'; +import { RequestScopedLoggingModule } from '@/logging/logging.module'; +import { NetworkModule } from '@/datasources/network/network.module'; +import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; +import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { Server } from 'net'; +import { chainUpdateEventBuilder } from '@/routes/hooks/entities/__tests__/chain-update.builder'; +import { safeAppsEventBuilder } from '@/routes/hooks/entities/__tests__/safe-apps-update.builder'; +import { outgoingEtherEventBuilder } from '@/routes/hooks/entities/__tests__/outgoing-ether.builder'; +import { outgoingTokenEventBuilder } from '@/routes/hooks/entities/__tests__/outgoing-token.builder'; +import { newConfirmationEventBuilder } from '@/routes/hooks/entities/__tests__/new-confirmation.builder'; +import { safeCreatedEventBuilder } from '@/routes/hooks/entities/__tests__/safe-created.build'; +import { deletedMultisigTransactionEventBuilder } from '@/routes/hooks/entities/__tests__/deleted-multisig-transaction.builder'; +import { executedTransactionEventBuilder } from '@/routes/hooks/entities/__tests__/executed-transaction.builder'; +import { moduleTransactionEventBuilder } from '@/routes/hooks/entities/__tests__/module-transaction.builder'; +import { incomingEtherEventBuilder } from '@/routes/hooks/entities/__tests__/incoming-ether.builder'; +import { incomingTokenEventBuilder } from '@/routes/hooks/entities/__tests__/incoming-token.builder'; +import { newMessageConfirmationEventBuilder } from '@/routes/hooks/entities/__tests__/new-message-confirmation.builder'; +import request from 'supertest'; +import { IPushNotificationsApi } from '@/domain/interfaces/push-notifications-api.interface'; +import { PushNotificationsApiModule } from '@/datasources/push-notifications-api/push-notifications-api.module'; +import { TestPushNotificationsApiModule } from '@/datasources/push-notifications-api/__tests__/test.push-notifications-api.module'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; +import { IConfigurationService } from '@/config/configuration.service.interface'; +import { + INetworkService, + NetworkService, +} from '@/datasources/network/network.service.interface'; +import { INotificationsDatasource } from '@/domain/interfaces/notifications.datasource.interface'; +import { faker } from '@faker-js/faker'; +import { getAddress } from 'viem'; +import { pageBuilder } from '@/domain/entities/__tests__/page.builder'; +import { nativeTokenTransferBuilder } from '@/domain/safe/entities/__tests__/native-token-transfer.builder'; +import { erc721TransferBuilder } from '@/domain/safe/entities/__tests__/erc721-transfer.builder'; +import { erc20TransferBuilder } from '@/domain/safe/entities/__tests__/erc20-transfer.builder'; +import { chainBuilder } from '@/domain/chains/entities/__tests__/chain.builder'; +import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder'; +import { pendingTransactionEventBuilder } from '@/routes/hooks/entities/__tests__/pending-transaction.builder'; +import { multisigTransactionBuilder } from '@/domain/safe/entities/__tests__/multisig-transaction.builder'; +import { confirmationBuilder } from '@/domain/safe/entities/__tests__/multisig-transaction-confirmation.builder'; +import { messageBuilder } from '@/domain/messages/entities/__tests__/message.builder'; +import { messageCreatedEventBuilder } from '@/routes/hooks/entities/__tests__/message-created.builder'; +import { messageConfirmationBuilder } from '@/domain/messages/entities/__tests__/message-confirmation.builder'; + +describe('Post Hook Events for Notifications (Unit)', () => { + let app: INestApplication; + let pushNotificationsApi: jest.MockedObjectDeep; + let notificationsDatasource: jest.MockedObjectDeep; + let networkService: jest.MockedObjectDeep; + let configurationService: IConfigurationService; + let authToken: string; + let safeConfigUrl: string; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule.register(configuration)], + }) + .overrideModule(CacheModule) + .useModule(TestCacheModule) + .overrideModule(RequestScopedLoggingModule) + .useModule(TestLoggingModule) + .overrideModule(NetworkModule) + .useModule(TestNetworkModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) + .overrideModule(PushNotificationsApiModule) + .useModule(TestPushNotificationsApiModule) + .overrideModule(QueuesApiModule) + .useModule(TestQueuesApiModule) + .compile(); + app = moduleFixture.createNestApplication(); + + networkService = moduleFixture.get(NetworkService); + pushNotificationsApi = moduleFixture.get(IPushNotificationsApi); + notificationsDatasource = moduleFixture.get(INotificationsDatasource); + configurationService = moduleFixture.get(IConfigurationService); + authToken = configurationService.getOrThrow('auth.token'); + safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); + + await app.init(); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + afterAll(async () => { + await app.close(); + }); + + it.each( + [ + chainUpdateEventBuilder().build(), + safeAppsEventBuilder().build(), + outgoingEtherEventBuilder().build(), + outgoingTokenEventBuilder().build(), + newConfirmationEventBuilder().build(), + newMessageConfirmationEventBuilder().build(), + safeCreatedEventBuilder().build(), + ].map((event) => [event.type, event]), + )('should not enqueue notifications for %s events', async (_, event) => { + await request(app.getHttpServer()) + .post(`/hooks/events`) + .set('Authorization', `Basic ${authToken}`) + .send(event) + .expect(202); + + expect(pushNotificationsApi.enqueueNotification).not.toHaveBeenCalled(); + }); + + it.each( + [ + deletedMultisigTransactionEventBuilder().build(), + executedTransactionEventBuilder().build(), + moduleTransactionEventBuilder().build(), + ].map((event) => [event.type, event]), + )('should enqueue %s event notifications as is', async (_, event) => { + const subscribers = Array.from( + { + length: faker.number.int({ min: 1, max: 5 }), + }, + () => ({ + subscriber: getAddress(faker.finance.ethereumAddress()), + cloudMessagingToken: faker.string.alphanumeric(), + }), + ); + notificationsDatasource.getSubscribersWithTokensBySafe.mockResolvedValue( + subscribers, + ); + + await request(app.getHttpServer()) + .post(`/hooks/events`) + .set('Authorization', `Basic ${authToken}`) + .send(event) + .expect(202); + + expect(pushNotificationsApi.enqueueNotification).toHaveBeenCalledTimes( + subscribers.length, + ); + subscribers.forEach((subscriber, i) => { + expect(pushNotificationsApi.enqueueNotification).toHaveBeenNthCalledWith( + i + 1, + subscriber.cloudMessagingToken, + { data: event }, + ); + }); + }); + + it.each( + [ + incomingEtherEventBuilder().build(), + incomingTokenEventBuilder().build(), + ].map((event) => [event.type, event]), + )( + 'should enqueue %s event notifications when receiving assets from other parties', + async (_, event) => { + const chain = chainBuilder().with('chainId', event.chainId).build(); + const subscribers = Array.from( + { + length: faker.number.int({ min: 1, max: 5 }), + }, + () => ({ + subscriber: getAddress(faker.finance.ethereumAddress()), + cloudMessagingToken: faker.string.alphanumeric(), + }), + ); + notificationsDatasource.getSubscribersWithTokensBySafe.mockResolvedValue( + subscribers, + ); + + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { + return Promise.resolve({ + data: chain, + status: 200, + }); + } else if ( + url === + `${chain.transactionService}/api/v1/safes/${event.address}/incoming-transfers/` + ) { + const transfers = [ + nativeTokenTransferBuilder() + .with('to', event.address) + .with('transactionHash', event.txHash as `0x${string}`) + .build(), + erc721TransferBuilder() + .with('to', event.address) + .with('transactionHash', event.txHash as `0x${string}`) + .build(), + erc20TransferBuilder() + .with('to', event.address) + .with('transactionHash', event.txHash as `0x${string}`) + .build(), + ]; + return Promise.resolve({ + status: 200, + data: pageBuilder() + .with('results', [faker.helpers.arrayElement(transfers)]) + .build(), + }); + } else { + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/hooks/events`) + .set('Authorization', `Basic ${authToken}`) + .send(event) + .expect(202); + + expect(pushNotificationsApi.enqueueNotification).toHaveBeenCalledTimes( + subscribers.length, + ); + subscribers.forEach((subscriber, i) => { + expect( + pushNotificationsApi.enqueueNotification, + ).toHaveBeenNthCalledWith(i + 1, subscriber.cloudMessagingToken, { + data: event, + }); + }); + }, + ); + + it.each( + [ + incomingEtherEventBuilder().build(), + incomingTokenEventBuilder().build(), + ].map((event) => [event.type, event]), + )( + 'should not enqueue %s event notifications when receiving assets from the Safe itself', + async (_, event) => { + const chain = chainBuilder().with('chainId', event.chainId).build(); + const subscribers = Array.from( + { + length: faker.number.int({ min: 1, max: 5 }), + }, + () => ({ + subscriber: getAddress(faker.finance.ethereumAddress()), + cloudMessagingToken: faker.string.alphanumeric(), + }), + ); + notificationsDatasource.getSubscribersWithTokensBySafe.mockResolvedValue( + subscribers, + ); + + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { + return Promise.resolve({ + data: chain, + status: 200, + }); + } else if ( + url === + `${chain.transactionService}/api/v1/safes/${event.address}/incoming-transfers/` + ) { + const transfers = [ + nativeTokenTransferBuilder() + .with('from', event.address) + .with('to', event.address) + .with('transactionHash', event.txHash as `0x${string}`) + .build(), + erc721TransferBuilder() + .with('from', event.address) + .with('to', event.address) + .with('transactionHash', event.txHash as `0x${string}`) + .build(), + erc20TransferBuilder() + .with('from', event.address) + .with('to', event.address) + .with('transactionHash', event.txHash as `0x${string}`) + .build(), + ]; + return Promise.resolve({ + status: 200, + data: pageBuilder() + .with('results', [faker.helpers.arrayElement(transfers)]) + .build(), + }); + } else { + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/hooks/events`) + .set('Authorization', `Basic ${authToken}`) + .send(event) + .expect(202); + + expect(pushNotificationsApi.enqueueNotification).not.toHaveBeenCalled(); + }, + ); + + it("should enqueue PENDING_MULTISIG_TRANSACTION event notifications if the Safe has a threshold > 1 and the subscriber hasn't yet signed", async () => { + const event = pendingTransactionEventBuilder().build(); + const chain = chainBuilder().with('chainId', event.chainId).build(); + const safe = safeBuilder() + .with('address', event.address) + .with('threshold', faker.number.int({ min: 2 })) + .build(); + const multisigTransaction = multisigTransactionBuilder() + .with('safe', event.address) + .build(); + const subscribers = Array.from( + { + length: faker.number.int({ min: 1, max: 5 }), + }, + () => ({ + subscriber: getAddress(faker.finance.ethereumAddress()), + cloudMessagingToken: faker.string.alphanumeric(), + }), + ); + notificationsDatasource.getSubscribersWithTokensBySafe.mockResolvedValue( + subscribers, + ); + + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { + return Promise.resolve({ + data: chain, + status: 200, + }); + } else if ( + url === `${chain.transactionService}/api/v1/safes/${event.address}` + ) { + return Promise.resolve({ + status: 200, + data: safe, + }); + } else if ( + url === + `${chain.transactionService}/api/v1/multisig-transactions/${event.safeTxHash}/` + ) { + return Promise.resolve({ + status: 200, + data: multisigTransaction, + }); + } else { + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/hooks/events`) + .set('Authorization', `Basic ${authToken}`) + .send(event) + .expect(202); + + expect(pushNotificationsApi.enqueueNotification).toHaveBeenCalledTimes( + subscribers.length, + ); + subscribers.forEach((subscriber, i) => { + expect(pushNotificationsApi.enqueueNotification).toHaveBeenNthCalledWith( + i + 1, + subscriber.cloudMessagingToken, + { + data: { + ...event, + type: 'CONFIRMATION_REQUEST', + }, + }, + ); + }); + }); + + it('should not enqueue PENDING_MULTISIG_TRANSACTION event notifications if the Safe has a threshold of 1', async () => { + const event = pendingTransactionEventBuilder().build(); + const chain = chainBuilder().with('chainId', event.chainId).build(); + const safe = safeBuilder() + .with('address', event.address) + .with('threshold', 1) + .build(); + const subscribers = Array.from( + { + length: faker.number.int({ min: 1, max: 5 }), + }, + () => ({ + subscriber: getAddress(faker.finance.ethereumAddress()), + cloudMessagingToken: faker.string.alphanumeric(), + }), + ); + notificationsDatasource.getSubscribersWithTokensBySafe.mockResolvedValue( + subscribers, + ); + + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { + return Promise.resolve({ + data: chain, + status: 200, + }); + } else if ( + url === `${chain.transactionService}/api/v1/safes/${event.address}` + ) { + return Promise.resolve({ + status: 200, + data: safe, + }); + } else { + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/hooks/events`) + .set('Authorization', `Basic ${authToken}`) + .send(event) + .expect(202); + + expect(pushNotificationsApi.enqueueNotification).not.toHaveBeenCalled(); + }); + + it('should not enqueue PENDING_MULTISIG_TRANSACTION event notifications if the Safe has a threshold > 1 but the subscriber has signed', async () => { + const event = pendingTransactionEventBuilder().build(); + const chain = chainBuilder().with('chainId', event.chainId).build(); + const safe = safeBuilder() + .with('address', event.address) + .with('threshold', faker.number.int({ min: 2 })) + .build(); + const subscribers = Array.from( + { + length: faker.number.int({ min: 1, max: 5 }), + }, + () => ({ + subscriber: getAddress(faker.finance.ethereumAddress()), + cloudMessagingToken: faker.string.alphanumeric(), + }), + ); + notificationsDatasource.getSubscribersWithTokensBySafe.mockResolvedValue( + subscribers, + ); + const multisigTransaction = multisigTransactionBuilder() + .with( + 'confirmations', + subscribers.map((subscriber) => { + return confirmationBuilder() + .with('owner', subscriber.subscriber) + .build(); + }), + ) + .build(); + + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { + return Promise.resolve({ + data: chain, + status: 200, + }); + } else if ( + url === `${chain.transactionService}/api/v1/safes/${event.address}` + ) { + return Promise.resolve({ + status: 200, + data: safe, + }); + } else if ( + url === + `${chain.transactionService}/api/v1/safes/${event.address}/multisig-transactions/` + ) { + return Promise.resolve({ + status: 200, + data: multisigTransaction, + }); + } else { + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/hooks/events`) + .set('Authorization', `Basic ${authToken}`) + .send(event) + .expect(202); + + expect(pushNotificationsApi.enqueueNotification).not.toHaveBeenCalled(); + }); + + it("should enqueue MESSAGE_CONFIRMATION_REQUEST event notifications if the Safe has a threshold > 1 and the subscriber hasn't yet signed", async () => { + const event = messageCreatedEventBuilder().build(); + const chain = chainBuilder().with('chainId', event.chainId).build(); + const safe = safeBuilder() + .with('address', event.address) + .with('threshold', faker.number.int({ min: 2 })) + .build(); + const message = messageBuilder() + .with('messageHash', event.messageHash as `0x${string}`) + .build(); + const subscribers = Array.from( + { + length: faker.number.int({ min: 1, max: 5 }), + }, + () => ({ + subscriber: getAddress(faker.finance.ethereumAddress()), + cloudMessagingToken: faker.string.alphanumeric(), + }), + ); + notificationsDatasource.getSubscribersWithTokensBySafe.mockResolvedValue( + subscribers, + ); + + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { + return Promise.resolve({ + data: chain, + status: 200, + }); + } else if ( + url === `${chain.transactionService}/api/v1/safes/${event.address}` + ) { + return Promise.resolve({ + status: 200, + data: safe, + }); + } else if ( + url === + `${chain.transactionService}/api/v1/messages/${event.messageHash}` + ) { + return Promise.resolve({ + status: 200, + data: message, + }); + } else { + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/hooks/events`) + .set('Authorization', `Basic ${authToken}`) + .send(event) + .expect(202); + + expect(pushNotificationsApi.enqueueNotification).toHaveBeenCalledTimes( + subscribers.length, + ); + subscribers.forEach((subscriber, i) => { + expect(pushNotificationsApi.enqueueNotification).toHaveBeenNthCalledWith( + i + 1, + subscriber.cloudMessagingToken, + { + data: { + ...event, + type: 'MESSAGE_CONFIRMATION_REQUEST', + }, + }, + ); + }); + }); + + it('should not enqueue MESSAGE_CONFIRMATION_REQUEST event notifications if the Safe has a threshold of 1', async () => { + const event = messageCreatedEventBuilder().build(); + const chain = chainBuilder().with('chainId', event.chainId).build(); + const safe = safeBuilder() + .with('address', event.address) + .with('threshold', 1) + .build(); + const subscribers = Array.from( + { + length: faker.number.int({ min: 1, max: 5 }), + }, + () => ({ + subscriber: getAddress(faker.finance.ethereumAddress()), + cloudMessagingToken: faker.string.alphanumeric(), + }), + ); + notificationsDatasource.getSubscribersWithTokensBySafe.mockResolvedValue( + subscribers, + ); + + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { + return Promise.resolve({ + data: chain, + status: 200, + }); + } else if ( + url === `${chain.transactionService}/api/v1/safes/${event.address}` + ) { + return Promise.resolve({ + status: 200, + data: safe, + }); + } else { + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/hooks/events`) + .set('Authorization', `Basic ${authToken}`) + .send(event) + .expect(202); + + expect(pushNotificationsApi.enqueueNotification).not.toHaveBeenCalled(); + }); + + it('should not enqueue MESSAGE_CONFIRMATION_REQUEST event notifications if the Safe has a threshold > 1 but the subscriber has signed', async () => { + const event = messageCreatedEventBuilder().build(); + const chain = chainBuilder().with('chainId', event.chainId).build(); + const safe = safeBuilder() + .with('address', event.address) + .with('threshold', faker.number.int({ min: 2 })) + .build(); + const subscribers = Array.from( + { + length: faker.number.int({ min: 1, max: 5 }), + }, + () => ({ + subscriber: getAddress(faker.finance.ethereumAddress()), + cloudMessagingToken: faker.string.alphanumeric(), + }), + ); + notificationsDatasource.getSubscribersWithTokensBySafe.mockResolvedValue( + subscribers, + ); + const message = messageBuilder() + .with('messageHash', event.messageHash as `0x${string}`) + .with( + 'confirmations', + subscribers.map((subscriber) => { + return messageConfirmationBuilder() + .with('owner', subscriber.subscriber) + .build(); + }), + ) + .build(); + + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { + return Promise.resolve({ + data: chain, + status: 200, + }); + } else if ( + url === `${chain.transactionService}/api/v1/safes/${event.address}` + ) { + return Promise.resolve({ + status: 200, + data: safe, + }); + } else if ( + url === + `${chain.transactionService}/api/v1/messages/${event.messageHash}` + ) { + return Promise.resolve({ + status: 200, + data: message, + }); + } else { + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/hooks/events`) + .set('Authorization', `Basic ${authToken}`) + .send(event) + .expect(202); + + expect(pushNotificationsApi.enqueueNotification).not.toHaveBeenCalled(); + }); + + it('should not fail to send all notifications if one throws', async () => { + const events = [ + chainUpdateEventBuilder().build(), + safeAppsEventBuilder().build(), + outgoingEtherEventBuilder().build(), + outgoingTokenEventBuilder().build(), + newConfirmationEventBuilder().build(), + newMessageConfirmationEventBuilder().build(), + safeCreatedEventBuilder().build(), + ]; + + pushNotificationsApi.enqueueNotification + .mockRejectedValueOnce(new Error('Error enqueueing notification')) + .mockResolvedValueOnce() + .mockRejectedValueOnce(new Error('Other error')) + .mockResolvedValue(); + + for (const event of events) { + await request(app.getHttpServer()) + .post(`/hooks/events`) + .set('Authorization', `Basic ${authToken}`) + .send(event) + // Doesn't throw + .expect(202); + } + }); +}); diff --git a/src/routes/hooks/hooks.controller.spec.ts b/src/routes/hooks/hooks.controller.spec.ts new file mode 100644 index 0000000000..6ba7bace2b --- /dev/null +++ b/src/routes/hooks/hooks.controller.spec.ts @@ -0,0 +1,95 @@ +import { faker } from '@faker-js/faker'; +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; +import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; +import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; +import { TestLoggingModule } from '@/logging/__tests__/test.logging.module'; +import configuration from '@/config/entities/__tests__/configuration'; +import { IConfigurationService } from '@/config/configuration.service.interface'; +import { AppModule } from '@/app.module'; +import { CacheModule } from '@/datasources/cache/cache.module'; +import { RequestScopedLoggingModule } from '@/logging/logging.module'; +import { NetworkModule } from '@/datasources/network/network.module'; +import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; +import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; +import { Server } from 'net'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; + +describe('Post Hook Events (Unit)', () => { + let app: INestApplication; + let authToken: string; + let configurationService: IConfigurationService; + + async function initApp(config: typeof configuration): Promise { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule.register(config)], + }) + .overrideModule(CacheModule) + .useModule(TestCacheModule) + .overrideModule(RequestScopedLoggingModule) + .useModule(TestLoggingModule) + .overrideModule(NetworkModule) + .useModule(TestNetworkModule) + .overrideModule(QueuesApiModule) + .useModule(TestQueuesApiModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) + .compile(); + app = moduleFixture.createNestApplication(); + + configurationService = moduleFixture.get(IConfigurationService); + authToken = configurationService.getOrThrow('auth.token'); + + await app.init(); + } + + beforeEach(async () => { + jest.resetAllMocks(); + await initApp(configuration); + }); + + afterAll(async () => { + await app.close(); + }); + + it('should return 410 if the eventsQueue FF is active and the hook is not CHAIN_UPDATE or SAFE_APPS_UPDATE', async () => { + const defaultConfiguration = configuration(); + const testConfiguration = (): typeof defaultConfiguration => ({ + ...defaultConfiguration, + features: { + ...defaultConfiguration.features, + eventsQueue: true, + }, + }); + + await initApp(testConfiguration); + + const payload = { + type: 'INCOMING_TOKEN', + tokenAddress: faker.finance.ethereumAddress(), + txHash: faker.string.hexadecimal({ length: 32 }), + }; + const safeAddress = faker.finance.ethereumAddress(); + const chainId = faker.string.numeric(); + const data = { + address: safeAddress, + chainId: chainId, + ...payload, + }; + + await request(app.getHttpServer()) + .post(`/hooks/events`) + .set('Authorization', `Basic ${authToken}`) + .send(data) + .expect(410); + }); + + it('should throw an error if authorization is not sent in the request headers', async () => { + await request(app.getHttpServer()) + .post(`/hooks/events`) + .send({}) + .expect(403); + }); +}); diff --git a/src/routes/messages/messages.controller.spec.ts b/src/routes/messages/messages.controller.spec.ts index 5ec04ec022..5479767037 100644 --- a/src/routes/messages/messages.controller.spec.ts +++ b/src/routes/messages/messages.controller.spec.ts @@ -33,6 +33,8 @@ import { NetworkResponseError } from '@/datasources/network/entities/network.err import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Messages controller', () => { let app: INestApplication; @@ -53,6 +55,8 @@ describe('Messages controller', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/notifications/notifications.controller.spec.ts b/src/routes/notifications/notifications.controller.spec.ts index 755b231a12..aadda93da2 100644 --- a/src/routes/notifications/notifications.controller.spec.ts +++ b/src/routes/notifications/notifications.controller.spec.ts @@ -25,6 +25,8 @@ import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues- import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; import { getAddress } from 'viem'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Notifications Controller (Unit)', () => { let app: INestApplication; @@ -45,6 +47,8 @@ describe('Notifications Controller (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/owners/owners.controller.spec.ts b/src/routes/owners/owners.controller.spec.ts index 75484b0391..88e625a1b0 100644 --- a/src/routes/owners/owners.controller.spec.ts +++ b/src/routes/owners/owners.controller.spec.ts @@ -23,6 +23,8 @@ import { pageBuilder } from '@/domain/entities/__tests__/page.builder'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Owners Controller (Unit)', () => { let app: INestApplication; @@ -43,6 +45,8 @@ describe('Owners Controller (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/recovery/recovery.controller.spec.ts b/src/routes/recovery/recovery.controller.spec.ts index e70d0c5365..57f1bd30c8 100644 --- a/src/routes/recovery/recovery.controller.spec.ts +++ b/src/routes/recovery/recovery.controller.spec.ts @@ -42,6 +42,8 @@ import { authPayloadDtoBuilder } from '@/domain/auth/entities/__tests__/auth-pay import { IJwtService } from '@/datasources/jwt/jwt.service.interface'; import { getAddress } from 'viem'; import { Server } from 'net'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; describe('Recovery (Unit)', () => { let app: INestApplication; @@ -82,6 +84,8 @@ describe('Recovery (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/relay/relay.controller.spec.ts b/src/routes/relay/relay.controller.spec.ts index a5a7befab8..24d4d9e802 100644 --- a/src/routes/relay/relay.controller.spec.ts +++ b/src/routes/relay/relay.controller.spec.ts @@ -52,6 +52,8 @@ import { getDeploymentVersionsByChainIds } from '@/__tests__/deployments.helper' import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; const supportedChainIds = Object.keys(configuration().relay.apiKey); @@ -106,6 +108,8 @@ describe('Relay controller', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) .compile(); configurationService = moduleFixture.get(IConfigurationService); diff --git a/src/routes/root/root.controller.spec.ts b/src/routes/root/root.controller.spec.ts index 0bd66d160d..c6d3463bb3 100644 --- a/src/routes/root/root.controller.spec.ts +++ b/src/routes/root/root.controller.spec.ts @@ -9,6 +9,8 @@ import request from 'supertest'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Root Controller tests', () => { let app: INestApplication; @@ -21,6 +23,8 @@ describe('Root Controller tests', () => { .useModule(TestCacheModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) .compile(); app = await new TestAppProvider().provide(moduleFixture); await app.init(); diff --git a/src/routes/safe-apps/safe-apps.controller.spec.ts b/src/routes/safe-apps/safe-apps.controller.spec.ts index 54562d0e98..901a4a645e 100644 --- a/src/routes/safe-apps/safe-apps.controller.spec.ts +++ b/src/routes/safe-apps/safe-apps.controller.spec.ts @@ -23,6 +23,8 @@ import { import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Safe Apps Controller (Unit)', () => { let app: INestApplication; @@ -43,6 +45,8 @@ describe('Safe Apps Controller (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/safes/safes.controller.nonces.spec.ts b/src/routes/safes/safes.controller.nonces.spec.ts index 3cbb566cd4..308f2e2f53 100644 --- a/src/routes/safes/safes.controller.nonces.spec.ts +++ b/src/routes/safes/safes.controller.nonces.spec.ts @@ -25,6 +25,8 @@ import { INestApplication } from '@nestjs/common'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Safes Controller Nonces (Unit)', () => { let app: INestApplication; @@ -46,6 +48,8 @@ describe('Safes Controller Nonces (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) .compile(); configurationService = moduleFixture.get(IConfigurationService); diff --git a/src/routes/safes/safes.controller.overview.spec.ts b/src/routes/safes/safes.controller.overview.spec.ts index 798ccd0579..49ddd1ac2f 100644 --- a/src/routes/safes/safes.controller.overview.spec.ts +++ b/src/routes/safes/safes.controller.overview.spec.ts @@ -31,6 +31,8 @@ import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Safes Controller Overview (Unit)', () => { let app: INestApplication; @@ -67,6 +69,8 @@ describe('Safes Controller Overview (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/safes/safes.controller.spec.ts b/src/routes/safes/safes.controller.spec.ts index df59375a15..52ae00ebf9 100644 --- a/src/routes/safes/safes.controller.spec.ts +++ b/src/routes/safes/safes.controller.spec.ts @@ -42,6 +42,8 @@ import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Safes Controller (Unit)', () => { let app: INestApplication; @@ -62,6 +64,8 @@ describe('Safes Controller (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/transactions/__tests__/controllers/delete-transaction.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/delete-transaction.transactions.controller.spec.ts index b25723714b..6e84d98a06 100644 --- a/src/routes/transactions/__tests__/controllers/delete-transaction.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/delete-transaction.transactions.controller.spec.ts @@ -29,6 +29,8 @@ import { FakeCacheService } from '@/datasources/cache/__tests__/fake.cache.servi import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Delete Transaction - Transactions Controller (Unit', () => { let app: INestApplication; @@ -50,6 +52,8 @@ describe('Delete Transaction - Transactions Controller (Unit', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/transactions/__tests__/controllers/get-transaction-by-id.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/get-transaction-by-id.transactions.controller.spec.ts index 6374cb61fa..6df780f771 100644 --- a/src/routes/transactions/__tests__/controllers/get-transaction-by-id.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/get-transaction-by-id.transactions.controller.spec.ts @@ -41,6 +41,8 @@ import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Get by id - Transactions Controller (Unit)', () => { let app: INestApplication; @@ -61,6 +63,8 @@ describe('Get by id - Transactions Controller (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/transactions/__tests__/controllers/list-incoming-transfers-by-safe.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/list-incoming-transfers-by-safe.transactions.controller.spec.ts index 92e0994cf1..f52848b69c 100644 --- a/src/routes/transactions/__tests__/controllers/list-incoming-transfers-by-safe.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/list-incoming-transfers-by-safe.transactions.controller.spec.ts @@ -38,6 +38,8 @@ import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('List incoming transfers by Safe - Transactions Controller (Unit)', () => { let app: INestApplication; @@ -58,6 +60,8 @@ describe('List incoming transfers by Safe - Transactions Controller (Unit)', () .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/transactions/__tests__/controllers/list-module-transactions-by-safe.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/list-module-transactions-by-safe.transactions.controller.spec.ts index 2770f8fba1..3a9a6ce64a 100644 --- a/src/routes/transactions/__tests__/controllers/list-module-transactions-by-safe.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/list-module-transactions-by-safe.transactions.controller.spec.ts @@ -27,6 +27,8 @@ import { NetworkResponseError } from '@/datasources/network/entities/network.err import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; describe('List module transactions by Safe - Transactions Controller (Unit)', () => { let app: INestApplication; @@ -47,6 +49,8 @@ describe('List module transactions by Safe - Transactions Controller (Unit)', () .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/transactions/__tests__/controllers/list-multisig-transactions-by-safe.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/list-multisig-transactions-by-safe.transactions.controller.spec.ts index e61738bf8b..8de0e426d2 100644 --- a/src/routes/transactions/__tests__/controllers/list-multisig-transactions-by-safe.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/list-multisig-transactions-by-safe.transactions.controller.spec.ts @@ -37,6 +37,8 @@ import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('List multisig transactions by Safe - Transactions Controller (Unit)', () => { let app: INestApplication; @@ -57,6 +59,8 @@ describe('List multisig transactions by Safe - Transactions Controller (Unit)', .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/transactions/__tests__/controllers/preview-transaction.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/preview-transaction.transactions.controller.spec.ts index 06b30ddd0a..187a6811ba 100644 --- a/src/routes/transactions/__tests__/controllers/preview-transaction.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/preview-transaction.transactions.controller.spec.ts @@ -29,6 +29,8 @@ import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; describe('Preview transaction - Transactions Controller (Unit)', () => { let app: INestApplication; @@ -49,6 +51,8 @@ describe('Preview transaction - Transactions Controller (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/transactions/__tests__/controllers/propose-transaction.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/propose-transaction.transactions.controller.spec.ts index 0ac63abe50..12281790da 100644 --- a/src/routes/transactions/__tests__/controllers/propose-transaction.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/propose-transaction.transactions.controller.spec.ts @@ -31,6 +31,8 @@ import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Propose transaction - Transactions Controller (Unit)', () => { let app: INestApplication; @@ -51,6 +53,8 @@ describe('Propose transaction - Transactions Controller (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/transactions/transactions-history.controller.spec.ts b/src/routes/transactions/transactions-history.controller.spec.ts index 81ce39e055..8a3e89a26f 100644 --- a/src/routes/transactions/transactions-history.controller.spec.ts +++ b/src/routes/transactions/transactions-history.controller.spec.ts @@ -61,6 +61,8 @@ import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Transactions History Controller (Unit)', () => { let app: INestApplication; @@ -92,6 +94,8 @@ describe('Transactions History Controller (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) .compile(); configurationService = moduleFixture.get(IConfigurationService); diff --git a/src/routes/transactions/transactions-history.imitation-transactions.controller.spec.ts b/src/routes/transactions/transactions-history.imitation-transactions.controller.spec.ts index 1e6f9b649a..7878dfbcdc 100644 --- a/src/routes/transactions/transactions-history.imitation-transactions.controller.spec.ts +++ b/src/routes/transactions/transactions-history.imitation-transactions.controller.spec.ts @@ -46,6 +46,8 @@ import { erc20TransferEncoder } from '@/domain/relay/contracts/__tests__/encoder import { EthereumTransaction } from '@/domain/safe/entities/ethereum-transaction.entity'; import { MultisigTransaction } from '@/domain/safe/entities/multisig-transaction.entity'; import { Server } from 'net'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Transactions History Controller (Unit) - Imitation Transactions', () => { let app: INestApplication; @@ -85,6 +87,8 @@ describe('Transactions History Controller (Unit) - Imitation Transactions', () = .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/transactions/transactions-view.controller.spec.ts b/src/routes/transactions/transactions-view.controller.spec.ts index 09fd0d315a..c9cc76e9b9 100644 --- a/src/routes/transactions/transactions-view.controller.spec.ts +++ b/src/routes/transactions/transactions-view.controller.spec.ts @@ -26,6 +26,8 @@ import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues- import { faker } from '@faker-js/faker'; import { Server } from 'net'; import { getAddress } from 'viem'; +import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; +import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('TransactionsViewController tests', () => { let app: INestApplication; @@ -63,6 +65,8 @@ describe('TransactionsViewController tests', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) + .overrideModule(NotificationsDatasourceModule) + .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( From a73958b766363b40fd9f814aaa7606cd85faeae3 Mon Sep 17 00:00:00 2001 From: iamacook Date: Tue, 23 Jul 2024 09:47:35 +0200 Subject: [PATCH 20/37] Fix test --- src/routes/hooks/hooks-notifications.spec.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/routes/hooks/hooks-notifications.spec.ts b/src/routes/hooks/hooks-notifications.spec.ts index ae388634d0..bc3a9964ca 100644 --- a/src/routes/hooks/hooks-notifications.spec.ts +++ b/src/routes/hooks/hooks-notifications.spec.ts @@ -59,7 +59,7 @@ describe('Post Hook Events for Notifications (Unit)', () => { let authToken: string; let safeConfigUrl: string; - beforeAll(async () => { + async function initApp(): Promise { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule.register(configuration)], }) @@ -86,10 +86,11 @@ describe('Post Hook Events for Notifications (Unit)', () => { safeConfigUrl = configurationService.getOrThrow('safeConfig.baseUri'); await app.init(); - }); + } - beforeEach(() => { + beforeEach(async () => { jest.resetAllMocks(); + await initApp(); }); afterAll(async () => { @@ -468,7 +469,7 @@ describe('Post Hook Events for Notifications (Unit)', () => { ) { return Promise.resolve({ status: 200, - data: multisigTransaction, + data: pageBuilder().with('results', [multisigTransaction]).build(), }); } else { return Promise.reject(`No matching rule for url: ${url}`); From 4d5bd6e68988d194cd28e0988480eec7de17bbe1 Mon Sep 17 00:00:00 2001 From: iamacook Date: Tue, 23 Jul 2024 12:21:01 +0200 Subject: [PATCH 21/37] Add feature flag for push notifications --- .../entities/__tests__/configuration.ts | 1 + src/config/entities/configuration.ts | 2 + .../hooks/hooks.repository.interface.ts | 3 +- .../notifications.repository.v2.interface.ts | 115 +++++++++++++++--- .../accounts/accounts.controller.spec.ts | 4 - src/routes/alerts/alerts.controller.spec.ts | 6 - src/routes/auth/auth.controller.spec.ts | 4 - .../zerion-balances.controller.spec.ts | 4 - .../balances/balances.controller.spec.ts | 4 - src/routes/chains/chains.controller.spec.ts | 4 - .../zerion-collectibles.controller.spec.ts | 4 - .../collectibles.controller.spec.ts | 4 - .../community/community.controller.spec.ts | 4 - .../contracts/contracts.controller.spec.ts | 4 - .../delegates/delegates.controller.spec.ts | 4 - .../v2/delegates.v2.controller.spec.ts | 4 - .../estimations.controller.spec.ts | 4 - src/routes/health/health.controller.spec.ts | 4 - src/routes/hooks/hooks-notifications.spec.ts | 13 +- .../messages/messages.controller.spec.ts | 4 - .../notifications.controller.spec.ts | 4 - src/routes/owners/owners.controller.spec.ts | 4 - .../recovery/recovery.controller.spec.ts | 4 - src/routes/relay/relay.controller.spec.ts | 4 - src/routes/root/root.controller.spec.ts | 4 - .../safe-apps/safe-apps.controller.spec.ts | 4 - .../safes/safes.controller.nonces.spec.ts | 4 - .../safes/safes.controller.overview.spec.ts | 4 - src/routes/safes/safes.controller.spec.ts | 4 - ...ransaction.transactions.controller.spec.ts | 4 - ...tion-by-id.transactions.controller.spec.ts | 4 - ...rs-by-safe.transactions.controller.spec.ts | 4 - ...ns-by-safe.transactions.controller.spec.ts | 4 - ...ns-by-safe.transactions.controller.spec.ts | 4 - ...ransaction.transactions.controller.spec.ts | 4 - ...ransaction.transactions.controller.spec.ts | 4 - .../transactions-history.controller.spec.ts | 4 - ....imitation-transactions.controller.spec.ts | 4 - .../transactions-view.controller.spec.ts | 4 - 39 files changed, 115 insertions(+), 157 deletions(-) diff --git a/src/config/entities/__tests__/configuration.ts b/src/config/entities/__tests__/configuration.ts index ce800413db..401dba382f 100644 --- a/src/config/entities/__tests__/configuration.ts +++ b/src/config/entities/__tests__/configuration.ts @@ -121,6 +121,7 @@ export default (): ReturnType => ({ delegatesV2: false, counterfactualBalances: false, accounts: false, + pushNotifications: false, }, httpClient: { requestTimeout: faker.number.int() }, locking: { diff --git a/src/config/entities/configuration.ts b/src/config/entities/configuration.ts index 317142a06c..17c081dc5e 100644 --- a/src/config/entities/configuration.ts +++ b/src/config/entities/configuration.ts @@ -180,6 +180,8 @@ export default () => ({ counterfactualBalances: process.env.FF_COUNTERFACTUAL_BALANCES?.toLowerCase() === 'true', accounts: process.env.FF_ACCOUNTS?.toLowerCase() === 'true', + pushNotifications: + process.env.FF_PUSH_NOTIFICATIONS?.toLowerCase() === 'true', }, httpClient: { // Timeout in milliseconds to be used for the HTTP client. diff --git a/src/domain/hooks/hooks.repository.interface.ts b/src/domain/hooks/hooks.repository.interface.ts index 33857d9795..df78581b3c 100644 --- a/src/domain/hooks/hooks.repository.interface.ts +++ b/src/domain/hooks/hooks.repository.interface.ts @@ -1,3 +1,4 @@ +import configuration from '@/config/entities/configuration'; import { BalancesRepositoryModule } from '@/domain/balances/balances.repository.interface'; import { BlockchainRepositoryModule } from '@/domain/blockchain/blockchain.repository.interface'; import { ChainsRepositoryModule } from '@/domain/chains/chains.repository.interface'; @@ -25,7 +26,7 @@ export interface IHooksRepository { ChainsRepositoryModule, CollectiblesRepositoryModule, MessagesRepositoryModule, - NotificationsRepositoryV2Module, + NotificationsRepositoryV2Module.forRoot(configuration), SafeAppsRepositoryModule, SafeRepositoryModule, TransactionsRepositoryModule, diff --git a/src/domain/notifications/notifications.repository.v2.interface.ts b/src/domain/notifications/notifications.repository.v2.interface.ts index 1806352787..aed501b68a 100644 --- a/src/domain/notifications/notifications.repository.v2.interface.ts +++ b/src/domain/notifications/notifications.repository.v2.interface.ts @@ -3,10 +3,11 @@ import { FirebaseNotification } from '@/datasources/push-notifications-api/entit import { PushNotificationsApiModule } from '@/datasources/push-notifications-api/push-notifications-api.module'; import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; import { NotificationsRepositoryV2 } from '@/domain/notifications/notifications.repository.v2'; -import { Module } from '@nestjs/common'; +import { DynamicModule, Module } from '@nestjs/common'; import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; import { SafeRepositoryModule } from '@/domain/safe/safe.repository.interface'; import { DelegatesV2RepositoryModule } from '@/domain/delegate/v2/delegates.v2.repository.interface'; +import configuration from '@/config/entities/__tests__/configuration'; export const INotificationsRepositoryV2 = Symbol('INotificationsRepositoryV2'); @@ -46,19 +47,99 @@ export interface INotificationsRepositoryV2 { deleteDevice(deviceUuid: Uuid): Promise; } -@Module({ - imports: [ - PushNotificationsApiModule, - NotificationsDatasourceModule, - SafeRepositoryModule, - DelegatesV2RepositoryModule, - ], - providers: [ - { - provide: INotificationsRepositoryV2, - useClass: NotificationsRepositoryV2, - }, - ], - exports: [INotificationsRepositoryV2], -}) -export class NotificationsRepositoryV2Module {} +/** + * The following is used for feature flagging. All functions are noops in order + * to not require database access when push notifications are disabled. + */ +class NoopNotificationsRepositoryV2 implements INotificationsRepositoryV2 { + enqueueNotification( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _token: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _notification: FirebaseNotification, + ): Promise { + return Promise.resolve(); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async upsertSubscriptions(_args: UpsertSubscriptionsDto): Promise<{ + deviceUuid: Uuid; + }> { + return Promise.resolve({ deviceUuid: crypto.randomUUID() }); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getSafeSubscription(_args: { + account: `0x${string}`; + deviceUuid: Uuid; + chainId: string; + safeAddress: `0x${string}`; + }): Promise { + return Promise.resolve(); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getSubscribersWithTokensBySafe(_args: { + chainId: string; + safeAddress: `0x${string}`; + }): Promise< + Array<{ + subscriber: `0x${string}`; + cloudMessagingToken: string; + }> + > { + return Promise.resolve([]); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + deleteSubscription(_args: { + account: `0x${string}`; + chainId: string; + safeAddress: `0x${string}`; + }): Promise { + return Promise.resolve(); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + deleteDevice(_deviceUuid: Uuid): Promise { + return Promise.resolve(); + } +} + +@Module({}) +export class NotificationsRepositoryV2Module { + static forRoot(config: typeof configuration): DynamicModule { + const isPushNotificationsEnabled = config().features.pushNotifications; + + if (!isPushNotificationsEnabled) { + return { + module: NotificationsRepositoryV2Module, + imports: [], + providers: [ + { + provide: INotificationsRepositoryV2, + useClass: NoopNotificationsRepositoryV2, + }, + ], + exports: [INotificationsRepositoryV2], + }; + } + + return { + module: NotificationsRepositoryV2Module, + imports: [ + PushNotificationsApiModule, + NotificationsDatasourceModule, + SafeRepositoryModule, + DelegatesV2RepositoryModule, + ], + providers: [ + { + provide: INotificationsRepositoryV2, + useClass: NotificationsRepositoryV2, + }, + ], + exports: [INotificationsRepositoryV2], + }; + } +} diff --git a/src/routes/accounts/accounts.controller.spec.ts b/src/routes/accounts/accounts.controller.spec.ts index 41e2d8cb63..8dbaf22d82 100644 --- a/src/routes/accounts/accounts.controller.spec.ts +++ b/src/routes/accounts/accounts.controller.spec.ts @@ -3,8 +3,6 @@ import { AppModule } from '@/app.module'; import configuration from '@/config/entities/__tests__/configuration'; import { TestAccountsDataSourceModule } from '@/datasources/accounts/__tests__/test.accounts.datasource.module'; import { AccountsDatasourceModule } from '@/datasources/accounts/accounts.datasource.module'; -import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; -import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; import { CacheModule } from '@/datasources/cache/cache.module'; import jwtConfiguration from '@/datasources/jwt/configuration/__tests__/jwt.configuration'; @@ -70,8 +68,6 @@ describe('AccountsController', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) .compile(); jwtService = moduleFixture.get(IJwtService); accountDataSource = moduleFixture.get(IAccountsDatasource); diff --git a/src/routes/alerts/alerts.controller.spec.ts b/src/routes/alerts/alerts.controller.spec.ts index 2ae9b36ae0..e8c9821592 100644 --- a/src/routes/alerts/alerts.controller.spec.ts +++ b/src/routes/alerts/alerts.controller.spec.ts @@ -55,8 +55,6 @@ import { import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; -import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; -import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; // The `x-tenderly-signature` header contains a cryptographic signature. The webhook request signature is // a HMAC SHA256 hash of concatenated signing secret, request payload, and timestamp, in this order. @@ -120,8 +118,6 @@ describe('Alerts (Unit)', () => { .useModule(TestEmailApiModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) .compile(); configurationService = moduleFixture.get(IConfigurationService); @@ -889,8 +885,6 @@ describe('Alerts (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) .compile(); app = moduleFixture.createNestApplication(); diff --git a/src/routes/auth/auth.controller.spec.ts b/src/routes/auth/auth.controller.spec.ts index fb18f7150b..beebacb6f0 100644 --- a/src/routes/auth/auth.controller.spec.ts +++ b/src/routes/auth/auth.controller.spec.ts @@ -35,8 +35,6 @@ import { IBlockchainApiManager, } from '@/domain/interfaces/blockchain-api.manager.interface'; import { TestBlockchainApiManagerModule } from '@/datasources/blockchain/__tests__/test.blockchain-api.manager'; -import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; -import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; const verifySiweMessageMock = jest.fn(); @@ -64,8 +62,6 @@ describe('AuthController', () => { .useModule(TestEmailApiModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) .compile(); cacheService = moduleFixture.get(CacheService); diff --git a/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts b/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts index c8bfff58e1..33cc158f46 100644 --- a/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts +++ b/src/routes/balances/__tests__/controllers/zerion-balances.controller.spec.ts @@ -34,8 +34,6 @@ import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; import { sample } from 'lodash'; import { balancesProviderBuilder } from '@/domain/chains/entities/__tests__/balances-provider.builder'; -import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; -import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Balances Controller (Unit)', () => { let app: INestApplication; @@ -80,8 +78,6 @@ describe('Balances Controller (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) .compile(); configurationService = moduleFixture.get(IConfigurationService); diff --git a/src/routes/balances/balances.controller.spec.ts b/src/routes/balances/balances.controller.spec.ts index 272bd710a8..87d1b52804 100644 --- a/src/routes/balances/balances.controller.spec.ts +++ b/src/routes/balances/balances.controller.spec.ts @@ -2,8 +2,6 @@ import { TestAppProvider } from '@/__tests__/test-app.provider'; import { AppModule } from '@/app.module'; import { IConfigurationService } from '@/config/configuration.service.interface'; import configuration from '@/config/entities/__tests__/configuration'; -import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; -import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; import { CacheModule } from '@/datasources/cache/cache.module'; import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; @@ -59,8 +57,6 @@ describe('Balances Controller (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/chains/chains.controller.spec.ts b/src/routes/chains/chains.controller.spec.ts index b067126a35..a97950a17f 100644 --- a/src/routes/chains/chains.controller.spec.ts +++ b/src/routes/chains/chains.controller.spec.ts @@ -30,8 +30,6 @@ import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; -import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; -import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Chains Controller (Unit)', () => { let app: INestApplication; @@ -66,8 +64,6 @@ describe('Chains Controller (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/collectibles/__tests__/controllers/zerion-collectibles.controller.spec.ts b/src/routes/collectibles/__tests__/controllers/zerion-collectibles.controller.spec.ts index 29ec0ccfd7..30bf63a5b5 100644 --- a/src/routes/collectibles/__tests__/controllers/zerion-collectibles.controller.spec.ts +++ b/src/routes/collectibles/__tests__/controllers/zerion-collectibles.controller.spec.ts @@ -28,8 +28,6 @@ import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues- import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; import { balancesProviderBuilder } from '@/domain/chains/entities/__tests__/balances-provider.builder'; -import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; -import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Zerion Collectibles Controller', () => { let app: INestApplication; @@ -52,8 +50,6 @@ describe('Zerion Collectibles Controller', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/collectibles/collectibles.controller.spec.ts b/src/routes/collectibles/collectibles.controller.spec.ts index 1268cbfba0..c5a27bc2a7 100644 --- a/src/routes/collectibles/collectibles.controller.spec.ts +++ b/src/routes/collectibles/collectibles.controller.spec.ts @@ -33,8 +33,6 @@ import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; import { getAddress } from 'viem'; import { safeBuilder } from '@/domain/safe/entities/__tests__/safe.builder'; -import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; -import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Collectibles Controller (Unit)', () => { let app: INestApplication; @@ -64,8 +62,6 @@ describe('Collectibles Controller (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/community/community.controller.spec.ts b/src/routes/community/community.controller.spec.ts index 8983de9b43..f5769c82e3 100644 --- a/src/routes/community/community.controller.spec.ts +++ b/src/routes/community/community.controller.spec.ts @@ -42,8 +42,6 @@ import { campaignActivityBuilder, toJson as campaignActivityToJson, } from '@/domain/community/entities/__tests__/campaign-activity.builder'; -import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; -import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Community (Unit)', () => { let app: INestApplication; @@ -64,8 +62,6 @@ describe('Community (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/contracts/contracts.controller.spec.ts b/src/routes/contracts/contracts.controller.spec.ts index 9da239c499..7df2881c47 100644 --- a/src/routes/contracts/contracts.controller.spec.ts +++ b/src/routes/contracts/contracts.controller.spec.ts @@ -21,8 +21,6 @@ import { NetworkResponseError } from '@/datasources/network/entities/network.err import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; -import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; -import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Contracts controller', () => { let app: INestApplication; @@ -43,8 +41,6 @@ describe('Contracts controller', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/delegates/delegates.controller.spec.ts b/src/routes/delegates/delegates.controller.spec.ts index 003e03d100..6580ca3d4e 100644 --- a/src/routes/delegates/delegates.controller.spec.ts +++ b/src/routes/delegates/delegates.controller.spec.ts @@ -28,8 +28,6 @@ import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; -import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; -import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; describe('Delegates controller', () => { let app: INestApplication; @@ -50,8 +48,6 @@ describe('Delegates controller', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/delegates/v2/delegates.v2.controller.spec.ts b/src/routes/delegates/v2/delegates.v2.controller.spec.ts index 42e66d7c2f..4b18bd6d31 100644 --- a/src/routes/delegates/v2/delegates.v2.controller.spec.ts +++ b/src/routes/delegates/v2/delegates.v2.controller.spec.ts @@ -2,8 +2,6 @@ import { TestAppProvider } from '@/__tests__/test-app.provider'; import { AppModule } from '@/app.module'; import { IConfigurationService } from '@/config/configuration.service.interface'; import configuration from '@/config/entities/__tests__/configuration'; -import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; -import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; import { TestCacheModule } from '@/datasources/cache/__tests__/test.cache.module'; import { CacheModule } from '@/datasources/cache/cache.module'; import { TestNetworkModule } from '@/datasources/network/__tests__/test.network.module'; @@ -58,8 +56,6 @@ describe('Delegates controller', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/estimations/estimations.controller.spec.ts b/src/routes/estimations/estimations.controller.spec.ts index e3a6fe3cae..10fd962de6 100644 --- a/src/routes/estimations/estimations.controller.spec.ts +++ b/src/routes/estimations/estimations.controller.spec.ts @@ -29,8 +29,6 @@ import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues- import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; import { getAddress } from 'viem'; -import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; -import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Estimations Controller (Unit)', () => { let app: INestApplication; @@ -51,8 +49,6 @@ describe('Estimations Controller (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/health/health.controller.spec.ts b/src/routes/health/health.controller.spec.ts index e9c9846628..c9c511bdff 100644 --- a/src/routes/health/health.controller.spec.ts +++ b/src/routes/health/health.controller.spec.ts @@ -19,8 +19,6 @@ import { QueueReadiness, } from '@/domain/interfaces/queue-readiness.interface'; import { Server } from 'net'; -import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; -import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Health Controller tests', () => { let app: INestApplication; @@ -41,8 +39,6 @@ describe('Health Controller tests', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) .compile(); app = await new TestAppProvider().provide(moduleFixture); diff --git a/src/routes/hooks/hooks-notifications.spec.ts b/src/routes/hooks/hooks-notifications.spec.ts index bc3a9964ca..738de98a7f 100644 --- a/src/routes/hooks/hooks-notifications.spec.ts +++ b/src/routes/hooks/hooks-notifications.spec.ts @@ -59,9 +59,20 @@ describe('Post Hook Events for Notifications (Unit)', () => { let authToken: string; let safeConfigUrl: string; + const defaultConfiguration = configuration(); + const testConfiguration = (): ReturnType => { + return { + ...defaultConfiguration, + features: { + ...defaultConfiguration.features, + pushNotifications: true, + }, + }; + }; + async function initApp(): Promise { const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule.register(configuration)], + imports: [AppModule.register(testConfiguration)], }) .overrideModule(CacheModule) .useModule(TestCacheModule) diff --git a/src/routes/messages/messages.controller.spec.ts b/src/routes/messages/messages.controller.spec.ts index 5479767037..5ec04ec022 100644 --- a/src/routes/messages/messages.controller.spec.ts +++ b/src/routes/messages/messages.controller.spec.ts @@ -33,8 +33,6 @@ import { NetworkResponseError } from '@/datasources/network/entities/network.err import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; -import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; -import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Messages controller', () => { let app: INestApplication; @@ -55,8 +53,6 @@ describe('Messages controller', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/notifications/notifications.controller.spec.ts b/src/routes/notifications/notifications.controller.spec.ts index aadda93da2..755b231a12 100644 --- a/src/routes/notifications/notifications.controller.spec.ts +++ b/src/routes/notifications/notifications.controller.spec.ts @@ -25,8 +25,6 @@ import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues- import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; import { getAddress } from 'viem'; -import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; -import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Notifications Controller (Unit)', () => { let app: INestApplication; @@ -47,8 +45,6 @@ describe('Notifications Controller (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/owners/owners.controller.spec.ts b/src/routes/owners/owners.controller.spec.ts index 88e625a1b0..75484b0391 100644 --- a/src/routes/owners/owners.controller.spec.ts +++ b/src/routes/owners/owners.controller.spec.ts @@ -23,8 +23,6 @@ import { pageBuilder } from '@/domain/entities/__tests__/page.builder'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; -import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; -import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Owners Controller (Unit)', () => { let app: INestApplication; @@ -45,8 +43,6 @@ describe('Owners Controller (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/recovery/recovery.controller.spec.ts b/src/routes/recovery/recovery.controller.spec.ts index 57f1bd30c8..e70d0c5365 100644 --- a/src/routes/recovery/recovery.controller.spec.ts +++ b/src/routes/recovery/recovery.controller.spec.ts @@ -42,8 +42,6 @@ import { authPayloadDtoBuilder } from '@/domain/auth/entities/__tests__/auth-pay import { IJwtService } from '@/datasources/jwt/jwt.service.interface'; import { getAddress } from 'viem'; import { Server } from 'net'; -import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; -import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; describe('Recovery (Unit)', () => { let app: INestApplication; @@ -84,8 +82,6 @@ describe('Recovery (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/relay/relay.controller.spec.ts b/src/routes/relay/relay.controller.spec.ts index 24d4d9e802..a5a7befab8 100644 --- a/src/routes/relay/relay.controller.spec.ts +++ b/src/routes/relay/relay.controller.spec.ts @@ -52,8 +52,6 @@ import { getDeploymentVersionsByChainIds } from '@/__tests__/deployments.helper' import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; -import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; -import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; const supportedChainIds = Object.keys(configuration().relay.apiKey); @@ -108,8 +106,6 @@ describe('Relay controller', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) .compile(); configurationService = moduleFixture.get(IConfigurationService); diff --git a/src/routes/root/root.controller.spec.ts b/src/routes/root/root.controller.spec.ts index c6d3463bb3..0bd66d160d 100644 --- a/src/routes/root/root.controller.spec.ts +++ b/src/routes/root/root.controller.spec.ts @@ -9,8 +9,6 @@ import request from 'supertest'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; -import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; -import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Root Controller tests', () => { let app: INestApplication; @@ -23,8 +21,6 @@ describe('Root Controller tests', () => { .useModule(TestCacheModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) .compile(); app = await new TestAppProvider().provide(moduleFixture); await app.init(); diff --git a/src/routes/safe-apps/safe-apps.controller.spec.ts b/src/routes/safe-apps/safe-apps.controller.spec.ts index 901a4a645e..54562d0e98 100644 --- a/src/routes/safe-apps/safe-apps.controller.spec.ts +++ b/src/routes/safe-apps/safe-apps.controller.spec.ts @@ -23,8 +23,6 @@ import { import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; -import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; -import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Safe Apps Controller (Unit)', () => { let app: INestApplication; @@ -45,8 +43,6 @@ describe('Safe Apps Controller (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/safes/safes.controller.nonces.spec.ts b/src/routes/safes/safes.controller.nonces.spec.ts index 308f2e2f53..3cbb566cd4 100644 --- a/src/routes/safes/safes.controller.nonces.spec.ts +++ b/src/routes/safes/safes.controller.nonces.spec.ts @@ -25,8 +25,6 @@ import { INestApplication } from '@nestjs/common'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; -import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; -import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Safes Controller Nonces (Unit)', () => { let app: INestApplication; @@ -48,8 +46,6 @@ describe('Safes Controller Nonces (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) .compile(); configurationService = moduleFixture.get(IConfigurationService); diff --git a/src/routes/safes/safes.controller.overview.spec.ts b/src/routes/safes/safes.controller.overview.spec.ts index 49ddd1ac2f..798ccd0579 100644 --- a/src/routes/safes/safes.controller.overview.spec.ts +++ b/src/routes/safes/safes.controller.overview.spec.ts @@ -31,8 +31,6 @@ import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; -import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; -import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Safes Controller Overview (Unit)', () => { let app: INestApplication; @@ -69,8 +67,6 @@ describe('Safes Controller Overview (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/safes/safes.controller.spec.ts b/src/routes/safes/safes.controller.spec.ts index 52ae00ebf9..df59375a15 100644 --- a/src/routes/safes/safes.controller.spec.ts +++ b/src/routes/safes/safes.controller.spec.ts @@ -42,8 +42,6 @@ import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; -import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; -import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Safes Controller (Unit)', () => { let app: INestApplication; @@ -64,8 +62,6 @@ describe('Safes Controller (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/transactions/__tests__/controllers/delete-transaction.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/delete-transaction.transactions.controller.spec.ts index 6e84d98a06..b25723714b 100644 --- a/src/routes/transactions/__tests__/controllers/delete-transaction.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/delete-transaction.transactions.controller.spec.ts @@ -29,8 +29,6 @@ import { FakeCacheService } from '@/datasources/cache/__tests__/fake.cache.servi import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; -import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; -import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Delete Transaction - Transactions Controller (Unit', () => { let app: INestApplication; @@ -52,8 +50,6 @@ describe('Delete Transaction - Transactions Controller (Unit', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/transactions/__tests__/controllers/get-transaction-by-id.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/get-transaction-by-id.transactions.controller.spec.ts index 6df780f771..6374cb61fa 100644 --- a/src/routes/transactions/__tests__/controllers/get-transaction-by-id.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/get-transaction-by-id.transactions.controller.spec.ts @@ -41,8 +41,6 @@ import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; -import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; -import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Get by id - Transactions Controller (Unit)', () => { let app: INestApplication; @@ -63,8 +61,6 @@ describe('Get by id - Transactions Controller (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/transactions/__tests__/controllers/list-incoming-transfers-by-safe.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/list-incoming-transfers-by-safe.transactions.controller.spec.ts index f52848b69c..92e0994cf1 100644 --- a/src/routes/transactions/__tests__/controllers/list-incoming-transfers-by-safe.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/list-incoming-transfers-by-safe.transactions.controller.spec.ts @@ -38,8 +38,6 @@ import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; -import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; -import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('List incoming transfers by Safe - Transactions Controller (Unit)', () => { let app: INestApplication; @@ -60,8 +58,6 @@ describe('List incoming transfers by Safe - Transactions Controller (Unit)', () .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/transactions/__tests__/controllers/list-module-transactions-by-safe.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/list-module-transactions-by-safe.transactions.controller.spec.ts index 3a9a6ce64a..2770f8fba1 100644 --- a/src/routes/transactions/__tests__/controllers/list-module-transactions-by-safe.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/list-module-transactions-by-safe.transactions.controller.spec.ts @@ -27,8 +27,6 @@ import { NetworkResponseError } from '@/datasources/network/entities/network.err import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; -import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; -import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; describe('List module transactions by Safe - Transactions Controller (Unit)', () => { let app: INestApplication; @@ -49,8 +47,6 @@ describe('List module transactions by Safe - Transactions Controller (Unit)', () .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/transactions/__tests__/controllers/list-multisig-transactions-by-safe.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/list-multisig-transactions-by-safe.transactions.controller.spec.ts index 8de0e426d2..e61738bf8b 100644 --- a/src/routes/transactions/__tests__/controllers/list-multisig-transactions-by-safe.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/list-multisig-transactions-by-safe.transactions.controller.spec.ts @@ -37,8 +37,6 @@ import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; -import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; -import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('List multisig transactions by Safe - Transactions Controller (Unit)', () => { let app: INestApplication; @@ -59,8 +57,6 @@ describe('List multisig transactions by Safe - Transactions Controller (Unit)', .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/transactions/__tests__/controllers/preview-transaction.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/preview-transaction.transactions.controller.spec.ts index 187a6811ba..06b30ddd0a 100644 --- a/src/routes/transactions/__tests__/controllers/preview-transaction.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/preview-transaction.transactions.controller.spec.ts @@ -29,8 +29,6 @@ import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; -import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; -import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; describe('Preview transaction - Transactions Controller (Unit)', () => { let app: INestApplication; @@ -51,8 +49,6 @@ describe('Preview transaction - Transactions Controller (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/transactions/__tests__/controllers/propose-transaction.transactions.controller.spec.ts b/src/routes/transactions/__tests__/controllers/propose-transaction.transactions.controller.spec.ts index 12281790da..0ac63abe50 100644 --- a/src/routes/transactions/__tests__/controllers/propose-transaction.transactions.controller.spec.ts +++ b/src/routes/transactions/__tests__/controllers/propose-transaction.transactions.controller.spec.ts @@ -31,8 +31,6 @@ import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; -import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; -import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Propose transaction - Transactions Controller (Unit)', () => { let app: INestApplication; @@ -53,8 +51,6 @@ describe('Propose transaction - Transactions Controller (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/transactions/transactions-history.controller.spec.ts b/src/routes/transactions/transactions-history.controller.spec.ts index 8a3e89a26f..81ce39e055 100644 --- a/src/routes/transactions/transactions-history.controller.spec.ts +++ b/src/routes/transactions/transactions-history.controller.spec.ts @@ -61,8 +61,6 @@ import { getAddress } from 'viem'; import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues-api.module'; import { QueuesApiModule } from '@/datasources/queues/queues-api.module'; import { Server } from 'net'; -import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; -import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Transactions History Controller (Unit)', () => { let app: INestApplication; @@ -94,8 +92,6 @@ describe('Transactions History Controller (Unit)', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) .compile(); configurationService = moduleFixture.get(IConfigurationService); diff --git a/src/routes/transactions/transactions-history.imitation-transactions.controller.spec.ts b/src/routes/transactions/transactions-history.imitation-transactions.controller.spec.ts index 7878dfbcdc..1e6f9b649a 100644 --- a/src/routes/transactions/transactions-history.imitation-transactions.controller.spec.ts +++ b/src/routes/transactions/transactions-history.imitation-transactions.controller.spec.ts @@ -46,8 +46,6 @@ import { erc20TransferEncoder } from '@/domain/relay/contracts/__tests__/encoder import { EthereumTransaction } from '@/domain/safe/entities/ethereum-transaction.entity'; import { MultisigTransaction } from '@/domain/safe/entities/multisig-transaction.entity'; import { Server } from 'net'; -import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; -import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('Transactions History Controller (Unit) - Imitation Transactions', () => { let app: INestApplication; @@ -87,8 +85,6 @@ describe('Transactions History Controller (Unit) - Imitation Transactions', () = .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( diff --git a/src/routes/transactions/transactions-view.controller.spec.ts b/src/routes/transactions/transactions-view.controller.spec.ts index c9cc76e9b9..09fd0d315a 100644 --- a/src/routes/transactions/transactions-view.controller.spec.ts +++ b/src/routes/transactions/transactions-view.controller.spec.ts @@ -26,8 +26,6 @@ import { TestQueuesApiModule } from '@/datasources/queues/__tests__/test.queues- import { faker } from '@faker-js/faker'; import { Server } from 'net'; import { getAddress } from 'viem'; -import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; -import { TestNotificationsDatasourceModule } from '@/datasources/accounts/notifications/__tests__/test.notifications.datasource.module'; describe('TransactionsViewController tests', () => { let app: INestApplication; @@ -65,8 +63,6 @@ describe('TransactionsViewController tests', () => { .useModule(TestNetworkModule) .overrideModule(QueuesApiModule) .useModule(TestQueuesApiModule) - .overrideModule(NotificationsDatasourceModule) - .useModule(TestNotificationsDatasourceModule) .compile(); const configurationService = moduleFixture.get( From 6658f68e83e9b5d090ee701d6d2dea2b823c828b Mon Sep 17 00:00:00 2001 From: iamacook Date: Tue, 23 Jul 2024 12:26:55 +0200 Subject: [PATCH 22/37] Only call enqueuer if feature enabled --- src/domain/hooks/hooks.repository.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/domain/hooks/hooks.repository.ts b/src/domain/hooks/hooks.repository.ts index 7156c360cf..214924f803 100644 --- a/src/domain/hooks/hooks.repository.ts +++ b/src/domain/hooks/hooks.repository.ts @@ -39,6 +39,7 @@ import { export class HooksRepository implements IHooksRepository { private static readonly HOOK_TYPE = 'hook'; private readonly queueName: string; + private readonly isPushNotificationsEnabled: boolean; constructor( @Inject(IBalancesRepository) @@ -67,6 +68,10 @@ export class HooksRepository implements IHooksRepository { private readonly notificationsRepository: INotificationsRepositoryV2, ) { this.queueName = this.configurationService.getOrThrow('amqp.queue'); + this.isPushNotificationsEnabled = + this.configurationService.getOrThrow( + 'features.pushNotifications', + ); } onModuleInit(): Promise { @@ -87,7 +92,9 @@ export class HooksRepository implements IHooksRepository { async onEvent(event: Event): Promise { return Promise.allSettled([ this.onEventClearCache(event), - this.onEventEnqueueNotifications(event), + ...(this.isPushNotificationsEnabled + ? [this.onEventEnqueueNotifications(event)] + : []), ]).finally(() => { this.onEventLog(event); }); From ec8b9c673347e05f976640c221f7ac0f48d311c3 Mon Sep 17 00:00:00 2001 From: iamacook Date: Wed, 24 Jul 2024 08:49:42 +0200 Subject: [PATCH 23/37] Fix interfaces --- .../interfaces/notifications.datasource.interface.ts | 10 ---------- .../notifications/notifications.repository.v2.ts | 1 + 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/src/domain/interfaces/notifications.datasource.interface.ts b/src/domain/interfaces/notifications.datasource.interface.ts index 5ac4ecb97c..3705e212c9 100644 --- a/src/domain/interfaces/notifications.datasource.interface.ts +++ b/src/domain/interfaces/notifications.datasource.interface.ts @@ -26,16 +26,6 @@ export interface INotificationsDatasource { }> >; - getSubscribersWithTokensBySafe(args: { - chainId: string; - safeAddress: `0x${string}`; - }): Promise< - Array<{ - subscriber: `0x${string}`; - cloudMessagingToken: string; - }> - >; - deleteSubscription(args: { account: `0x${string}`; deviceUuid: Uuid; diff --git a/src/domain/notifications/notifications.repository.v2.ts b/src/domain/notifications/notifications.repository.v2.ts index 5b161a5e98..57332ac92f 100644 --- a/src/domain/notifications/notifications.repository.v2.ts +++ b/src/domain/notifications/notifications.repository.v2.ts @@ -99,6 +99,7 @@ export class NotificationsRepositoryV2 implements INotificationsRepositoryV2 { deleteSubscription(args: { account: `0x${string}`; + deviceUuid: Uuid; chainId: string; safeAddress: `0x${string}`; }): Promise { From 86de9983e3f833aa0ee96db7c5a889b4dad2a63f Mon Sep 17 00:00:00 2001 From: iamacook Date: Wed, 24 Jul 2024 10:10:46 +0200 Subject: [PATCH 24/37] Cleanup unregistered tokens --- .../test.notifications.datasource.module.ts | 2 +- .../notifications.datasource.spec.ts | 2 +- .../notifications/notifications.datasource.ts | 14 +++- .../firebase-cloud-messaging-api.service.ts | 1 + src/domain/hooks/hooks.repository.ts | 10 ++- .../notifications.datasource.interface.ts | 3 +- .../notifications.repository.v2.interface.ts | 36 +++++---- .../notifications.repository.v2.ts | 74 ++++++++++++++++--- src/routes/hooks/hooks-notifications.spec.ts | 45 ++++++----- 9 files changed, 129 insertions(+), 58 deletions(-) diff --git a/src/datasources/accounts/notifications/__tests__/test.notifications.datasource.module.ts b/src/datasources/accounts/notifications/__tests__/test.notifications.datasource.module.ts index 074c7b0c3f..4283fea477 100644 --- a/src/datasources/accounts/notifications/__tests__/test.notifications.datasource.module.ts +++ b/src/datasources/accounts/notifications/__tests__/test.notifications.datasource.module.ts @@ -5,7 +5,7 @@ const accountsDatasource: INotificationsDatasource = { deleteDevice: jest.fn(), deleteSubscription: jest.fn(), getSafeSubscription: jest.fn(), - getSubscribersWithTokensBySafe: jest.fn(), + getSubscribersBySafe: jest.fn(), upsertSubscriptions: jest.fn(), }; diff --git a/src/datasources/accounts/notifications/notifications.datasource.spec.ts b/src/datasources/accounts/notifications/notifications.datasource.spec.ts index b1ae3866be..fc827225db 100644 --- a/src/datasources/accounts/notifications/notifications.datasource.spec.ts +++ b/src/datasources/accounts/notifications/notifications.datasource.spec.ts @@ -374,7 +374,7 @@ describe('NotificationsDatasource', () => { const safe = upsertSubscriptionsDto.safes[0]; await expect( - target.getSubscribersWithTokensBySafe({ + target.getSubscribersBySafe({ chainId: safe.chainId, safeAddress: safe.address, }), diff --git a/src/datasources/accounts/notifications/notifications.datasource.ts b/src/datasources/accounts/notifications/notifications.datasource.ts index 54016b108b..f2038ae390 100644 --- a/src/datasources/accounts/notifications/notifications.datasource.ts +++ b/src/datasources/accounts/notifications/notifications.datasource.ts @@ -139,26 +139,31 @@ export class NotificationsDatasource implements INotificationsDatasource { } /** - * Gets subscribers and their cloud messaging tokens for the given Safe. + * Gets subscribers and their device UUID/cloud messaging tokens for the given Safe. * * @param args.chainId Chain ID * @param args.safeAddress Safe address * * @returns List of subscribers/tokens for given Safe */ - async getSubscribersWithTokensBySafe(args: { + async getSubscribersBySafe(args: { chainId: string; safeAddress: string; }): Promise< Array<{ subscriber: `0x${string}`; + deviceUuid: Uuid; cloudMessagingToken: string; }> > { const subscribers = await this.sql< - Array<{ address: `0x${string}`; cloud_messaging_token: string }> + Array<{ + address: `0x${string}`; + device_uuid: Uuid; + cloud_messaging_token: string; + }> >` - SELECT a.address, pnd.cloud_messaging_token + SELECT a.address, nd.device_uuid, nd.cloud_messaging_token FROM notification_subscriptions ns JOIN accounts a ON ns.account_id = a.id JOIN push_notification_devices pnd ON ns.push_notification_device_id = pnd.id @@ -173,6 +178,7 @@ export class NotificationsDatasource implements INotificationsDatasource { return subscribers.map((subscriber) => { return { subscriber: subscriber.address, + deviceUuid: subscriber.device_uuid, cloudMessagingToken: subscriber.cloud_messaging_token, }; }); diff --git a/src/datasources/push-notifications-api/firebase-cloud-messaging-api.service.ts b/src/datasources/push-notifications-api/firebase-cloud-messaging-api.service.ts index 58e92f01f0..95198f20d5 100644 --- a/src/datasources/push-notifications-api/firebase-cloud-messaging-api.service.ts +++ b/src/datasources/push-notifications-api/firebase-cloud-messaging-api.service.ts @@ -96,6 +96,7 @@ export class FirebaseCloudMessagingApiService implements IPushNotificationsApi { * * @returns - OAuth2 token */ + // TODO: Use CacheFirstDataSource private async getOauth2Token(): Promise { const cacheDir = CacheRouter.getFirebaseOAuth2TokenCacheDir(); const cachedToken = await this.cacheService.get(cacheDir); diff --git a/src/domain/hooks/hooks.repository.ts b/src/domain/hooks/hooks.repository.ts index 214924f803..239fd9d8d8 100644 --- a/src/domain/hooks/hooks.repository.ts +++ b/src/domain/hooks/hooks.repository.ts @@ -386,7 +386,7 @@ export class HooksRepository implements IHooksRepository { } const subscriptions = - await this.notificationsRepository.getSubscribersWithTokensBySafe({ + await this.notificationsRepository.getSubscribersBySafe({ chainId: event.chainId, safeAddress: event.address, }); @@ -404,8 +404,12 @@ export class HooksRepository implements IHooksRepository { } return this.notificationsRepository - .enqueueNotification(subscription.cloudMessagingToken, { - data, + .enqueueNotification({ + token: subscription.cloudMessagingToken, + deviceUuid: subscription.deviceUuid, + notification: { + data, + }, }) .then(() => { this.loggingService.info('Notification sent successfully'); diff --git a/src/domain/interfaces/notifications.datasource.interface.ts b/src/domain/interfaces/notifications.datasource.interface.ts index 3705e212c9..957a47c8ef 100644 --- a/src/domain/interfaces/notifications.datasource.interface.ts +++ b/src/domain/interfaces/notifications.datasource.interface.ts @@ -16,12 +16,13 @@ export interface INotificationsDatasource { safeAddress: `0x${string}`; }): Promise>; - getSubscribersWithTokensBySafe(args: { + getSubscribersBySafe(args: { chainId: string; safeAddress: `0x${string}`; }): Promise< Array<{ subscriber: `0x${string}`; + deviceUuid: Uuid; cloudMessagingToken: string; }> >; diff --git a/src/domain/notifications/notifications.repository.v2.interface.ts b/src/domain/notifications/notifications.repository.v2.interface.ts index aed501b68a..6fc44eb14a 100644 --- a/src/domain/notifications/notifications.repository.v2.interface.ts +++ b/src/domain/notifications/notifications.repository.v2.interface.ts @@ -8,14 +8,16 @@ import { NotificationsDatasourceModule } from '@/datasources/accounts/notificati import { SafeRepositoryModule } from '@/domain/safe/safe.repository.interface'; import { DelegatesV2RepositoryModule } from '@/domain/delegate/v2/delegates.v2.repository.interface'; import configuration from '@/config/entities/__tests__/configuration'; +import { NotificationType } from '@/domain/notifications/entities-v2/notification-type.entity'; export const INotificationsRepositoryV2 = Symbol('INotificationsRepositoryV2'); export interface INotificationsRepositoryV2 { - enqueueNotification( - token: string, - notification: FirebaseNotification, - ): Promise; + enqueueNotification(args: { + token: string; + deviceUuid: Uuid; + notification: FirebaseNotification; + }): Promise; upsertSubscriptions(args: UpsertSubscriptionsDto): Promise<{ deviceUuid: Uuid; @@ -26,14 +28,15 @@ export interface INotificationsRepositoryV2 { deviceUuid: Uuid; chainId: string; safeAddress: `0x${string}`; - }): Promise; + }): Promise>; - getSubscribersWithTokensBySafe(args: { + getSubscribersBySafe(args: { chainId: string; safeAddress: `0x${string}`; }): Promise< Array<{ subscriber: `0x${string}`; + deviceUuid: Uuid; cloudMessagingToken: string; }> >; @@ -52,17 +55,17 @@ export interface INotificationsRepositoryV2 { * to not require database access when push notifications are disabled. */ class NoopNotificationsRepositoryV2 implements INotificationsRepositoryV2 { - enqueueNotification( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _token: string, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _notification: FirebaseNotification, - ): Promise { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + enqueueNotification(_args: { + token: string; + deviceUuid: string; + notification: FirebaseNotification; + }): Promise { return Promise.resolve(); } // eslint-disable-next-line @typescript-eslint/no-unused-vars - async upsertSubscriptions(_args: UpsertSubscriptionsDto): Promise<{ + upsertSubscriptions(_args: UpsertSubscriptionsDto): Promise<{ deviceUuid: Uuid; }> { return Promise.resolve({ deviceUuid: crypto.randomUUID() }); @@ -74,17 +77,18 @@ class NoopNotificationsRepositoryV2 implements INotificationsRepositoryV2 { deviceUuid: Uuid; chainId: string; safeAddress: `0x${string}`; - }): Promise { - return Promise.resolve(); + }): Promise> { + return Promise.resolve([]); } // eslint-disable-next-line @typescript-eslint/no-unused-vars - getSubscribersWithTokensBySafe(_args: { + getSubscribersBySafe(_args: { chainId: string; safeAddress: `0x${string}`; }): Promise< Array<{ subscriber: `0x${string}`; + deviceUuid: Uuid; cloudMessagingToken: string; }> > { diff --git a/src/domain/notifications/notifications.repository.v2.ts b/src/domain/notifications/notifications.repository.v2.ts index 57332ac92f..055a9b6cae 100644 --- a/src/domain/notifications/notifications.repository.v2.ts +++ b/src/domain/notifications/notifications.repository.v2.ts @@ -4,12 +4,46 @@ import { INotificationsDatasource } from '@/domain/interfaces/notifications.data import { IPushNotificationsApi } from '@/domain/interfaces/push-notifications-api.interface'; import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; import { INotificationsRepositoryV2 } from '@/domain/notifications/notifications.repository.v2.interface'; -import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; +import { + Inject, + Injectable, + UnauthorizedException, + UnprocessableEntityException, +} from '@nestjs/common'; import { ISafeRepository } from '@/domain/safe/safe.repository.interface'; import { IDelegatesV2Repository } from '@/domain/delegate/v2/delegates.v2.repository.interface'; +import { asError } from '@/logging/utils'; +import { ILoggingService, LoggingService } from '@/logging/logging.interface'; +import { NotificationType } from '@/domain/notifications/entities-v2/notification.entity'; @Injectable() export class NotificationsRepositoryV2 implements INotificationsRepositoryV2 { + /** + * Firebase REST error message for the HTTP v1 API relevant to token registration: + * + * This error can be caused by missing registration tokens, or unregistered tokens. + * + * Missing Registration: If the message's target is a token value, check that the + * request contains a registration token. + * + * Not registered: An existing registration token may cease to be valid in a number + * of scenarios, including: + * - If the client app unregisters with FCM. + * - If the client app is automatically unregistered, which can happen if the user + * uninstalls the application. For example, on iOS, if the APNs Feedback Service + * reported the APNs token as invalid. + * - If the registration token expires (for example, Google might decide to refresh + * registration tokens, or the APNs token has expired for iOS devices). + * - If the client app is updated but the new version is not configured to receive + * messages. + * + * For all these cases, remove this registration token from the app server and stop + * using it to send messages. + * + * @see https://firebase.google.com/docs/cloud-messaging/send-message#rest + */ + static readonly UnregisteredErrorMessage = 'UNREGISTERED'; + constructor( @Inject(IPushNotificationsApi) private readonly pushNotificationsApi: IPushNotificationsApi, @@ -19,13 +53,34 @@ export class NotificationsRepositoryV2 implements INotificationsRepositoryV2 { private readonly safeRepository: ISafeRepository, @Inject(IDelegatesV2Repository) private readonly delegatesRepository: IDelegatesV2Repository, + @Inject(LoggingService) + private readonly loggingService: ILoggingService, ) {} - enqueueNotification( - token: string, - notification: FirebaseNotification, - ): Promise { - return this.pushNotificationsApi.enqueueNotification(token, notification); + async enqueueNotification(args: { + token: string; + deviceUuid: Uuid; + notification: FirebaseNotification; + }): Promise { + try { + return this.pushNotificationsApi.enqueueNotification( + args.token, + args.notification, + ); + } catch (e) { + if (this.isTokenUnregistered(e)) { + await this.deleteDevice(args.deviceUuid).catch(() => null); + } else { + this.loggingService.info(`Failed to enqueue notification: ${e}`); + throw new UnprocessableEntityException(); + } + } + } + + private isTokenUnregistered(e: unknown): boolean { + return ( + asError(e).message === NotificationsRepositoryV2.UnregisteredErrorMessage + ); } async upsertSubscriptions(args: UpsertSubscriptionsDto): Promise<{ @@ -81,20 +136,21 @@ export class NotificationsRepositoryV2 implements INotificationsRepositoryV2 { deviceUuid: Uuid; chainId: string; safeAddress: `0x${string}`; - }): Promise { + }): Promise> { return this.notificationsDatasource.getSafeSubscription(args); } - getSubscribersWithTokensBySafe(args: { + getSubscribersBySafe(args: { chainId: string; safeAddress: `0x${string}`; }): Promise< Array<{ subscriber: `0x${string}`; + deviceUuid: Uuid; cloudMessagingToken: string; }> > { - return this.notificationsDatasource.getSubscribersWithTokensBySafe(args); + return this.notificationsDatasource.getSubscribersBySafe(args); } deleteSubscription(args: { diff --git a/src/routes/hooks/hooks-notifications.spec.ts b/src/routes/hooks/hooks-notifications.spec.ts index 738de98a7f..25e7287309 100644 --- a/src/routes/hooks/hooks-notifications.spec.ts +++ b/src/routes/hooks/hooks-notifications.spec.ts @@ -49,6 +49,8 @@ import { confirmationBuilder } from '@/domain/safe/entities/__tests__/multisig-t import { messageBuilder } from '@/domain/messages/entities/__tests__/message.builder'; import { messageCreatedEventBuilder } from '@/routes/hooks/entities/__tests__/message-created.builder'; import { messageConfirmationBuilder } from '@/domain/messages/entities/__tests__/message-confirmation.builder'; +import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; +import { NotificationsRepositoryV2Module } from '@/domain/notifications/notifications.repository.v2.interface'; describe('Post Hook Events for Notifications (Unit)', () => { let app: INestApplication; @@ -141,12 +143,11 @@ describe('Post Hook Events for Notifications (Unit)', () => { }, () => ({ subscriber: getAddress(faker.finance.ethereumAddress()), + deviceUuid: faker.string.uuid() as Uuid, cloudMessagingToken: faker.string.alphanumeric(), }), ); - notificationsDatasource.getSubscribersWithTokensBySafe.mockResolvedValue( - subscribers, - ); + notificationsDatasource.getSubscribersBySafe.mockResolvedValue(subscribers); await request(app.getHttpServer()) .post(`/hooks/events`) @@ -181,10 +182,11 @@ describe('Post Hook Events for Notifications (Unit)', () => { }, () => ({ subscriber: getAddress(faker.finance.ethereumAddress()), + deviceUuid: faker.string.uuid() as Uuid, cloudMessagingToken: faker.string.alphanumeric(), }), ); - notificationsDatasource.getSubscribersWithTokensBySafe.mockResolvedValue( + notificationsDatasource.getSubscribersBySafe.mockResolvedValue( subscribers, ); @@ -257,10 +259,11 @@ describe('Post Hook Events for Notifications (Unit)', () => { }, () => ({ subscriber: getAddress(faker.finance.ethereumAddress()), + deviceUuid: faker.string.uuid() as Uuid, cloudMessagingToken: faker.string.alphanumeric(), }), ); - notificationsDatasource.getSubscribersWithTokensBySafe.mockResolvedValue( + notificationsDatasource.getSubscribersBySafe.mockResolvedValue( subscribers, ); @@ -328,12 +331,11 @@ describe('Post Hook Events for Notifications (Unit)', () => { }, () => ({ subscriber: getAddress(faker.finance.ethereumAddress()), + deviceUuid: faker.string.uuid() as Uuid, cloudMessagingToken: faker.string.alphanumeric(), }), ); - notificationsDatasource.getSubscribersWithTokensBySafe.mockResolvedValue( - subscribers, - ); + notificationsDatasource.getSubscribersBySafe.mockResolvedValue(subscribers); networkService.get.mockImplementation(({ url }) => { if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { @@ -397,12 +399,11 @@ describe('Post Hook Events for Notifications (Unit)', () => { }, () => ({ subscriber: getAddress(faker.finance.ethereumAddress()), + deviceUuid: faker.string.uuid() as Uuid, cloudMessagingToken: faker.string.alphanumeric(), }), ); - notificationsDatasource.getSubscribersWithTokensBySafe.mockResolvedValue( - subscribers, - ); + notificationsDatasource.getSubscribersBySafe.mockResolvedValue(subscribers); networkService.get.mockImplementation(({ url }) => { if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { @@ -444,12 +445,11 @@ describe('Post Hook Events for Notifications (Unit)', () => { }, () => ({ subscriber: getAddress(faker.finance.ethereumAddress()), + deviceUuid: faker.string.uuid() as Uuid, cloudMessagingToken: faker.string.alphanumeric(), }), ); - notificationsDatasource.getSubscribersWithTokensBySafe.mockResolvedValue( - subscribers, - ); + notificationsDatasource.getSubscribersBySafe.mockResolvedValue(subscribers); const multisigTransaction = multisigTransactionBuilder() .with( 'confirmations', @@ -512,12 +512,11 @@ describe('Post Hook Events for Notifications (Unit)', () => { }, () => ({ subscriber: getAddress(faker.finance.ethereumAddress()), + deviceUuid: faker.string.uuid() as Uuid, cloudMessagingToken: faker.string.alphanumeric(), }), ); - notificationsDatasource.getSubscribersWithTokensBySafe.mockResolvedValue( - subscribers, - ); + notificationsDatasource.getSubscribersBySafe.mockResolvedValue(subscribers); networkService.get.mockImplementation(({ url }) => { if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { @@ -581,12 +580,11 @@ describe('Post Hook Events for Notifications (Unit)', () => { }, () => ({ subscriber: getAddress(faker.finance.ethereumAddress()), + deviceUuid: faker.string.uuid() as Uuid, cloudMessagingToken: faker.string.alphanumeric(), }), ); - notificationsDatasource.getSubscribersWithTokensBySafe.mockResolvedValue( - subscribers, - ); + notificationsDatasource.getSubscribersBySafe.mockResolvedValue(subscribers); networkService.get.mockImplementation(({ url }) => { if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { @@ -628,12 +626,11 @@ describe('Post Hook Events for Notifications (Unit)', () => { }, () => ({ subscriber: getAddress(faker.finance.ethereumAddress()), + deviceUuid: faker.string.uuid() as Uuid, cloudMessagingToken: faker.string.alphanumeric(), }), ); - notificationsDatasource.getSubscribersWithTokensBySafe.mockResolvedValue( - subscribers, - ); + notificationsDatasource.getSubscribersBySafe.mockResolvedValue(subscribers); const message = messageBuilder() .with('messageHash', event.messageHash as `0x${string}`) .with( @@ -681,6 +678,8 @@ describe('Post Hook Events for Notifications (Unit)', () => { expect(pushNotificationsApi.enqueueNotification).not.toHaveBeenCalled(); }); + it.todo('should cleanup unregistered tokens'); + it('should not fail to send all notifications if one throws', async () => { const events = [ chainUpdateEventBuilder().build(), From b59e7f7fd991d185f101a58225ce0e451d972e1d Mon Sep 17 00:00:00 2001 From: iamacook Date: Wed, 24 Jul 2024 13:38:12 +0200 Subject: [PATCH 25/37] Fix tests and cleanup stale tokens --- src/app.module.ts | 10 +- .../notifications.datasource.spec.ts | 2 + .../hooks/hooks.repository.interface.ts | 31 +- src/domain/hooks/hooks.repository.ts | 408 +++++++++++++++++- .../notifications.repository.v2.interface.ts | 116 +---- .../notifications.repository.v2.ts | 18 +- src/routes/hooks/hooks-notifications.spec.ts | 45 +- src/routes/hooks/hooks.module.ts | 16 +- 8 files changed, 522 insertions(+), 124 deletions(-) diff --git a/src/app.module.ts b/src/app.module.ts index ff4640830f..123d9d7e81 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -18,7 +18,10 @@ import { CommunityModule } from '@/routes/community/community.module'; import { ContractsModule } from '@/routes/contracts/contracts.module'; import { DataDecodedModule } from '@/routes/data-decode/data-decoded.module'; import { DelegatesModule } from '@/routes/delegates/delegates.module'; -import { HooksModule } from '@/routes/hooks/hooks.module'; +import { + HooksModule, + HooksModuleWithNotifications, +} from '@/routes/hooks/hooks.module'; import { SafeAppsModule } from '@/routes/safe-apps/safe-apps.module'; import { HealthModule } from '@/routes/health/health.module'; import { OwnersModule } from '@/routes/owners/owners.module'; @@ -58,6 +61,7 @@ export class AppModule implements NestModule { email: isEmailFeatureEnabled, confirmationView: isConfirmationViewEnabled, delegatesV2: isDelegatesV2Enabled, + pushNotifications: isPushNotificationsEnabled, } = configFactory()['features']; return { @@ -82,7 +86,9 @@ export class AppModule implements NestModule { : []), EstimationsModule, HealthModule, - HooksModule, + ...(isPushNotificationsEnabled + ? [HooksModuleWithNotifications] + : [HooksModule]), MessagesModule, NotificationsModule, OwnersModule, diff --git a/src/datasources/accounts/notifications/notifications.datasource.spec.ts b/src/datasources/accounts/notifications/notifications.datasource.spec.ts index fc827225db..37843c106c 100644 --- a/src/datasources/accounts/notifications/notifications.datasource.spec.ts +++ b/src/datasources/accounts/notifications/notifications.datasource.spec.ts @@ -381,10 +381,12 @@ describe('NotificationsDatasource', () => { ).resolves.toStrictEqual([ { subscriber: upsertSubscriptionsDto.account, + deviceUuid: upsertSubscriptionsDto.deviceUuid!, cloudMessagingToken: upsertSubscriptionsDto.cloudMessagingToken, }, { subscriber: secondUpsertSubscriptionsDto.account, + deviceUuid: secondUpsertSubscriptionsDto.deviceUuid!, cloudMessagingToken: secondUpsertSubscriptionsDto.cloudMessagingToken, }, ]); diff --git a/src/domain/hooks/hooks.repository.interface.ts b/src/domain/hooks/hooks.repository.interface.ts index df78581b3c..048c078908 100644 --- a/src/domain/hooks/hooks.repository.interface.ts +++ b/src/domain/hooks/hooks.repository.interface.ts @@ -1,9 +1,11 @@ -import configuration from '@/config/entities/configuration'; import { BalancesRepositoryModule } from '@/domain/balances/balances.repository.interface'; import { BlockchainRepositoryModule } from '@/domain/blockchain/blockchain.repository.interface'; import { ChainsRepositoryModule } from '@/domain/chains/chains.repository.interface'; import { CollectiblesRepositoryModule } from '@/domain/collectibles/collectibles.repository.interface'; -import { HooksRepository } from '@/domain/hooks/hooks.repository'; +import { + HooksRepository, + HooksRepositoryWithNotifications, +} from '@/domain/hooks/hooks.repository'; import { MessagesRepositoryModule } from '@/domain/messages/messages.repository.interface'; import { NotificationsRepositoryV2Module } from '@/domain/notifications/notifications.repository.v2.interface'; import { QueuesRepositoryModule } from '@/domain/queues/queues-repository.interface'; @@ -26,7 +28,30 @@ export interface IHooksRepository { ChainsRepositoryModule, CollectiblesRepositoryModule, MessagesRepositoryModule, - NotificationsRepositoryV2Module.forRoot(configuration), + NotificationsRepositoryV2Module, + SafeAppsRepositoryModule, + SafeRepositoryModule, + TransactionsRepositoryModule, + QueuesRepositoryModule, + ], + providers: [ + { provide: IHooksRepository, useClass: HooksRepositoryWithNotifications }, + ], + exports: [IHooksRepository], +}) +export class HooksRepositoryWithNotificationsModule {} + +// TODO: Remove after notifications FF is enables +// Note: trying to convert this into a dynamic module proved to be too complex +// due to config injection issues from the ConfigurationService so this is a +// temporary solution +@Module({ + imports: [ + BalancesRepositoryModule, + BlockchainRepositoryModule, + ChainsRepositoryModule, + CollectiblesRepositoryModule, + MessagesRepositoryModule, SafeAppsRepositoryModule, SafeRepositoryModule, TransactionsRepositoryModule, diff --git a/src/domain/hooks/hooks.repository.ts b/src/domain/hooks/hooks.repository.ts index 239fd9d8d8..b621d07a13 100644 --- a/src/domain/hooks/hooks.repository.ts +++ b/src/domain/hooks/hooks.repository.ts @@ -36,10 +36,9 @@ import { } from '@/domain/notifications/entities-v2/notification.entity'; @Injectable() -export class HooksRepository implements IHooksRepository { +export class HooksRepositoryWithNotifications implements IHooksRepository { private static readonly HOOK_TYPE = 'hook'; private readonly queueName: string; - private readonly isPushNotificationsEnabled: boolean; constructor( @Inject(IBalancesRepository) @@ -68,10 +67,6 @@ export class HooksRepository implements IHooksRepository { private readonly notificationsRepository: INotificationsRepositoryV2, ) { this.queueName = this.configurationService.getOrThrow('amqp.queue'); - this.isPushNotificationsEnabled = - this.configurationService.getOrThrow( - 'features.pushNotifications', - ); } onModuleInit(): Promise { @@ -92,9 +87,7 @@ export class HooksRepository implements IHooksRepository { async onEvent(event: Event): Promise { return Promise.allSettled([ this.onEventClearCache(event), - ...(this.isPushNotificationsEnabled - ? [this.onEventEnqueueNotifications(event)] - : []), + this.onEventEnqueueNotifications(event), ]).finally(() => { this.onEventLog(event); }); @@ -574,6 +567,403 @@ export class HooksRepository implements IHooksRepository { } } + private _logSafeTxEvent( + event: Event & { address: string; safeTxHash: string }, + ): void { + this.loggingService.info({ + type: HooksRepositoryWithNotifications.HOOK_TYPE, + eventType: event.type, + address: event.address, + chainId: event.chainId, + safeTxHash: event.safeTxHash, + }); + } + + private _logTxEvent( + event: Event & { address: string; txHash: string }, + ): void { + this.loggingService.info({ + type: HooksRepositoryWithNotifications.HOOK_TYPE, + eventType: event.type, + address: event.address, + chainId: event.chainId, + txHash: event.txHash, + }); + } + + private _logMessageEvent( + event: Event & { address: string; messageHash: string }, + ): void { + this.loggingService.info({ + type: HooksRepositoryWithNotifications.HOOK_TYPE, + eventType: event.type, + address: event.address, + chainId: event.chainId, + messageHash: event.messageHash, + }); + } + + private _logEvent(event: Event): void { + this.loggingService.info({ + type: HooksRepositoryWithNotifications.HOOK_TYPE, + eventType: event.type, + chainId: event.chainId, + }); + } +} + +// TODO: Remove after notifications FF is enables +// Note: trying to convert this into a dynamic module proved to be too complex +// due to config injection issues from the ConfigurationService so this is a +// temporary solution +@Injectable() +export class HooksRepository implements IHooksRepository { + private static readonly HOOK_TYPE = 'hook'; + private readonly queueName: string; + + constructor( + @Inject(IBalancesRepository) + private readonly balancesRepository: IBalancesRepository, + @Inject(IBlockchainRepository) + private readonly blockchainRepository: IBlockchainRepository, + @Inject(IChainsRepository) + private readonly chainsRepository: IChainsRepository, + @Inject(ICollectiblesRepository) + private readonly collectiblesRepository: ICollectiblesRepository, + @Inject(IMessagesRepository) + private readonly messagesRepository: IMessagesRepository, + @Inject(ISafeAppsRepository) + private readonly safeAppsRepository: ISafeAppsRepository, + @Inject(ISafeRepository) + private readonly safeRepository: ISafeRepository, + @Inject(ITransactionsRepository) + private readonly transactionsRepository: ITransactionsRepository, + @Inject(LoggingService) + private readonly loggingService: ILoggingService, + @Inject(IQueuesRepository) + private readonly queuesRepository: IQueuesRepository, + @Inject(IConfigurationService) + private readonly configurationService: IConfigurationService, + ) { + this.queueName = this.configurationService.getOrThrow('amqp.queue'); + } + + onModuleInit(): Promise { + return this.queuesRepository.subscribe( + this.queueName, + async (msg: ConsumeMessage) => { + try { + const content = JSON.parse(msg.content.toString()); + const event: Event = EventSchema.parse(content); + await this.onEvent(event); + } catch (err) { + this.loggingService.error(err); + } + }, + ); + } + + async onEvent(event: Event): Promise { + return this.onEventClearCache(event).finally(() => { + this.onEventLog(event); + }); + } + + private async onEventClearCache(event: Event): Promise { + const promises: Promise[] = []; + switch (event.type) { + // A new pending multisig transaction affects: + // queued transactions – clear multisig transactions + // the pending transaction – clear multisig transaction + case TransactionEventType.PENDING_MULTISIG_TRANSACTION: + promises.push( + this.safeRepository.clearMultisigTransactions({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearMultisigTransaction({ + chainId: event.chainId, + safeTransactionHash: event.safeTxHash, + }), + ); + break; + // A deleted multisig transaction affects: + // queued transactions – clear multisig transactions + // the pending transaction – clear multisig transaction + case TransactionEventType.DELETED_MULTISIG_TRANSACTION: + promises.push( + this.safeRepository.clearMultisigTransactions({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearMultisigTransaction({ + chainId: event.chainId, + safeTransactionHash: event.safeTxHash, + }), + ); + break; + // An executed module transaction might affect: + // - the list of all executed transactions for the safe + // - the list of module transactions for the safe + // - the safe configuration + case TransactionEventType.MODULE_TRANSACTION: + promises.push( + this.safeRepository.clearAllExecutedTransactions({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearModuleTransactions({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearSafe({ + chainId: event.chainId, + address: event.address, + }), + ); + break; + // A new executed multisig transaction affects: + // - the collectibles that the safe has + // - the list of all executed transactions for the safe + // - the transfers for that safe + // - queued transactions and history – clear multisig transactions + // - the transaction executed – clear multisig transaction + // - the safe configuration - clear safe info + case TransactionEventType.EXECUTED_MULTISIG_TRANSACTION: + promises.push( + this.collectiblesRepository.clearCollectibles({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearAllExecutedTransactions({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearTransfers({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearMultisigTransactions({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearMultisigTransaction({ + chainId: event.chainId, + safeTransactionHash: event.safeTxHash, + }), + this.safeRepository.clearSafe({ + chainId: event.chainId, + address: event.address, + }), + ); + break; + // A new confirmation for a pending transaction affects: + // - queued transactions – clear multisig transactions + // - the pending transaction – clear multisig transaction + case TransactionEventType.NEW_CONFIRMATION: + promises.push( + this.safeRepository.clearMultisigTransactions({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearMultisigTransaction({ + chainId: event.chainId, + safeTransactionHash: event.safeTxHash, + }), + ); + break; + // Incoming ether affects: + // - the balance of the safe - clear safe balance + // - the list of all executed transactions (including transfers) for the safe + // - the incoming transfers for that safe + case TransactionEventType.INCOMING_ETHER: + promises.push( + this.balancesRepository.clearBalances({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearAllExecutedTransactions({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearMultisigTransactions({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearTransfers({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearIncomingTransfers({ + chainId: event.chainId, + safeAddress: event.address, + }), + ); + break; + // Outgoing ether affects: + // - the balance of the safe - clear safe balance + // - the list of all executed transactions for the safe + // - queued transactions and history – clear multisig transactions + // - the transfers for that safe + case TransactionEventType.OUTGOING_ETHER: + promises.push( + this.balancesRepository.clearBalances({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearAllExecutedTransactions({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearMultisigTransactions({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearTransfers({ + chainId: event.chainId, + safeAddress: event.address, + }), + ); + break; + // An incoming token affects: + // - the balance of the safe - clear safe balance + // - the collectibles that the safe has + // - the list of all executed transactions (including transfers) for the safe + // - queued transactions and history – clear multisig transactions + // - the transfers for that safe + // - the incoming transfers for that safe + case TransactionEventType.INCOMING_TOKEN: + promises.push( + this.balancesRepository.clearBalances({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.collectiblesRepository.clearCollectibles({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearAllExecutedTransactions({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearMultisigTransactions({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearTransfers({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearIncomingTransfers({ + chainId: event.chainId, + safeAddress: event.address, + }), + ); + break; + // An outgoing token affects: + // - the balance of the safe - clear safe balance + // - the collectibles that the safe has + // - the list of all executed transactions (including transfers) for the safe + // - queued transactions and history – clear multisig transactions + // - the transfers for that safe + case TransactionEventType.OUTGOING_TOKEN: + promises.push( + this.balancesRepository.clearBalances({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.collectiblesRepository.clearCollectibles({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearAllExecutedTransactions({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearMultisigTransactions({ + chainId: event.chainId, + safeAddress: event.address, + }), + this.safeRepository.clearTransfers({ + chainId: event.chainId, + safeAddress: event.address, + }), + ); + break; + // A message created affects: + // - the messages associated to the Safe + case TransactionEventType.MESSAGE_CREATED: + promises.push( + this.messagesRepository.clearMessagesBySafe({ + chainId: event.chainId, + safeAddress: event.address, + }), + ); + break; + // A new message confirmation affects: + // - the message itself + // - the messages associated to the Safe + case TransactionEventType.MESSAGE_CONFIRMATION: + promises.push( + this.messagesRepository.clearMessagesByHash({ + chainId: event.chainId, + messageHash: event.messageHash, + }), + this.messagesRepository.clearMessagesBySafe({ + chainId: event.chainId, + safeAddress: event.address, + }), + ); + break; + case ConfigEventType.CHAIN_UPDATE: + promises.push( + this.chainsRepository.clearChain(event.chainId).then(() => { + // RPC may have changed + this.blockchainRepository.clearApi(event.chainId); + // Transaction Service may have changed + this.transactionsRepository.clearApi(event.chainId); + this.balancesRepository.clearApi(event.chainId); + }), + ); + break; + case ConfigEventType.SAFE_APPS_UPDATE: + promises.push(this.safeAppsRepository.clearSafeApps(event.chainId)); + break; + case TransactionEventType.SAFE_CREATED: + promises.push(this.safeRepository.clearIsSafe(event)); + break; + } + return Promise.all(promises); + } + + private onEventLog(event: Event): void { + switch (event.type) { + case TransactionEventType.PENDING_MULTISIG_TRANSACTION: + case TransactionEventType.DELETED_MULTISIG_TRANSACTION: + case TransactionEventType.EXECUTED_MULTISIG_TRANSACTION: + case TransactionEventType.NEW_CONFIRMATION: + this._logSafeTxEvent(event); + break; + case TransactionEventType.MODULE_TRANSACTION: + case TransactionEventType.INCOMING_ETHER: + case TransactionEventType.OUTGOING_ETHER: + case TransactionEventType.INCOMING_TOKEN: + case TransactionEventType.OUTGOING_TOKEN: + this._logTxEvent(event); + break; + case TransactionEventType.MESSAGE_CREATED: + case TransactionEventType.MESSAGE_CONFIRMATION: + this._logMessageEvent(event); + break; + case ConfigEventType.CHAIN_UPDATE: + case ConfigEventType.SAFE_APPS_UPDATE: + this._logEvent(event); + break; + case TransactionEventType.SAFE_CREATED: + break; + } + } + private _logSafeTxEvent( event: Event & { address: string; safeTxHash: string }, ): void { diff --git a/src/domain/notifications/notifications.repository.v2.interface.ts b/src/domain/notifications/notifications.repository.v2.interface.ts index 6fc44eb14a..8b31906b6b 100644 --- a/src/domain/notifications/notifications.repository.v2.interface.ts +++ b/src/domain/notifications/notifications.repository.v2.interface.ts @@ -3,11 +3,10 @@ import { FirebaseNotification } from '@/datasources/push-notifications-api/entit import { PushNotificationsApiModule } from '@/datasources/push-notifications-api/push-notifications-api.module'; import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; import { NotificationsRepositoryV2 } from '@/domain/notifications/notifications.repository.v2'; -import { DynamicModule, Module } from '@nestjs/common'; +import { Module } from '@nestjs/common'; import { NotificationsDatasourceModule } from '@/datasources/accounts/notifications/notifications.datasource.module'; import { SafeRepositoryModule } from '@/domain/safe/safe.repository.interface'; import { DelegatesV2RepositoryModule } from '@/domain/delegate/v2/delegates.v2.repository.interface'; -import configuration from '@/config/entities/__tests__/configuration'; import { NotificationType } from '@/domain/notifications/entities-v2/notification-type.entity'; export const INotificationsRepositoryV2 = Symbol('INotificationsRepositoryV2'); @@ -50,100 +49,19 @@ export interface INotificationsRepositoryV2 { deleteDevice(deviceUuid: Uuid): Promise; } -/** - * The following is used for feature flagging. All functions are noops in order - * to not require database access when push notifications are disabled. - */ -class NoopNotificationsRepositoryV2 implements INotificationsRepositoryV2 { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - enqueueNotification(_args: { - token: string; - deviceUuid: string; - notification: FirebaseNotification; - }): Promise { - return Promise.resolve(); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - upsertSubscriptions(_args: UpsertSubscriptionsDto): Promise<{ - deviceUuid: Uuid; - }> { - return Promise.resolve({ deviceUuid: crypto.randomUUID() }); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - getSafeSubscription(_args: { - account: `0x${string}`; - deviceUuid: Uuid; - chainId: string; - safeAddress: `0x${string}`; - }): Promise> { - return Promise.resolve([]); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - getSubscribersBySafe(_args: { - chainId: string; - safeAddress: `0x${string}`; - }): Promise< - Array<{ - subscriber: `0x${string}`; - deviceUuid: Uuid; - cloudMessagingToken: string; - }> - > { - return Promise.resolve([]); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - deleteSubscription(_args: { - account: `0x${string}`; - chainId: string; - safeAddress: `0x${string}`; - }): Promise { - return Promise.resolve(); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - deleteDevice(_deviceUuid: Uuid): Promise { - return Promise.resolve(); - } -} - -@Module({}) -export class NotificationsRepositoryV2Module { - static forRoot(config: typeof configuration): DynamicModule { - const isPushNotificationsEnabled = config().features.pushNotifications; - - if (!isPushNotificationsEnabled) { - return { - module: NotificationsRepositoryV2Module, - imports: [], - providers: [ - { - provide: INotificationsRepositoryV2, - useClass: NoopNotificationsRepositoryV2, - }, - ], - exports: [INotificationsRepositoryV2], - }; - } - - return { - module: NotificationsRepositoryV2Module, - imports: [ - PushNotificationsApiModule, - NotificationsDatasourceModule, - SafeRepositoryModule, - DelegatesV2RepositoryModule, - ], - providers: [ - { - provide: INotificationsRepositoryV2, - useClass: NotificationsRepositoryV2, - }, - ], - exports: [INotificationsRepositoryV2], - }; - } -} +@Module({ + imports: [ + PushNotificationsApiModule, + NotificationsDatasourceModule, + SafeRepositoryModule, + DelegatesV2RepositoryModule, + ], + providers: [ + { + provide: INotificationsRepositoryV2, + useClass: NotificationsRepositoryV2, + }, + ], + exports: [INotificationsRepositoryV2], +}) +export class NotificationsRepositoryV2Module {} diff --git a/src/domain/notifications/notifications.repository.v2.ts b/src/domain/notifications/notifications.repository.v2.ts index 055a9b6cae..fd15a7d16d 100644 --- a/src/domain/notifications/notifications.repository.v2.ts +++ b/src/domain/notifications/notifications.repository.v2.ts @@ -12,9 +12,9 @@ import { } from '@nestjs/common'; import { ISafeRepository } from '@/domain/safe/safe.repository.interface'; import { IDelegatesV2Repository } from '@/domain/delegate/v2/delegates.v2.repository.interface'; -import { asError } from '@/logging/utils'; import { ILoggingService, LoggingService } from '@/logging/logging.interface'; import { NotificationType } from '@/domain/notifications/entities-v2/notification.entity'; +import { get } from 'lodash'; @Injectable() export class NotificationsRepositoryV2 implements INotificationsRepositoryV2 { @@ -42,7 +42,8 @@ export class NotificationsRepositoryV2 implements INotificationsRepositoryV2 { * * @see https://firebase.google.com/docs/cloud-messaging/send-message#rest */ - static readonly UnregisteredErrorMessage = 'UNREGISTERED'; + static readonly UnregisteredErrorCode = 404; + static readonly UnregisteredErrorStatus = 'UNREGISTERED'; constructor( @Inject(IPushNotificationsApi) @@ -63,7 +64,7 @@ export class NotificationsRepositoryV2 implements INotificationsRepositoryV2 { notification: FirebaseNotification; }): Promise { try { - return this.pushNotificationsApi.enqueueNotification( + await this.pushNotificationsApi.enqueueNotification( args.token, args.notification, ); @@ -77,10 +78,13 @@ export class NotificationsRepositoryV2 implements INotificationsRepositoryV2 { } } - private isTokenUnregistered(e: unknown): boolean { - return ( - asError(e).message === NotificationsRepositoryV2.UnregisteredErrorMessage - ); + private isTokenUnregistered(error: unknown): boolean { + const isNotFound = + get(error, 'code') === NotificationsRepositoryV2.UnregisteredErrorCode; + const isUnregistered = + get(error, 'status') === + NotificationsRepositoryV2.UnregisteredErrorStatus; + return isNotFound && isUnregistered; } async upsertSubscriptions(args: UpsertSubscriptionsDto): Promise<{ diff --git a/src/routes/hooks/hooks-notifications.spec.ts b/src/routes/hooks/hooks-notifications.spec.ts index 25e7287309..c8253eccac 100644 --- a/src/routes/hooks/hooks-notifications.spec.ts +++ b/src/routes/hooks/hooks-notifications.spec.ts @@ -50,7 +50,6 @@ import { messageBuilder } from '@/domain/messages/entities/__tests__/message.bui import { messageCreatedEventBuilder } from '@/routes/hooks/entities/__tests__/message-created.builder'; import { messageConfirmationBuilder } from '@/domain/messages/entities/__tests__/message-confirmation.builder'; import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; -import { NotificationsRepositoryV2Module } from '@/domain/notifications/notifications.repository.v2.interface'; describe('Post Hook Events for Notifications (Unit)', () => { let app: INestApplication; @@ -166,7 +165,6 @@ describe('Post Hook Events for Notifications (Unit)', () => { ); }); }); - it.each( [ incomingEtherEventBuilder().build(), @@ -678,7 +676,48 @@ describe('Post Hook Events for Notifications (Unit)', () => { expect(pushNotificationsApi.enqueueNotification).not.toHaveBeenCalled(); }); - it.todo('should cleanup unregistered tokens'); + it('should cleanup unregistered tokens', async () => { + // Events that are notified "as is" for simplicity + const event = faker.helpers.arrayElement([ + deletedMultisigTransactionEventBuilder().build(), + executedTransactionEventBuilder().build(), + moduleTransactionEventBuilder().build(), + ]); + const subscribers = Array.from( + { + length: faker.number.int({ min: 2, max: 5 }), + }, + () => ({ + subscriber: getAddress(faker.finance.ethereumAddress()), + deviceUuid: faker.string.uuid() as Uuid, + cloudMessagingToken: faker.string.alphanumeric(), + }), + ); + notificationsDatasource.getSubscribersBySafe.mockResolvedValue(subscribers); + + pushNotificationsApi.enqueueNotification + // Specific error regarding unregistered/stale tokens + // @see https://firebase.google.com/docs/cloud-messaging/send-message#rest + .mockRejectedValueOnce({ + code: 404, + message: faker.lorem.words(), + status: 'UNREGISTERED', + details: [], + }) + .mockResolvedValue(); + + await request(app.getHttpServer()) + .post(`/hooks/events`) + .set('Authorization', `Basic ${authToken}`) + .send(event) + .expect(202); + + expect(notificationsDatasource.deleteDevice).toHaveBeenCalledTimes(1); + expect(notificationsDatasource.deleteDevice).toHaveBeenNthCalledWith( + 1, + subscribers[0].deviceUuid, + ); + }); it('should not fail to send all notifications if one throws', async () => { const events = [ diff --git a/src/routes/hooks/hooks.module.ts b/src/routes/hooks/hooks.module.ts index 99cff20bf2..f818b236ab 100644 --- a/src/routes/hooks/hooks.module.ts +++ b/src/routes/hooks/hooks.module.ts @@ -1,8 +1,22 @@ import { Module } from '@nestjs/common'; import { HooksController } from '@/routes/hooks/hooks.controller'; -import { HooksRepositoryModule } from '@/domain/hooks/hooks.repository.interface'; +import { + HooksRepositoryModule, + HooksRepositoryWithNotificationsModule, +} from '@/domain/hooks/hooks.repository.interface'; import { HooksService } from '@/routes/hooks/hooks.service'; +@Module({ + imports: [HooksRepositoryWithNotificationsModule], + providers: [HooksService], + controllers: [HooksController], +}) +export class HooksModuleWithNotifications {} + +// TODO: Remove after notifications FF is enables +// Note: trying to convert this into a dynamic module proved to be too complex +// due to config injection issues from the ConfigurationService so this is a +// temporary solution @Module({ imports: [HooksRepositoryModule], providers: [HooksService], From b9354c99048548363eafbe936c6c412ebe06043c Mon Sep 17 00:00:00 2001 From: iamacook Date: Wed, 24 Jul 2024 15:39:10 +0200 Subject: [PATCH 26/37] Fix query --- .../accounts/notifications/notifications.datasource.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/datasources/accounts/notifications/notifications.datasource.ts b/src/datasources/accounts/notifications/notifications.datasource.ts index f2038ae390..147a084b37 100644 --- a/src/datasources/accounts/notifications/notifications.datasource.ts +++ b/src/datasources/accounts/notifications/notifications.datasource.ts @@ -163,7 +163,7 @@ export class NotificationsDatasource implements INotificationsDatasource { cloud_messaging_token: string; }> >` - SELECT a.address, nd.device_uuid, nd.cloud_messaging_token + SELECT a.address, pnd.device_uuid, pnd.cloud_messaging_token FROM notification_subscriptions ns JOIN accounts a ON ns.account_id = a.id JOIN push_notification_devices pnd ON ns.push_notification_device_id = pnd.id From 8088fcd6a7cbff6225df5244d1d9aac939647212 Mon Sep 17 00:00:00 2001 From: iamacook Date: Thu, 25 Jul 2024 16:35:08 +0200 Subject: [PATCH 27/37] Simplify upsertion logic --- .../notifications.repository.v2.ts | 25 ++++++------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/src/domain/notifications/notifications.repository.v2.ts b/src/domain/notifications/notifications.repository.v2.ts index fd15a7d16d..62ef027d57 100644 --- a/src/domain/notifications/notifications.repository.v2.ts +++ b/src/domain/notifications/notifications.repository.v2.ts @@ -90,8 +90,6 @@ export class NotificationsRepositoryV2 implements INotificationsRepositoryV2 { async upsertSubscriptions(args: UpsertSubscriptionsDto): Promise<{ deviceUuid: Uuid; }> { - const authorizedSafesToSubscribe: UpsertSubscriptionsDto['safes'] = []; - // Only allow owners or delegates to subscribe to notifications // We don't Promise.all getSafe/getDelegates to prevent unnecessary calls for (const safeToSubscribe of args.safes) { @@ -102,9 +100,8 @@ export class NotificationsRepositoryV2 implements INotificationsRepositoryV2 { }) .catch(() => null); - // Upsert owner - if (safe && safe.owners.includes(args.account)) { - authorizedSafesToSubscribe.push(safeToSubscribe); + const isOwner = !!safe?.owners.includes(args.account); + if (isOwner) { continue; } @@ -116,23 +113,17 @@ export class NotificationsRepositoryV2 implements INotificationsRepositoryV2 { }) .catch(() => null); - // Upsert delegate - if ( - delegates && - delegates.results.some(({ delegate }) => delegate === args.account) - ) { - authorizedSafesToSubscribe.push(safeToSubscribe); + const isDelegate = !!delegates?.results.some( + ({ delegate }) => delegate === args.account, + ); + if (isDelegate) { + continue; } - } - if (authorizedSafesToSubscribe.length === 0) { throw new UnauthorizedException(); } - return this.notificationsDatasource.upsertSubscriptions({ - ...args, - safes: authorizedSafesToSubscribe, - }); + return this.notificationsDatasource.upsertSubscriptions(args); } getSafeSubscription(args: { From 663d9016dd7226f0da55c1cef1048f4fa8ef02d3 Mon Sep 17 00:00:00 2001 From: iamacook Date: Sat, 27 Jul 2024 20:40:47 +0200 Subject: [PATCH 28/37] Add log and test coverage --- .../notifications.repository.v2.ts | 34 ++-- src/routes/hooks/hooks-notifications.spec.ts | 178 +++++++++++++++++- 2 files changed, 194 insertions(+), 18 deletions(-) diff --git a/src/domain/notifications/notifications.repository.v2.ts b/src/domain/notifications/notifications.repository.v2.ts index 62ef027d57..d38c575663 100644 --- a/src/domain/notifications/notifications.repository.v2.ts +++ b/src/domain/notifications/notifications.repository.v2.ts @@ -93,33 +93,35 @@ export class NotificationsRepositoryV2 implements INotificationsRepositoryV2 { // Only allow owners or delegates to subscribe to notifications // We don't Promise.all getSafe/getDelegates to prevent unnecessary calls for (const safeToSubscribe of args.safes) { - const safe = await this.safeRepository - .getSafe({ - chainId: safeToSubscribe.chainId, - address: safeToSubscribe.address, - }) - .catch(() => null); + const safe = await this.safeRepository.getSafe({ + chainId: safeToSubscribe.chainId, + address: safeToSubscribe.address, + }); const isOwner = !!safe?.owners.includes(args.account); if (isOwner) { continue; } - const delegates = await this.delegatesRepository - .getDelegates({ - chainId: safeToSubscribe.chainId, - safeAddress: safeToSubscribe.address, - delegate: args.account, - }) - .catch(() => null); + const delegates = await this.delegatesRepository.getDelegates({ + chainId: safeToSubscribe.chainId, + safeAddress: safeToSubscribe.address, + delegate: args.account, + }); - const isDelegate = !!delegates?.results.some( - ({ delegate }) => delegate === args.account, - ); + const isDelegate = !!delegates?.results.some((delegate) => { + return ( + delegate.delegate === args.account && + delegate.safe === safeToSubscribe.address + ); + }); if (isDelegate) { continue; } + this.loggingService.info( + `Non-owner/delegate ${args.account} tried to subscribe to Safe ${safeToSubscribe.address}`, + ); throw new UnauthorizedException(); } diff --git a/src/routes/hooks/hooks-notifications.spec.ts b/src/routes/hooks/hooks-notifications.spec.ts index c8253eccac..3724761851 100644 --- a/src/routes/hooks/hooks-notifications.spec.ts +++ b/src/routes/hooks/hooks-notifications.spec.ts @@ -474,11 +474,11 @@ describe('Post Hook Events for Notifications (Unit)', () => { }); } else if ( url === - `${chain.transactionService}/api/v1/safes/${event.address}/multisig-transactions/` + `${chain.transactionService}/api/v1/multisig-transactions/${event.safeTxHash}/` ) { return Promise.resolve({ status: 200, - data: pageBuilder().with('results', [multisigTransaction]).build(), + data: multisigTransaction, }); } else { return Promise.reject(`No matching rule for url: ${url}`); @@ -494,6 +494,93 @@ describe('Post Hook Events for Notifications (Unit)', () => { expect(pushNotificationsApi.enqueueNotification).not.toHaveBeenCalled(); }); + it("should only enqueue PENDING_MULTISIG_TRANSACTION event notifications for those that haven't signed", async () => { + const event = pendingTransactionEventBuilder().build(); + const chain = chainBuilder().with('chainId', event.chainId).build(); + const owners = [ + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + ]; + const safe = safeBuilder() + .with('address', event.address) + .with('threshold', faker.number.int({ min: 2 })) + .with('owners', owners) + .build(); + const subscribers = owners.map((owner) => ({ + subscriber: owner, + deviceUuid: faker.string.uuid() as Uuid, + cloudMessagingToken: faker.string.alphanumeric(), + })); + notificationsDatasource.getSubscribersBySafe.mockResolvedValue(subscribers); + const confirmations = faker.helpers + .arrayElements(owners, { min: 1, max: owners.length - 1 }) + .map((owner) => { + return confirmationBuilder().with('owner', owner).build(); + }); + const multisigTransaction = multisigTransactionBuilder() + .with('safe', event.address) + .with('confirmations', confirmations) + .build(); + + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { + return Promise.resolve({ + data: chain, + status: 200, + }); + } else if ( + url === `${chain.transactionService}/api/v1/safes/${event.address}` + ) { + return Promise.resolve({ + status: 200, + data: safe, + }); + } else if ( + url === + `${chain.transactionService}/api/v1/multisig-transactions/${event.safeTxHash}/` + ) { + return Promise.resolve({ + status: 200, + data: multisigTransaction, + }); + } else { + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/hooks/events`) + .set('Authorization', `Basic ${authToken}`) + .send(event) + .expect(202); + + expect(pushNotificationsApi.enqueueNotification).toHaveBeenCalledTimes( + subscribers.length - confirmations.length, + ); + expect(pushNotificationsApi.enqueueNotification.mock.calls).toStrictEqual( + expect.arrayContaining( + subscribers + .filter((subscriber) => { + return confirmations.every((confirmation) => { + return confirmation.owner !== subscriber.subscriber; + }); + }) + .map((subscriber) => [ + subscriber.cloudMessagingToken, + { + data: { + ...event, + type: 'CONFIRMATION_REQUEST', + }, + }, + ]), + ), + ); + }); + it("should enqueue MESSAGE_CONFIRMATION_REQUEST event notifications if the Safe has a threshold > 1 and the subscriber hasn't yet signed", async () => { const event = messageCreatedEventBuilder().build(); const chain = chainBuilder().with('chainId', event.chainId).build(); @@ -676,6 +763,93 @@ describe('Post Hook Events for Notifications (Unit)', () => { expect(pushNotificationsApi.enqueueNotification).not.toHaveBeenCalled(); }); + it("should only enqueue MESSAGE_CONFIRMATION_REQUEST event notifications for those that haven't signed", async () => { + const event = messageCreatedEventBuilder().build(); + const chain = chainBuilder().with('chainId', event.chainId).build(); + const owners = [ + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + ]; + const safe = safeBuilder() + .with('address', event.address) + .with('threshold', faker.number.int({ min: 2 })) + .with('owners', owners) + .build(); + const subscribers = owners.map((owner) => ({ + subscriber: owner, + deviceUuid: faker.string.uuid() as Uuid, + cloudMessagingToken: faker.string.alphanumeric(), + })); + notificationsDatasource.getSubscribersBySafe.mockResolvedValue(subscribers); + const confirmations = faker.helpers + .arrayElements(owners, { min: 1, max: owners.length - 1 }) + .map((owner) => { + return messageConfirmationBuilder().with('owner', owner).build(); + }); + const message = messageBuilder() + .with('messageHash', event.messageHash as `0x${string}`) + .with('confirmations', confirmations) + .build(); + + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { + return Promise.resolve({ + data: chain, + status: 200, + }); + } else if ( + url === `${chain.transactionService}/api/v1/safes/${event.address}` + ) { + return Promise.resolve({ + status: 200, + data: safe, + }); + } else if ( + url === + `${chain.transactionService}/api/v1/messages/${event.messageHash}` + ) { + return Promise.resolve({ + status: 200, + data: message, + }); + } else { + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/hooks/events`) + .set('Authorization', `Basic ${authToken}`) + .send(event) + .expect(202); + + expect(pushNotificationsApi.enqueueNotification).toHaveBeenCalledTimes( + subscribers.length - confirmations.length, + ); + expect(pushNotificationsApi.enqueueNotification.mock.calls).toStrictEqual( + expect.arrayContaining( + subscribers + .filter((subscriber) => { + return confirmations.every((confirmation) => { + return confirmation.owner !== subscriber.subscriber; + }); + }) + .map((subscriber) => [ + subscriber.cloudMessagingToken, + { + data: { + ...event, + type: 'MESSAGE_CONFIRMATION_REQUEST', + }, + }, + ]), + ), + ); + }); + it('should cleanup unregistered tokens', async () => { // Events that are notified "as is" for simplicity const event = faker.helpers.arrayElement([ From c63bf4a68f00f5f475b3c45b45f1ab61d46b0d8c Mon Sep 17 00:00:00 2001 From: iamacook Date: Tue, 30 Jul 2024 17:12:32 +0200 Subject: [PATCH 29/37] Decouple account from notifications --- migrations/00005_notifications/index.sql | 11 +- .../__tests__/00005_notifications.spec.ts | 202 +++--------------- 2 files changed, 39 insertions(+), 174 deletions(-) diff --git a/migrations/00005_notifications/index.sql b/migrations/00005_notifications/index.sql index 9478d9de27..ea96b7e3c5 100644 --- a/migrations/00005_notifications/index.sql +++ b/migrations/00005_notifications/index.sql @@ -35,20 +35,19 @@ INSERT INTO notification_types (name) VALUES ('MESSAGE_CONFIRMATION_REQUEST'), -- MESSAGE_CREATED ('MODULE_TRANSACTION'); ---------------------------------------------------- --- Safe subscriptions for a given account/device -- ---------------------------------------------------- +------------------------------------------- +-- Safe subscriptions for a given device -- +------------------------------------------- CREATE TABLE notification_subscriptions ( id SERIAL PRIMARY KEY, - account_id INT NOT NULL, push_notification_device_id INT NOT NULL, chain_id VARCHAR(255) NOT NULL, safe_address VARCHAR(42) NOT NULL, + signer_address VARCHAR(42) NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), - FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE, FOREIGN KEY (push_notification_device_id) REFERENCES push_notification_devices(id) ON DELETE CASCADE, - UNIQUE(account_id, chain_id, safe_address, push_notification_device_id) + UNIQUE(chain_id, safe_address, push_notification_device_id, signer_address) ); -- Update updated_at when a notification subscription is updated diff --git a/migrations/__tests__/00005_notifications.spec.ts b/migrations/__tests__/00005_notifications.spec.ts index 57dd827aaa..bba7ee9c66 100644 --- a/migrations/__tests__/00005_notifications.spec.ts +++ b/migrations/__tests__/00005_notifications.spec.ts @@ -8,7 +8,6 @@ import { getAddress } from 'viem'; type PushNotificationDevicesRow = { id: number; - account_id: number; device_type: 'ANDROID' | 'IOS' | 'WEB'; device_uuid: Uuid; cloud_messaging_token: string; @@ -30,7 +29,7 @@ type NotificationTypesRow = { type NotificationSubscriptionsRow = { id: number; - account_id: number; + signer_address: `0x${string}`; push_notification_device_id: PushNotificationDevicesRow['id']; chain_id: string; safe_address: `0x${string}`; @@ -146,7 +145,7 @@ describe('Migration 00005_notifications', () => { notification_subscriptions: { columns: expect.arrayContaining([ { column_name: 'id' }, - { column_name: 'account_id' }, + { column_name: 'signer_address' }, { column_name: 'push_notification_device_id' }, { column_name: 'chain_id' }, { column_name: 'safe_address' }, @@ -272,48 +271,8 @@ describe('Migration 00005_notifications', () => { ); }); - it('should delete the subscription if the account is deleted', async () => { - const address = getAddress(faker.finance.ethereumAddress()); - const safeAddress = getAddress(faker.finance.ethereumAddress()); - const chainId = faker.string.numeric(); - const deviceType = faker.helpers.arrayElement(Object.values(DeviceType)); - const deviceUuid = faker.string.uuid() as Uuid; - const cloudMessagingToken = faker.string.alphanumeric(); - const afterMigration = await migrator.test({ - migration: '00005_notifications', - after: async (sql: postgres.Sql) => { - const [[device], [account]] = await Promise.all([ - // Create device - sql< - [PushNotificationDevicesRow] - >`INSERT INTO push_notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, - // Create account - sql< - [ - { - id: number; - }, - ] - >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`, - ]); - // Create subscription - const [subscription] = await sql< - [NotificationSubscriptionsRow] - >`INSERT INTO notification_subscriptions (account_id, push_notification_device_id, chain_id, safe_address) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress}) RETURNING *`; - return { account, subscription }; - }, - }); - // Delete account - await sql`DELETE FROM accounts WHERE id = ${afterMigration.after.account.id}`; - - // Assert that subscription was deleted - await expect( - sql`SELECT * FROM notification_subscriptions WHERE id = ${afterMigration.after.subscription.id}`, - ).resolves.toStrictEqual([]); - }); - it('should delete the subscription if the device is deleted', async () => { - const address = getAddress(faker.finance.ethereumAddress()); + const signerAddress = getAddress(faker.finance.ethereumAddress()); const safeAddress = getAddress(faker.finance.ethereumAddress()); const chainId = faker.string.numeric(); const deviceType = faker.helpers.arrayElement(Object.values(DeviceType)); @@ -322,24 +281,14 @@ describe('Migration 00005_notifications', () => { const afterMigration = await migrator.test({ migration: '00005_notifications', after: async (sql: postgres.Sql) => { - const [[device], [account]] = await Promise.all([ - // Create device - sql< - [PushNotificationDevicesRow] - >`INSERT INTO push_notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, - // Create account - sql< - [ - { - id: number; - }, - ] - >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`, - ]); + // Create device + const [device] = await sql< + [PushNotificationDevicesRow] + >`INSERT INTO push_notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`; // Create subscription const [subscription] = await sql< [NotificationSubscriptionsRow] - >`INSERT INTO notification_subscriptions (account_id, push_notification_device_id, chain_id, safe_address) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress}) RETURNING *`; + >`INSERT INTO notification_subscriptions (signer_address, push_notification_device_id, chain_id, safe_address) VALUES (${signerAddress}, ${device.id}, ${chainId}, ${safeAddress}) RETURNING *`; return { device, subscription }; }, @@ -353,8 +302,8 @@ describe('Migration 00005_notifications', () => { ).resolves.toStrictEqual([]); }); - it('should delete the subscription if the device is deleted', async () => { - const address = getAddress(faker.finance.ethereumAddress()); + it('should prevent duplicate subscriptions (signer, chain, Safe address and device) in notification_subscriptions', async () => { + const signerAddress = getAddress(faker.finance.ethereumAddress()); const safeAddress = getAddress(faker.finance.ethereumAddress()); const chainId = faker.string.numeric(); const deviceType = faker.helpers.arrayElement(Object.values(DeviceType)); @@ -363,77 +312,27 @@ describe('Migration 00005_notifications', () => { const afterMigration = await migrator.test({ migration: '00005_notifications', after: async (sql: postgres.Sql) => { - const [[device], [account]] = await Promise.all([ - // Create device - sql< - [PushNotificationDevicesRow] - >`INSERT INTO push_notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, - // Create account - sql< - [ - { - id: number; - }, - ] - >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`, - ]); - // Create subscription - const [subscription] = await sql< - [NotificationSubscriptionsRow] - >`INSERT INTO notification_subscriptions (account_id, push_notification_device_id, chain_id, safe_address) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress}) RETURNING *`; - return { device, subscription }; - }, - }); - // Delete channel - await sql`DELETE FROM push_notification_devices WHERE id = ${afterMigration.after.device.id}`; - - // Assert that subscription was deleted - await expect( - sql`SELECT * FROM notification_subscriptions WHERE id = ${afterMigration.after.subscription.id}`, - ).resolves.toStrictEqual([]); - }); - - it('should prevent duplicate subscriptions (account, chain, Safe address and device) in notification_subscriptions', async () => { - const address = getAddress(faker.finance.ethereumAddress()); - const safeAddress = getAddress(faker.finance.ethereumAddress()); - const chainId = faker.string.numeric(); - const deviceType = faker.helpers.arrayElement(Object.values(DeviceType)); - const deviceUuid = faker.string.uuid() as Uuid; - const cloudMessagingToken = faker.string.alphanumeric(); - const afterMigration = await migrator.test({ - migration: '00005_notifications', - after: async (sql: postgres.Sql) => { - const [[device], [account]] = await Promise.all([ - // Create device - sql< - [PushNotificationDevicesRow] - >`INSERT INTO push_notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, - // Create account - sql< - [ - { - id: number; - }, - ] - >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`, - ]); + // Create device + const [device] = await sql< + [PushNotificationDevicesRow] + >`INSERT INTO push_notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`; // Create subscription return sql< [NotificationSubscriptionsRow] - >`INSERT INTO notification_subscriptions (account_id, push_notification_device_id, chain_id, safe_address) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress}) RETURNING *`; + >`INSERT INTO notification_subscriptions (signer_address, push_notification_device_id, chain_id, safe_address) VALUES (${signerAddress}, ${device.id}, ${chainId}, ${safeAddress}) RETURNING *`; }, }); // Create duplicate subscription await expect( - sql`INSERT INTO notification_subscriptions (account_id, push_notification_device_id, chain_id, safe_address) VALUES (${afterMigration.after[0].account_id}, ${afterMigration.after[0].push_notification_device_id}, ${chainId}, ${safeAddress})`, + sql`INSERT INTO notification_subscriptions (signer_address, push_notification_device_id, chain_id, safe_address) VALUES (${afterMigration.after[0].signer_address}, ${afterMigration.after[0].push_notification_device_id}, ${chainId}, ${safeAddress})`, ).rejects.toThrow( - 'duplicate key value violates unique constraint "notification_subscriptions_account_id_chain_id_safe_address_key"', + 'duplicate key value violates unique constraint "notification_subscriptions_chain_id_safe_address_push_notif_key"', ); }); it('should upsert the updated_at timestamp in notification_subscriptions', async () => { - const address = getAddress(faker.finance.ethereumAddress()); + const signerAddress = getAddress(faker.finance.ethereumAddress()); const safeAddress = getAddress(faker.finance.ethereumAddress()); const chainId = faker.string.numeric(); const deviceType = faker.helpers.arrayElement(Object.values(DeviceType)); @@ -442,31 +341,21 @@ describe('Migration 00005_notifications', () => { const afterMigration = await migrator.test({ migration: '00005_notifications', after: async (sql: postgres.Sql) => { - const [[device], [account]] = await Promise.all([ - // Create device - sql< - [PushNotificationDevicesRow] - >`INSERT INTO push_notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, - // Create account - sql< - [ - { - id: number; - }, - ] - >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`, - ]); + // Create device + const [device] = await sql< + [PushNotificationDevicesRow] + >`INSERT INTO push_notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`; // Create subscription return sql< [NotificationSubscriptionsRow] - >`INSERT INTO notification_subscriptions (account_id, push_notification_device_id, chain_id, safe_address) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress}) RETURNING *`; + >`INSERT INTO notification_subscriptions (signer_address, push_notification_device_id, chain_id, safe_address) VALUES (${signerAddress}, ${device.id}, ${chainId}, ${safeAddress}) RETURNING *`; }, }); expect(afterMigration.after).toStrictEqual([ { id: 1, - account_id: 1, + signer_address: signerAddress, push_notification_device_id: 1, chain_id: chainId, safe_address: safeAddress, @@ -484,7 +373,7 @@ describe('Migration 00005_notifications', () => { expect(afterUpdate).toStrictEqual([ { id: afterMigration.after[0].id, - account_id: afterMigration.after[0].account_id, + signer_address: afterMigration.after[0].signer_address, push_notification_device_id: afterMigration.after[0].push_notification_device_id, chain_id: afterMigration.after[0].chain_id, @@ -501,7 +390,7 @@ describe('Migration 00005_notifications', () => { }); it('should delete the subscribed notification type(s) if the subscription is deleted', async () => { - const address = getAddress(faker.finance.ethereumAddress()); + const signerAddress = getAddress(faker.finance.ethereumAddress()); const safeAddress = getAddress(faker.finance.ethereumAddress()); const chainId = faker.string.numeric(); const deviceType = faker.helpers.arrayElement(Object.values(DeviceType)); @@ -510,25 +399,18 @@ describe('Migration 00005_notifications', () => { const afterMigration = await migrator.test({ migration: '00005_notifications', after: async (sql: postgres.Sql) => { - const [[device], [notificationType], [account]] = await Promise.all([ + const [[device], [notificationType]] = await Promise.all([ // Create device sql< [PushNotificationDevicesRow] >`INSERT INTO push_notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, // Get all notification types sql>`SELECT * FROM notification_types`, - sql< - [ - { - id: number; - }, - ] - >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`, ]); // Create subscription const [subscription] = await sql< [NotificationSubscriptionsRow] - >`INSERT INTO notification_subscriptions (account_id, push_notification_device_id, chain_id, safe_address) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress}) RETURNING *`; + >`INSERT INTO notification_subscriptions (signer_address, push_notification_device_id, chain_id, safe_address) VALUES (${signerAddress}, ${device.id}, ${chainId}, ${safeAddress}) RETURNING *`; // Subscribe to notification type const [subscribedNotificationType] = await sql< [NotificationSubscriptionNotificationTypesRow] @@ -546,7 +428,7 @@ describe('Migration 00005_notifications', () => { }); it('should delete the subscribed notification type if the notification type is deleted', async () => { - const address = getAddress(faker.finance.ethereumAddress()); + const signerAddress = getAddress(faker.finance.ethereumAddress()); const safeAddress = getAddress(faker.finance.ethereumAddress()); const chainId = faker.string.numeric(); const deviceType = faker.helpers.arrayElement(Object.values(DeviceType)); @@ -555,26 +437,18 @@ describe('Migration 00005_notifications', () => { const afterMigration = await migrator.test({ migration: '00005_notifications', after: async (sql: postgres.Sql) => { - const [[device], [notificationType], [account]] = await Promise.all([ + const [[device], [notificationType]] = await Promise.all([ // Create device sql< [PushNotificationDevicesRow] >`INSERT INTO push_notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, // Get all notification types sql>`SELECT * FROM notification_types`, - // Create account - sql< - [ - { - id: number; - }, - ] - >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`, ]); // Create subscription const [subscription] = await sql< [NotificationSubscriptionsRow] - >`INSERT INTO notification_subscriptions (account_id, push_notification_device_id, chain_id, safe_address) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress}) RETURNING *`; + >`INSERT INTO notification_subscriptions (signer_address, push_notification_device_id, chain_id, safe_address) VALUES (${signerAddress}, ${device.id}, ${chainId}, ${safeAddress}) RETURNING *`; // Subscribe to notification type const [subscribedNotificationType] = await sql< [NotificationSubscriptionNotificationTypesRow] @@ -592,7 +466,7 @@ describe('Migration 00005_notifications', () => { }); it('should prevent duplicate notification types (subscription, notification type) in notification_subscription_notification_types', async () => { - const address = getAddress(faker.finance.ethereumAddress()); + const signerAddress = getAddress(faker.finance.ethereumAddress()); const safeAddress = getAddress(faker.finance.ethereumAddress()); const chainId = faker.string.numeric(); const deviceType = faker.helpers.arrayElement(Object.values(DeviceType)); @@ -601,26 +475,18 @@ describe('Migration 00005_notifications', () => { const afterMigration = await migrator.test({ migration: '00005_notifications', after: async (sql: postgres.Sql) => { - const [[device], [notificationType], [account]] = await Promise.all([ + const [[device], [notificationType]] = await Promise.all([ // Create device sql< [PushNotificationDevicesRow] >`INSERT INTO push_notification_devices (device_type, device_uuid, cloud_messaging_token) VALUES (${deviceType}, ${deviceUuid}, ${cloudMessagingToken}) RETURNING *`, // Get all notification types sql>`SELECT * FROM notification_types`, - // Create account - sql< - [ - { - id: number; - }, - ] - >`INSERT INTO accounts (address) VALUES (${address}) RETURNING id`, ]); // Create subscription const [subscription] = await sql< [NotificationSubscriptionsRow] - >`INSERT INTO notification_subscriptions (account_id, push_notification_device_id, chain_id, safe_address) VALUES (${account.id}, ${device.id}, ${chainId}, ${safeAddress}) RETURNING *`; + >`INSERT INTO notification_subscriptions (signer_address, push_notification_device_id, chain_id, safe_address) VALUES (${signerAddress}, ${device.id}, ${chainId}, ${safeAddress}) RETURNING *`; // Subscribe to notification type return sql< [NotificationSubscriptionNotificationTypesRow] From 8badc918a4597129635b7b01a47ee7914831ee0e Mon Sep 17 00:00:00 2001 From: iamacook Date: Tue, 30 Jul 2024 17:45:34 +0200 Subject: [PATCH 30/37] Decouple notifications from account in datasource --- ...upsert-subscriptions.dto.entity.builder.ts | 1 - .../upsert-subscriptions.dto.entity.ts | 1 - .../notifications.datasource.spec.ts | 482 +++++++++++------- .../notifications/notifications.datasource.ts | 77 ++- .../notifications.datasource.interface.ts | 10 +- 5 files changed, 334 insertions(+), 237 deletions(-) diff --git a/src/datasources/accounts/notifications/__tests__/upsert-subscriptions.dto.entity.builder.ts b/src/datasources/accounts/notifications/__tests__/upsert-subscriptions.dto.entity.builder.ts index 4be052cd09..efc4c087fa 100644 --- a/src/datasources/accounts/notifications/__tests__/upsert-subscriptions.dto.entity.builder.ts +++ b/src/datasources/accounts/notifications/__tests__/upsert-subscriptions.dto.entity.builder.ts @@ -9,7 +9,6 @@ import { NotificationType } from '@/domain/notifications/entities-v2/notificatio // TODO: Move to domain export function upsertSubscriptionsDtoBuilder(): IBuilder { return new Builder() - .with('account', getAddress(faker.finance.ethereumAddress())) .with('cloudMessagingToken', faker.string.alphanumeric({ length: 10 })) .with('deviceType', faker.helpers.arrayElement(Object.values(DeviceType))) .with('deviceUuid', faker.string.uuid() as Uuid) diff --git a/src/datasources/accounts/notifications/entities/upsert-subscriptions.dto.entity.ts b/src/datasources/accounts/notifications/entities/upsert-subscriptions.dto.entity.ts index c30e07ecd4..548707932f 100644 --- a/src/datasources/accounts/notifications/entities/upsert-subscriptions.dto.entity.ts +++ b/src/datasources/accounts/notifications/entities/upsert-subscriptions.dto.entity.ts @@ -4,7 +4,6 @@ import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; // TODO: Move to domain export type UpsertSubscriptionsDto = { - account: `0x${string}`; cloudMessagingToken: string; safes: Array<{ chainId: string; diff --git a/src/datasources/accounts/notifications/notifications.datasource.spec.ts b/src/datasources/accounts/notifications/notifications.datasource.spec.ts index 80467610a1..5cd480401b 100644 --- a/src/datasources/accounts/notifications/notifications.datasource.spec.ts +++ b/src/datasources/accounts/notifications/notifications.datasource.spec.ts @@ -24,7 +24,6 @@ const mockConfigurationService = jest.mocked({ describe('NotificationsDatasource', () => { let fakeCacheService: FakeCacheService; - let accountsDatasource: AccountsDatasource; let migrator: PostgresDatabaseMigrator; let sql: postgres.Sql; const testDbFactory = new TestDbFactory(); @@ -38,7 +37,7 @@ describe('NotificationsDatasource', () => { mockConfigurationService.getOrThrow.mockImplementation((key) => { if (key === 'expirationTimeInSeconds.default') return faker.number.int(); }); - accountsDatasource = new AccountsDatasource( + const accountsDatasource = new AccountsDatasource( fakeCacheService, sql, mockLoggingService, @@ -53,7 +52,7 @@ describe('NotificationsDatasource', () => { afterEach(async () => { // Don't truncate notification_types as it has predefined rows - await sql`TRUNCATE TABLE accounts, push_notification_devices, notification_subscriptions, notification_subscription_notification_types RESTART IDENTITY CASCADE`; + await sql`TRUNCATE TABLE push_notification_devices, notification_subscriptions, notification_subscription_notification_types RESTART IDENTITY CASCADE`; }); afterAll(async () => { @@ -62,100 +61,89 @@ describe('NotificationsDatasource', () => { describe('upsertSubscriptions', () => { it('should insert a subscription', async () => { + const signerAddress = getAddress(faker.finance.ethereumAddress()); const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() .with('deviceUuid', undefined) .build(); - await accountsDatasource.createAccount(upsertSubscriptionsDto.account); - const actual = await target.upsertSubscriptions(upsertSubscriptionsDto); + const actual = await target.upsertSubscriptions({ + signerAddress, + upsertSubscriptionsDto, + }); expect(actual).toStrictEqual({ deviceUuid: expect.any(String) }); // Ensure correct database structure await Promise.all([ - sql`SELECT * FROM accounts`, sql`SELECT * FROM push_notification_devices`, sql`SELECT * FROM notification_types`, sql`SELECT * FROM notification_subscriptions`, sql`SELECT * FROM notification_subscription_notification_types`, - ]).then( - ([ - accounts, - devices, - types, - subscriptions, - subscribedNotifications, - ]) => { - expect(accounts).toStrictEqual([ - { - id: 1, - group_id: null, - address: upsertSubscriptionsDto.account, - created_at: expect.any(Date), - updated_at: expect.any(Date), - }, - ]); - expect(devices).toStrictEqual([ - { - id: 1, - device_type: upsertSubscriptionsDto.deviceType, - device_uuid: actual.deviceUuid, - cloud_messaging_token: upsertSubscriptionsDto.cloudMessagingToken, + ]).then(([devices, types, subscriptions, subscribedNotifications]) => { + expect(devices).toStrictEqual([ + { + id: 1, + device_type: upsertSubscriptionsDto.deviceType, + device_uuid: actual.deviceUuid, + cloud_messaging_token: upsertSubscriptionsDto.cloudMessagingToken, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }, + ]); + expect(types).toStrictEqual( + Object.values(NotificationType).map((type) => { + return { + id: expect.any(Number), + name: type, + }; + }), + ); + expect(subscriptions).toStrictEqual( + upsertSubscriptionsDto.safes.map((safe, i) => { + return { + id: i + 1, + signer_address: signerAddress, + push_notification_device_id: devices[0].id, + chain_id: safe.chainId, + safe_address: safe.address, created_at: expect.any(Date), updated_at: expect.any(Date), - }, - ]); - expect(types).toStrictEqual( - Object.values(NotificationType).map((type) => { - return { - id: expect.any(Number), - name: type, - }; - }), - ); - expect(subscriptions).toStrictEqual( - upsertSubscriptionsDto.safes.map((safe, i) => { - return { - id: i + 1, - account_id: accounts[0].id, - push_notification_device_id: devices[0].id, - chain_id: safe.chainId, - safe_address: safe.address, - created_at: expect.any(Date), - updated_at: expect.any(Date), - }; + }; + }), + ); + expect(subscribedNotifications).toStrictEqual( + expect.arrayContaining( + upsertSubscriptionsDto.safes.flatMap((safe, i) => { + return safe.notificationTypes.map((type) => { + return { + id: expect.any(Number), + notification_subscription_id: i + 1, + notification_type_id: types.find((t) => t.name === type)?.id, + }; + }); }), - ); - expect(subscribedNotifications).toStrictEqual( - expect.arrayContaining( - upsertSubscriptionsDto.safes.flatMap((safe, i) => { - return safe.notificationTypes.map((type) => { - return { - id: expect.any(Number), - notification_subscription_id: i + 1, - notification_type_id: types.find((t) => t.name === type) - ?.id, - }; - }); - }), - ), - ); - }, - ); + ), + ); + }); }); it('should always update the deviceType/cloudMessagingToken', async () => { + const signerAddress = getAddress(faker.finance.ethereumAddress()); const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); const secondSubscriptionsDto = upsertSubscriptionsDtoBuilder() - .with('account', upsertSubscriptionsDto.account) .with('deviceUuid', upsertSubscriptionsDto.deviceUuid) .build(); - await accountsDatasource.createAccount(upsertSubscriptionsDto.account); - await target.upsertSubscriptions(upsertSubscriptionsDto); + await target.upsertSubscriptions({ + signerAddress, + upsertSubscriptionsDto, + }); // Insert should not throw despite it being the same device UUID await expect( - target.upsertSubscriptions(secondSubscriptionsDto), + target.upsertSubscriptions({ + signerAddress, + upsertSubscriptionsDto: secondSubscriptionsDto, + }), ).resolves.not.toThrow(); // Device UUID should have updated await expect( @@ -173,6 +161,7 @@ describe('NotificationsDatasource', () => { }); it('should update a subscription, setting only the newly subscribed notification types', async () => { + const signerAddress = getAddress(faker.finance.ethereumAddress()); const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() .with('safes', [ { @@ -187,16 +176,21 @@ describe('NotificationsDatasource', () => { const newNotificationTypes = faker.helpers.arrayElements( Object.values(NotificationType), ); - await accountsDatasource.createAccount(upsertSubscriptionsDto.account); - await target.upsertSubscriptions(upsertSubscriptionsDto); await target.upsertSubscriptions({ - ...upsertSubscriptionsDto, - safes: [ - { - ...upsertSubscriptionsDto.safes[0], - notificationTypes: newNotificationTypes, - }, - ], + signerAddress, + upsertSubscriptionsDto, + }); + await target.upsertSubscriptions({ + signerAddress, + upsertSubscriptionsDto: { + ...upsertSubscriptionsDto, + safes: [ + { + ...upsertSubscriptionsDto.safes[0], + notificationTypes: newNotificationTypes, + }, + ], + }, }); await Promise.all([ @@ -221,136 +215,223 @@ describe('NotificationsDatasource', () => { }); it('should allow multiple subscriptions, varying by device UUID', async () => { + const signerAddress = getAddress(faker.finance.ethereumAddress()); const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); const secondDeviceUuid = faker.string.uuid() as Uuid; const secondUpsertSubscriptionsDto = { ...upsertSubscriptionsDto, deviceUuid: secondDeviceUuid, }; - await accountsDatasource.createAccount(upsertSubscriptionsDto.account); - await target.upsertSubscriptions(upsertSubscriptionsDto); - await target.upsertSubscriptions(secondUpsertSubscriptionsDto); + await target.upsertSubscriptions({ + signerAddress, + upsertSubscriptionsDto, + }); + await target.upsertSubscriptions({ + signerAddress, + upsertSubscriptionsDto: secondUpsertSubscriptionsDto, + }); // Ensure correct database structure await Promise.all([ - sql`SELECT * FROM accounts`, sql`SELECT * FROM push_notification_devices`, sql`SELECT * FROM notification_types`, sql`SELECT * FROM notification_subscriptions`, sql`SELECT * FROM notification_subscription_notification_types`, - ]).then( - ([ - accounts, - devices, - types, - subscriptions, - subscribedNotifications, - ]) => { - expect(accounts).toStrictEqual([ - { - id: 1, - group_id: null, - address: upsertSubscriptionsDto.account, - created_at: expect.any(Date), - updated_at: expect.any(Date), - }, - ]); - expect(devices).toStrictEqual([ - { - id: 1, - device_type: upsertSubscriptionsDto.deviceType, - device_uuid: upsertSubscriptionsDto.deviceUuid, - cloud_messaging_token: upsertSubscriptionsDto.cloudMessagingToken, - created_at: expect.any(Date), - updated_at: expect.any(Date), - }, - { - id: 2, - device_type: upsertSubscriptionsDto.deviceType, - device_uuid: secondDeviceUuid, - cloud_messaging_token: upsertSubscriptionsDto.cloudMessagingToken, - created_at: expect.any(Date), - updated_at: expect.any(Date), - }, - ]); - expect(types).toStrictEqual( - Object.values(NotificationType).map((type) => { + ]).then(([devices, types, subscriptions, subscribedNotifications]) => { + expect(devices).toStrictEqual([ + { + id: 1, + device_type: upsertSubscriptionsDto.deviceType, + device_uuid: upsertSubscriptionsDto.deviceUuid, + cloud_messaging_token: upsertSubscriptionsDto.cloudMessagingToken, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }, + { + id: 2, + device_type: upsertSubscriptionsDto.deviceType, + device_uuid: secondDeviceUuid, + cloud_messaging_token: upsertSubscriptionsDto.cloudMessagingToken, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }, + ]); + expect(types).toStrictEqual( + Object.values(NotificationType).map((type) => { + return { + id: expect.any(Number), + name: type, + }; + }), + ); + expect(subscriptions).toStrictEqual( + upsertSubscriptionsDto.safes + .map((safe, i) => { return { - id: expect.any(Number), - name: type, + id: i + 1, + signer_address: signerAddress, + push_notification_device_id: devices[0].id, + chain_id: safe.chainId, + safe_address: safe.address, + created_at: expect.any(Date), + updated_at: expect.any(Date), }; - }), - ); - expect(subscriptions).toStrictEqual( - upsertSubscriptionsDto.safes - .map((safe, i) => { + }) + .concat( + secondUpsertSubscriptionsDto.safes.map((safe, i) => { return { - id: i + 1, - account_id: accounts[0].id, - push_notification_device_id: devices[0].id, + id: upsertSubscriptionsDto.safes.length + i + 1, + signer_address: signerAddress, + push_notification_device_id: devices[1].id, chain_id: safe.chainId, safe_address: safe.address, created_at: expect.any(Date), updated_at: expect.any(Date), }; - }) - .concat( - secondUpsertSubscriptionsDto.safes.map((safe, i) => { + }), + ), + ); + expect(subscribedNotifications).toStrictEqual( + expect.arrayContaining( + upsertSubscriptionsDto.safes + .flatMap((safe, i) => { + return safe.notificationTypes.map((type) => { return { - id: upsertSubscriptionsDto.safes.length + i + 1, - account_id: accounts[0].id, - push_notification_device_id: devices[1].id, - chain_id: safe.chainId, - safe_address: safe.address, - created_at: expect.any(Date), - updated_at: expect.any(Date), + id: expect.any(Number), + notification_subscription_id: i + 1, + notification_type_id: types.find((t) => t.name === type) + ?.id, }; + }); + }) + .concat( + secondUpsertSubscriptionsDto.safes.flatMap((safe, i) => { + return safe.notificationTypes.map((type) => { + return { + id: expect.any(Number), + notification_subscription_id: + upsertSubscriptionsDto.safes.length + i + 1, + notification_type_id: types.find((t) => t.name === type) + ?.id, + }; + }); }), ), - ); - expect(subscribedNotifications).toStrictEqual( - expect.arrayContaining( - upsertSubscriptionsDto.safes - .flatMap((safe, i) => { + ), + ); + }); + }); + + it('should allow multiple subscriptions, varying by signer', async () => { + const signerAddress = getAddress(faker.finance.ethereumAddress()); + const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); + const secondSignerAddress = getAddress(faker.finance.ethereumAddress()); + await target.upsertSubscriptions({ + signerAddress, + upsertSubscriptionsDto, + }); + await target.upsertSubscriptions({ + signerAddress: secondSignerAddress, + upsertSubscriptionsDto: upsertSubscriptionsDto, + }); + + // Ensure correct database structure + await Promise.all([ + sql`SELECT * FROM push_notification_devices`, + sql`SELECT * FROM notification_types`, + sql`SELECT * FROM notification_subscriptions`, + sql`SELECT * FROM notification_subscription_notification_types`, + ]).then(([devices, types, subscriptions, subscribedNotifications]) => { + expect(devices).toStrictEqual([ + { + id: 1, + device_type: upsertSubscriptionsDto.deviceType, + device_uuid: upsertSubscriptionsDto.deviceUuid, + cloud_messaging_token: upsertSubscriptionsDto.cloudMessagingToken, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }, + ]); + expect(types).toStrictEqual( + Object.values(NotificationType).map((type) => { + return { + id: expect.any(Number), + name: type, + }; + }), + ); + expect(subscriptions).toStrictEqual( + upsertSubscriptionsDto.safes + .map((safe, i) => { + return { + id: i + 1, + signer_address: signerAddress, + push_notification_device_id: devices[0].id, + chain_id: safe.chainId, + safe_address: safe.address, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }; + }) + .concat( + upsertSubscriptionsDto.safes.map((safe, i) => { + return { + id: upsertSubscriptionsDto.safes.length + i + 1, + signer_address: secondSignerAddress, + push_notification_device_id: devices[0].id, + chain_id: safe.chainId, + safe_address: safe.address, + created_at: expect.any(Date), + updated_at: expect.any(Date), + }; + }), + ), + ); + expect(subscribedNotifications).toStrictEqual( + expect.arrayContaining( + upsertSubscriptionsDto.safes + .flatMap((safe, i) => { + return safe.notificationTypes.map((type) => { + return { + id: expect.any(Number), + notification_subscription_id: i + 1, + notification_type_id: types.find((t) => t.name === type) + ?.id, + }; + }); + }) + .concat( + upsertSubscriptionsDto.safes.flatMap((safe, i) => { return safe.notificationTypes.map((type) => { return { id: expect.any(Number), - notification_subscription_id: i + 1, + notification_subscription_id: + upsertSubscriptionsDto.safes.length + i + 1, notification_type_id: types.find((t) => t.name === type) ?.id, }; }); - }) - .concat( - secondUpsertSubscriptionsDto.safes.flatMap((safe, i) => { - return safe.notificationTypes.map((type) => { - return { - id: expect.any(Number), - notification_subscription_id: - upsertSubscriptionsDto.safes.length + i + 1, - notification_type_id: types.find((t) => t.name === type) - ?.id, - }; - }); - }), - ), - ), - ); - }, - ); + }), + ), + ), + ); + }); }); }); describe('getSafeSubscription', () => { it('should return a subscription for a Safe', async () => { + const signerAddress = getAddress(faker.finance.ethereumAddress()); const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); - await accountsDatasource.createAccount(upsertSubscriptionsDto.account); - await target.upsertSubscriptions(upsertSubscriptionsDto); + await target.upsertSubscriptions({ + signerAddress, + upsertSubscriptionsDto, + }); const safe = upsertSubscriptionsDto.safes[0]; await expect( target.getSafeSubscription({ - account: upsertSubscriptionsDto.account, + signerAddress, deviceUuid: upsertSubscriptionsDto.deviceUuid!, chainId: safe.chainId, safeAddress: safe.address, @@ -361,16 +442,20 @@ describe('NotificationsDatasource', () => { describe('getSubscribersWithTokensBySafe', () => { it('should return a list of subscribers with tokens for a Safe', async () => { + const signerAddress = getAddress(faker.finance.ethereumAddress()); const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); + const secondSignerAddress = getAddress(faker.finance.ethereumAddress()); const secondUpsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() .with('safes', upsertSubscriptionsDto.safes) .build(); - await accountsDatasource.createAccount(upsertSubscriptionsDto.account); - await accountsDatasource.createAccount( - secondUpsertSubscriptionsDto.account, - ); - await target.upsertSubscriptions(upsertSubscriptionsDto); - await target.upsertSubscriptions(secondUpsertSubscriptionsDto); + await target.upsertSubscriptions({ + signerAddress, + upsertSubscriptionsDto, + }); + await target.upsertSubscriptions({ + signerAddress: secondSignerAddress, + upsertSubscriptionsDto: secondUpsertSubscriptionsDto, + }); const safe = upsertSubscriptionsDto.safes[0]; await expect( @@ -380,11 +465,11 @@ describe('NotificationsDatasource', () => { }), ).resolves.toStrictEqual([ { - subscriber: upsertSubscriptionsDto.account, + subscriber: signerAddress, cloudMessagingToken: upsertSubscriptionsDto.cloudMessagingToken, }, { - subscriber: secondUpsertSubscriptionsDto.account, + subscriber: secondSignerAddress, cloudMessagingToken: secondUpsertSubscriptionsDto.cloudMessagingToken, }, ]); @@ -393,6 +478,7 @@ describe('NotificationsDatasource', () => { describe('deleteSubscription', () => { it('should delete a subscription and orphaned device', async () => { + const signerAddress = getAddress(faker.finance.ethereumAddress()); const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() .with('safes', [ { @@ -404,21 +490,21 @@ describe('NotificationsDatasource', () => { }, ]) .build(); - const account = await accountsDatasource.createAccount( - upsertSubscriptionsDto.account, - ); - await target.upsertSubscriptions(upsertSubscriptionsDto); + await target.upsertSubscriptions({ + signerAddress, + upsertSubscriptionsDto, + }); const safe = upsertSubscriptionsDto.safes[0]; await target.deleteSubscription({ - account: upsertSubscriptionsDto.account, + signerAddress, deviceUuid: upsertSubscriptionsDto.deviceUuid!, chainId: safe.chainId, safeAddress: safe.address, }); await expect( - sql`SELECT * FROM notification_subscriptions WHERE account_id = ${account.id} AND chain_id = ${safe.chainId} AND safe_address = ${safe.address}`, + sql`SELECT * FROM notification_subscriptions WHERE signer_address = ${signerAddress} AND chain_id = ${safe.chainId} AND safe_address = ${safe.address}`, ).resolves.toStrictEqual([]); await expect( sql`SELECT * FROM push_notification_devices WHERE device_uuid = ${upsertSubscriptionsDto.deviceUuid!}`, @@ -426,6 +512,7 @@ describe('NotificationsDatasource', () => { }); it('should not delete subscriptions of other device UUIDs', async () => { + const signerAddress = getAddress(faker.finance.ethereumAddress()); const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() .with('safes', [ { @@ -442,13 +529,18 @@ describe('NotificationsDatasource', () => { ...upsertSubscriptionsDto, deviceUuid: secondDeviceUuid, }; - await accountsDatasource.createAccount(upsertSubscriptionsDto.account); - await target.upsertSubscriptions(upsertSubscriptionsDto); - await target.upsertSubscriptions(secondUpsertSubscriptionsDto); + await target.upsertSubscriptions({ + signerAddress, + upsertSubscriptionsDto, + }); + await target.upsertSubscriptions({ + signerAddress, + upsertSubscriptionsDto: secondUpsertSubscriptionsDto, + }); const safe = upsertSubscriptionsDto.safes[0]; await target.deleteSubscription({ - account: upsertSubscriptionsDto.account, + signerAddress, deviceUuid: upsertSubscriptionsDto.deviceUuid!, chainId: safe.chainId, safeAddress: safe.address, @@ -460,7 +552,7 @@ describe('NotificationsDatasource', () => { ).resolves.toStrictEqual([ { id: 2, - account_id: 1, + signer_address: signerAddress, push_notification_device_id: 2, chain_id: safe.chainId, safe_address: safe.address, @@ -471,6 +563,7 @@ describe('NotificationsDatasource', () => { }); it('should not delete devices with other subscriptions', async () => { + const signerAddress = getAddress(faker.finance.ethereumAddress()); const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() .with('safes', [ { @@ -489,15 +582,17 @@ describe('NotificationsDatasource', () => { }, ]) .build(); - await accountsDatasource.createAccount(upsertSubscriptionsDto.account); - await target.upsertSubscriptions(upsertSubscriptionsDto); + await target.upsertSubscriptions({ + signerAddress, + upsertSubscriptionsDto, + }); const safe = upsertSubscriptionsDto.safes[0]; await target.deleteSubscription({ - account: upsertSubscriptionsDto.account, deviceUuid: upsertSubscriptionsDto.deviceUuid!, chainId: safe.chainId, safeAddress: safe.address, + signerAddress, }); // Device should not have been deleted @@ -518,9 +613,12 @@ describe('NotificationsDatasource', () => { describe('deleteDevice', () => { it('should delete all subscriptions of a device', async () => { + const signerAddress = getAddress(faker.finance.ethereumAddress()); const upsertSubscriptionsDto = upsertSubscriptionsDtoBuilder().build(); - await accountsDatasource.createAccount(upsertSubscriptionsDto.account); - await target.upsertSubscriptions(upsertSubscriptionsDto); + await target.upsertSubscriptions({ + signerAddress, + upsertSubscriptionsDto, + }); await target.deleteDevice(upsertSubscriptionsDto.deviceUuid!); diff --git a/src/datasources/accounts/notifications/notifications.datasource.ts b/src/datasources/accounts/notifications/notifications.datasource.ts index e1c4213711..fe7f6cdb4c 100644 --- a/src/datasources/accounts/notifications/notifications.datasource.ts +++ b/src/datasources/accounts/notifications/notifications.datasource.ts @@ -25,30 +25,26 @@ export class NotificationsDatasource implements INotificationsDatasource { ) {} /** - * Upserts subscriptions for the given account/device as per the list of Safes + * Upserts subscriptions for the given signer/device as per the list of Safes * and notification types provided. * - * @param args.account Account address - * @param args.cloudMessagingToken Cloud messaging token - * @param args.deviceType Device type - * @param args.deviceUuid Device UUID (defaults to random UUID) - * @param args.safes List of Safes with notification types + * @param args.signerAddress Signer address + * @param args.upsertSubscriptionsDto {@link UpsertSubscriptionsDto} DTO * * @returns Device UUID */ - async upsertSubscriptions( - upsertSubscriptionsDto: UpsertSubscriptionsDto, - ): Promise<{ deviceUuid: Uuid }> { - const account = await this.accountsDatasource.getAccount( - upsertSubscriptionsDto.account, - ); - const deviceUuid = upsertSubscriptionsDto.deviceUuid ?? crypto.randomUUID(); + async upsertSubscriptions(args: { + signerAddress: `0x${string}`; + upsertSubscriptionsDto: UpsertSubscriptionsDto; + }): Promise<{ deviceUuid: Uuid }> { + const deviceUuid = + args.upsertSubscriptionsDto.deviceUuid ?? crypto.randomUUID(); await this.sql.begin(async (sql) => { // Insert (or update the type/cloud messaging token of) a device const [device] = await sql<[{ id: number }]>` INSERT INTO push_notification_devices (device_type, device_uuid, cloud_messaging_token) - VALUES (${upsertSubscriptionsDto.deviceType}, ${deviceUuid}, ${upsertSubscriptionsDto.cloudMessagingToken}) + VALUES (${args.upsertSubscriptionsDto.deviceType}, ${deviceUuid}, ${args.upsertSubscriptionsDto.cloudMessagingToken}) ON CONFLICT (device_uuid) DO UPDATE SET cloud_messaging_token = EXCLUDED.cloud_messaging_token, @@ -64,13 +60,13 @@ export class NotificationsDatasource implements INotificationsDatasource { // For each Safe, upsert the subscription and overwrite the subscribed-to notification types await Promise.all( - upsertSubscriptionsDto.safes.map(async (safe) => { + args.upsertSubscriptionsDto.safes.map(async (safe) => { try { // 1. Upsert subscription const [subscription] = await sql<[{ id: number }]>` - INSERT INTO notification_subscriptions (account_id, chain_id, safe_address, push_notification_device_id) - VALUES (${account.id}, ${safe.chainId}, ${safe.address}, ${device.id}) - ON CONFLICT (account_id, chain_id, safe_address, push_notification_device_id) + INSERT INTO notification_subscriptions (chain_id, safe_address, signer_address, push_notification_device_id) + VALUES (${safe.chainId}, ${safe.address}, ${args.signerAddress}, ${device.id}) + ON CONFLICT (chain_id, safe_address, signer_address, push_notification_device_id) -- If no value is set ON CONFLICT, an error is thrown meaning nothing is returned DO UPDATE SET updated_at = NOW() RETURNING id @@ -100,23 +96,21 @@ export class NotificationsDatasource implements INotificationsDatasource { } /** - * Gets notification preferences for given account/device for the given Safe. + * Gets notification preferences for given signer/device for the given Safe. * - * @param args.account Account address * @param args.deviceUuid Device UUID * @param args.chainId Chain ID * @param args.safeAddress Safe address + * @param args.signerAddress Signer address * * @returns List of {@link DomainNotificationType} notifications subscribed to */ async getSafeSubscription(args: { - account: `0x${string}`; deviceUuid: Uuid; chainId: string; safeAddress: `0x${string}`; + signerAddress: `0x${string}`; }): Promise> { - const account = await this.accountsDatasource.getAccount(args.account); - const notificationTypes = await this.sql< Array<{ name: DomainNotificationType }> >` @@ -125,9 +119,9 @@ export class NotificationsDatasource implements INotificationsDatasource { JOIN push_notification_devices pnd ON ns.push_notification_device_id = pnd.id JOIN notification_subscription_notification_types nsnt ON ns.id = nsnt.notification_subscription_id JOIN notification_types nt ON nsnt.notification_type_id = nt.id - WHERE ns.account_id = ${account.id} - AND ns.chain_id = ${args.chainId} + WHERE ns.chain_id = ${args.chainId} AND ns.safe_address = ${args.safeAddress} + AND ns.signer_address = ${args.signerAddress} AND pnd.device_uuid = ${args.deviceUuid} `.catch((e) => { const error = 'Error getting subscription or notification types'; @@ -156,14 +150,18 @@ export class NotificationsDatasource implements INotificationsDatasource { }> > { const subscribers = await this.sql< - Array<{ address: `0x${string}`; cloud_messaging_token: string }> + Array<{ signer_address: `0x${string}`; cloud_messaging_token: string }> >` - SELECT a.address, pnd.cloud_messaging_token - FROM notification_subscriptions ns - JOIN accounts a ON ns.account_id = a.id - JOIN push_notification_devices pnd ON ns.push_notification_device_id = pnd.id - WHERE ns.chain_id = ${args.chainId} - AND ns.safe_address = ${args.safeAddress} + SELECT + pd.cloud_messaging_token, + ns.signer_address + FROM + push_notification_devices pd + JOIN + notification_subscriptions ns ON pd.id = ns.push_notification_device_id + WHERE + ns.chain_id = ${args.chainId} + AND ns.safe_address = ${args.safeAddress}; `.catch((e) => { const error = 'Error getting subscribers with tokens'; this.loggingService.info(`${error}: ${asError(e).message}`); @@ -172,25 +170,25 @@ export class NotificationsDatasource implements INotificationsDatasource { return subscribers.map((subscriber) => { return { - subscriber: subscriber.address, + subscriber: subscriber.signer_address, cloudMessagingToken: subscriber.cloud_messaging_token, }; }); } /** - * Deletes the Safe subscription for the given account/device. + * Deletes the Safe subscription for the given signer/device. * - * @param args.account Account address * @param args.deviceUuid Device UUID * @param args.chainId Chain ID * @param args.safeAddress Safe address + * @param args.signerAddress Signer address */ async deleteSubscription(args: { - account: `0x${string}`; deviceUuid: Uuid; chainId: string; safeAddress: `0x${string}`; + signerAddress: `0x${string}`; }): Promise { await this.sql.begin(async (sql) => { try { @@ -199,13 +197,12 @@ export class NotificationsDatasource implements INotificationsDatasource { [{ push_notification_device_id: number }] >` DELETE FROM notification_subscriptions ns - USING accounts a, push_notification_devices pnd - WHERE ns.account_id = a.id - AND ns.push_notification_device_id = pnd.id - AND a.address = ${args.account} + USING push_notification_devices pnd + WHERE ns.push_notification_device_id = pnd.id AND pnd.device_uuid = ${args.deviceUuid} AND ns.chain_id = ${args.chainId} AND ns.safe_address = ${args.safeAddress} + AND ns.signer_address = ${args.signerAddress} RETURNING ns.push_notification_device_id; `; diff --git a/src/domain/interfaces/notifications.datasource.interface.ts b/src/domain/interfaces/notifications.datasource.interface.ts index 01b7886176..e45c7f3150 100644 --- a/src/domain/interfaces/notifications.datasource.interface.ts +++ b/src/domain/interfaces/notifications.datasource.interface.ts @@ -5,12 +5,15 @@ import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; export const INotificationsDatasource = Symbol('INotificationsDatasource'); export interface INotificationsDatasource { - upsertSubscriptions(upsertSubscriptionsDto: UpsertSubscriptionsDto): Promise<{ + upsertSubscriptions(args: { + signerAddress: `0x${string}`; + upsertSubscriptionsDto: UpsertSubscriptionsDto; + }): Promise<{ deviceUuid: Uuid; }>; getSafeSubscription(args: { - account: `0x${string}`; + signerAddress: `0x${string}`; deviceUuid: Uuid; chainId: string; safeAddress: `0x${string}`; @@ -19,6 +22,7 @@ export interface INotificationsDatasource { getSubscribersWithTokensBySafe(args: { chainId: string; safeAddress: `0x${string}`; + signerAddress: `0x${string}`; }): Promise< Array<{ subscriber: `0x${string}`; @@ -27,10 +31,10 @@ export interface INotificationsDatasource { >; deleteSubscription(args: { - account: `0x${string}`; deviceUuid: Uuid; chainId: string; safeAddress: `0x${string}`; + signerAddress: `0x${string}`; }): Promise; deleteDevice(deviceUuid: Uuid): Promise; From 2d2ab0ddfd9bc6fd86a098cd9be81805e59a72fc Mon Sep 17 00:00:00 2001 From: iamacook Date: Tue, 30 Jul 2024 17:47:04 +0200 Subject: [PATCH 31/37] Move datasource --- .../__tests__/upsert-subscriptions.dto.entity.builder.ts | 2 +- .../notifications/entities/upsert-subscriptions.dto.entity.ts | 0 .../notifications/notifications.datasource.spec.ts | 4 ++-- .../{accounts => }/notifications/notifications.datasource.ts | 2 +- src/domain/interfaces/notifications.datasource.interface.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) rename src/datasources/{accounts => }/notifications/__tests__/upsert-subscriptions.dto.entity.builder.ts (90%) rename src/datasources/{accounts => }/notifications/entities/upsert-subscriptions.dto.entity.ts (100%) rename src/datasources/{accounts => }/notifications/notifications.datasource.spec.ts (98%) rename src/datasources/{accounts => }/notifications/notifications.datasource.ts (98%) diff --git a/src/datasources/accounts/notifications/__tests__/upsert-subscriptions.dto.entity.builder.ts b/src/datasources/notifications/__tests__/upsert-subscriptions.dto.entity.builder.ts similarity index 90% rename from src/datasources/accounts/notifications/__tests__/upsert-subscriptions.dto.entity.builder.ts rename to src/datasources/notifications/__tests__/upsert-subscriptions.dto.entity.builder.ts index efc4c087fa..334d7e8cd1 100644 --- a/src/datasources/accounts/notifications/__tests__/upsert-subscriptions.dto.entity.builder.ts +++ b/src/datasources/notifications/__tests__/upsert-subscriptions.dto.entity.builder.ts @@ -1,7 +1,7 @@ import { faker } from '@faker-js/faker'; import { Builder, IBuilder } from '@/__tests__/builder'; import { getAddress } from 'viem'; -import { UpsertSubscriptionsDto } from '@/datasources/accounts/notifications/entities/upsert-subscriptions.dto.entity'; +import { UpsertSubscriptionsDto } from '@/datasources/notifications/entities/upsert-subscriptions.dto.entity'; import { DeviceType } from '@/domain/notifications/entities-v2/device-type.entity'; import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; import { NotificationType } from '@/domain/notifications/entities-v2/notification-type.entity'; diff --git a/src/datasources/accounts/notifications/entities/upsert-subscriptions.dto.entity.ts b/src/datasources/notifications/entities/upsert-subscriptions.dto.entity.ts similarity index 100% rename from src/datasources/accounts/notifications/entities/upsert-subscriptions.dto.entity.ts rename to src/datasources/notifications/entities/upsert-subscriptions.dto.entity.ts diff --git a/src/datasources/accounts/notifications/notifications.datasource.spec.ts b/src/datasources/notifications/notifications.datasource.spec.ts similarity index 98% rename from src/datasources/accounts/notifications/notifications.datasource.spec.ts rename to src/datasources/notifications/notifications.datasource.spec.ts index 5cd480401b..4bcbc751d2 100644 --- a/src/datasources/accounts/notifications/notifications.datasource.spec.ts +++ b/src/datasources/notifications/notifications.datasource.spec.ts @@ -1,8 +1,8 @@ import { TestDbFactory } from '@/__tests__/db.factory'; import { IConfigurationService } from '@/config/configuration.service.interface'; import { AccountsDatasource } from '@/datasources/accounts/accounts.datasource'; -import { upsertSubscriptionsDtoBuilder } from '@/datasources/accounts/notifications/__tests__/upsert-subscriptions.dto.entity.builder'; -import { NotificationsDatasource } from '@/datasources/accounts/notifications/notifications.datasource'; +import { upsertSubscriptionsDtoBuilder } from '@/datasources/notifications/__tests__/upsert-subscriptions.dto.entity.builder'; +import { NotificationsDatasource } from '@/datasources/notifications/notifications.datasource'; import { FakeCacheService } from '@/datasources/cache/__tests__/fake.cache.service'; import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; import { NotificationType } from '@/domain/notifications/entities-v2/notification-type.entity'; diff --git a/src/datasources/accounts/notifications/notifications.datasource.ts b/src/datasources/notifications/notifications.datasource.ts similarity index 98% rename from src/datasources/accounts/notifications/notifications.datasource.ts rename to src/datasources/notifications/notifications.datasource.ts index fe7f6cdb4c..3d81920d90 100644 --- a/src/datasources/accounts/notifications/notifications.datasource.ts +++ b/src/datasources/notifications/notifications.datasource.ts @@ -11,7 +11,7 @@ import { UnprocessableEntityException, } from '@nestjs/common'; import postgres from 'postgres'; -import { UpsertSubscriptionsDto } from '@/datasources/accounts/notifications/entities/upsert-subscriptions.dto.entity'; +import { UpsertSubscriptionsDto } from '@/datasources/notifications/entities/upsert-subscriptions.dto.entity'; @Injectable() export class NotificationsDatasource implements INotificationsDatasource { diff --git a/src/domain/interfaces/notifications.datasource.interface.ts b/src/domain/interfaces/notifications.datasource.interface.ts index e45c7f3150..a50edbddab 100644 --- a/src/domain/interfaces/notifications.datasource.interface.ts +++ b/src/domain/interfaces/notifications.datasource.interface.ts @@ -1,4 +1,4 @@ -import { UpsertSubscriptionsDto } from '@/datasources/accounts/notifications/entities/upsert-subscriptions.dto.entity'; +import { UpsertSubscriptionsDto } from '@/datasources/notifications/entities/upsert-subscriptions.dto.entity'; import { NotificationType } from '@/domain/notifications/entities-v2/notification-type.entity'; import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; From c908faee5f77d852c40bdd04ca760c24d4ec5908 Mon Sep 17 00:00:00 2001 From: iamacook Date: Tue, 30 Jul 2024 18:28:36 +0200 Subject: [PATCH 32/37] Remove unnecessary entity and fix typos --- .../entities/upsert-subscriptions.dto.entity.ts | 14 -------------- src/domain/hooks/hooks.repository.interface.ts | 2 +- src/domain/hooks/hooks.repository.ts | 2 +- src/routes/hooks/hooks.module.ts | 2 +- 4 files changed, 3 insertions(+), 17 deletions(-) delete mode 100644 src/datasources/notifications/entities/upsert-subscriptions.dto.entity.ts diff --git a/src/datasources/notifications/entities/upsert-subscriptions.dto.entity.ts b/src/datasources/notifications/entities/upsert-subscriptions.dto.entity.ts deleted file mode 100644 index e896cdea03..0000000000 --- a/src/datasources/notifications/entities/upsert-subscriptions.dto.entity.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { DeviceType } from '@/domain/notifications/entities-v2/device-type.entity'; -import { NotificationType } from '@/domain/notifications/entities-v2/notification-type.entity'; -import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; - -export type UpsertSubscriptionsDto = { - cloudMessagingToken: string; - safes: Array<{ - chainId: string; - address: `0x${string}`; - notificationTypes: Array; - }>; - deviceType: DeviceType; - deviceUuid?: Uuid; -}; diff --git a/src/domain/hooks/hooks.repository.interface.ts b/src/domain/hooks/hooks.repository.interface.ts index 048c078908..a01e3471ae 100644 --- a/src/domain/hooks/hooks.repository.interface.ts +++ b/src/domain/hooks/hooks.repository.interface.ts @@ -41,7 +41,7 @@ export interface IHooksRepository { }) export class HooksRepositoryWithNotificationsModule {} -// TODO: Remove after notifications FF is enables +// TODO: Remove after notifications FF is enabled // Note: trying to convert this into a dynamic module proved to be too complex // due to config injection issues from the ConfigurationService so this is a // temporary solution diff --git a/src/domain/hooks/hooks.repository.ts b/src/domain/hooks/hooks.repository.ts index b621d07a13..6bec17880b 100644 --- a/src/domain/hooks/hooks.repository.ts +++ b/src/domain/hooks/hooks.repository.ts @@ -612,7 +612,7 @@ export class HooksRepositoryWithNotifications implements IHooksRepository { } } -// TODO: Remove after notifications FF is enables +// TODO: Remove after notifications FF is enabled // Note: trying to convert this into a dynamic module proved to be too complex // due to config injection issues from the ConfigurationService so this is a // temporary solution diff --git a/src/routes/hooks/hooks.module.ts b/src/routes/hooks/hooks.module.ts index f818b236ab..8dfab29034 100644 --- a/src/routes/hooks/hooks.module.ts +++ b/src/routes/hooks/hooks.module.ts @@ -13,7 +13,7 @@ import { HooksService } from '@/routes/hooks/hooks.service'; }) export class HooksModuleWithNotifications {} -// TODO: Remove after notifications FF is enables +// TODO: Remove after notifications FF is enabled // Note: trying to convert this into a dynamic module proved to be too complex // due to config injection issues from the ConfigurationService so this is a // temporary solution From c13f4b8d941fc4c59b0ddd89c758f0e5c9660173 Mon Sep 17 00:00:00 2001 From: iamacook Date: Wed, 31 Jul 2024 16:37:44 +0200 Subject: [PATCH 33/37] Dispatch notifications on hook --- .../helpers/event-notifications.helper.ts | 399 ++++ .../hooks/hooks.repository.interface.ts | 4 +- src/domain/hooks/hooks.repository.ts | 202 +- src/routes/hooks/hooks-notifications.spec.ts | 2003 +++++++++++++---- 4 files changed, 1995 insertions(+), 613 deletions(-) create mode 100644 src/domain/hooks/helpers/event-notifications.helper.ts diff --git a/src/domain/hooks/helpers/event-notifications.helper.ts b/src/domain/hooks/helpers/event-notifications.helper.ts new file mode 100644 index 0000000000..25b6318895 --- /dev/null +++ b/src/domain/hooks/helpers/event-notifications.helper.ts @@ -0,0 +1,399 @@ +import { Inject, Injectable, Module } from '@nestjs/common'; +import { + IMessagesRepository, + MessagesRepositoryModule, +} from '@/domain/messages/messages.repository.interface'; +import { + ISafeRepository, + SafeRepositoryModule, +} from '@/domain/safe/safe.repository.interface'; +import { + TransactionEventType, + ConfigEventType, +} from '@/routes/hooks/entities/event-type.entity'; +import { LoggingService, ILoggingService } from '@/logging/logging.interface'; +import { Event } from '@/routes/hooks/entities/event.entity'; +import { + INotificationsRepositoryV2, + NotificationsRepositoryV2Module, +} from '@/domain/notifications/notifications.repository.v2.interface'; +import { DeletedMultisigTransactionEvent } from '@/routes/hooks/entities/schemas/deleted-multisig-transaction.schema'; +import { ExecutedTransactionEvent } from '@/routes/hooks/entities/schemas/executed-transaction.schema'; +import { IncomingEtherEvent } from '@/routes/hooks/entities/schemas/incoming-ether.schema'; +import { IncomingTokenEvent } from '@/routes/hooks/entities/schemas/incoming-token.schema'; +import { ModuleTransactionEvent } from '@/routes/hooks/entities/schemas/module-transaction.schema'; +import { PendingTransactionEvent } from '@/routes/hooks/entities/schemas/pending-transaction.schema'; +import { MessageCreatedEvent } from '@/routes/hooks/entities/schemas/message-created.schema'; +import { + IncomingEtherNotification, + IncomingTokenNotification, + ConfirmationRequestNotification, + MessageConfirmationNotification, + Notification, + NotificationType, +} from '@/domain/notifications/entities-v2/notification.entity'; +import { + DelegatesV2RepositoryModule, + IDelegatesV2Repository, +} from '@/domain/delegate/v2/delegates.v2.repository.interface'; +import { UUID } from 'crypto'; + +type EventToNotify = + | DeletedMultisigTransactionEvent + | ExecutedTransactionEvent + | IncomingEtherEvent + | IncomingTokenEvent + | ModuleTransactionEvent + | MessageCreatedEvent + | PendingTransactionEvent; + +@Injectable() +export class EventNotificationsHelper { + constructor( + @Inject(IDelegatesV2Repository) + private readonly delegatesRepository: IDelegatesV2Repository, + @Inject(IMessagesRepository) + private readonly messagesRepository: IMessagesRepository, + @Inject(ISafeRepository) + private readonly safeRepository: ISafeRepository, + @Inject(LoggingService) + private readonly loggingService: ILoggingService, + @Inject(INotificationsRepositoryV2) + private readonly notificationsRepository: INotificationsRepositoryV2, + ) {} + + /** + * Enqueues notifications for the relevant events to owners/delegates + * and non-owners/delegates of a Safe accordingly. + * + * @param event - {@link Event} to notify about + */ + public async onEventEnqueueNotifications(event: Event): Promise { + console.log('==> onEventEnqueueNotifications'); + if (!this.isEventToNotify(event)) { + return; + } + + const subscriptions = await this.getRelevantSubscribers(event); + + return await Promise.allSettled( + subscriptions.map(async (subscription) => { + const data = await this.mapEventNotification( + event, + subscription.subscriber, + ); + + if (!data) { + return; + } + + return this.notificationsRepository + .enqueueNotification({ + token: subscription.cloudMessagingToken, + deviceUuid: subscription.deviceUuid, + notification: { + data, + }, + }) + .then(() => { + this.loggingService.info('Notification sent successfully'); + }) + .catch((e) => { + this.loggingService.error( + `Failed to send notification: ${e.reason}`, + ); + }); + }), + ); + } + + /** + * Checks if the event is to be notified. + * + * @param event - {@link Event} to check + */ + private isEventToNotify(event: Event): event is EventToNotify { + return ( + // Don't notify about Config events + event.type !== ConfigEventType.CHAIN_UPDATE && + event.type !== ConfigEventType.SAFE_APPS_UPDATE && + // We otherwise notify about executed transactions + event.type !== TransactionEventType.OUTGOING_ETHER && + event.type !== TransactionEventType.OUTGOING_TOKEN && + // We only notify required confirmations on creation - see PENDING_MULTISIG_TRANSACTION + event.type !== TransactionEventType.NEW_CONFIRMATION && + // We only notify required confirmations on required - see MESSAGE_CREATED + event.type !== TransactionEventType.MESSAGE_CONFIRMATION && + // You cannot subscribe to Safes-to-be-created + event.type !== TransactionEventType.SAFE_CREATED + ); + } + + /** + * Checks if the event an owner/delegate only event. + * @param event - {@link EventToNotify} to check + */ + private isOwnerOrDelegateOnlyEventToNotify( + event: EventToNotify, + ): event is PendingTransactionEvent | MessageCreatedEvent { + // We only notify required confirmation events to owners or delegates + // to prevent other subscribers from receiving "private" events + return ( + event.type === TransactionEventType.PENDING_MULTISIG_TRANSACTION || + event.type === TransactionEventType.MESSAGE_CREATED + ); + } + + /** + * Gets subscribers and their device UUID/cloud messaging tokens for the + * given Safe depending on the event type. + * + * @param event - {@link EventToNotify} to get subscribers for + * + * @returns - List of subscribers/tokens for given Safe + */ + private async getRelevantSubscribers(event: EventToNotify): Promise< + Array<{ + subscriber: `0x${string}`; + deviceUuid: UUID; + cloudMessagingToken: string; + }> + > { + const subscriptions = + await this.notificationsRepository.getSubscribersBySafe({ + chainId: event.chainId, + safeAddress: event.address, + }); + + if (!this.isOwnerOrDelegateOnlyEventToNotify(event)) { + return subscriptions; + } + + const ownersAndDelegates = await Promise.allSettled( + subscriptions.map(async (subscription) => { + const isOwnerOrDelegate = await this.isOwnerOrDelegate({ + chainId: event.chainId, + safeAddress: event.address, + subscriber: subscription.subscriber, + }); + + if (!isOwnerOrDelegate) { + return; + } + + return subscription; + }), + ); + + return ownersAndDelegates + .filter( + ( + item: PromiseSettledResult, + ): item is PromiseFulfilledResult> => { + return item.status === 'fulfilled' && !!item.value; + }, + ) + .map((result) => result.value); + } + + /** + * Checks if the subscriber is an owner or delegate of the Safe. + * + * @param args.chainId - Chain ID + * @param args.safeAddress - Safe address + * @param args.subscriber - Subscriber address + * + * @returns - True if the subscriber is an owner or delegate of the Safe, otherwise false + */ + private async isOwnerOrDelegate(args: { + chainId: string; + safeAddress: `0x${string}`; + subscriber: `0x${string}`; + }): Promise { + // We don't use Promise.all avoid unnecessary calls for delegates + const safe = await this.safeRepository.getSafe({ + chainId: args.chainId, + address: args.safeAddress, + }); + if (safe?.owners.includes(args.subscriber)) { + return true; + } + + const delegates = await this.delegatesRepository.getDelegates(args); + return !!delegates?.results.some((delegate) => { + return ( + delegate.safe === args.safeAddress && + delegate.delegate === args.subscriber + ); + }); + } + + /** + * Maps an {@link EventToNotify} to a notification. + * + * @param event - {@link EventToNotify} to map + * @param subscriber - Subscriber address + * + * @returns - The {@link Notification} if the conditions are met, otherwise null + */ + private async mapEventNotification( + event: EventToNotify, + subscriber: `0x${string}`, + ): Promise { + if ( + event.type === TransactionEventType.INCOMING_ETHER || + event.type === TransactionEventType.INCOMING_TOKEN + ) { + return await this.mapIncomingAssetEventNotification(event); + } else if ( + event.type === TransactionEventType.PENDING_MULTISIG_TRANSACTION + ) { + return await this.mapPendingMultisigTransactionEventNotification( + event, + subscriber, + ); + } else if (event.type === TransactionEventType.MESSAGE_CREATED) { + return await this.mapMessageCreatedEventNotification(event, subscriber); + } else { + return event; + } + } + + /** + * Maps {@link IncomingEtherEvent} or {@link IncomingTokenEvent} to {@link IncomingEtherNotification} or {@link IncomingTokenNotification} if: + * + * - The asset was sent to the Safe by another address. + * + * @param event - {@link IncomingEtherEvent} or {@link IncomingTokenEvent} to map + * + * @returns - The {@link IncomingEtherNotification} or {@link IncomingTokenNotification} if the conditions are met, otherwise null + */ + private async mapIncomingAssetEventNotification( + event: IncomingEtherEvent | IncomingTokenEvent, + ): Promise { + const incomingTransfers = await this.safeRepository + .getIncomingTransfers({ + chainId: event.chainId, + safeAddress: event.address, + txHash: event.txHash, + }) + .catch(() => null); + + const transfer = incomingTransfers?.results?.find((result) => { + return result.transactionHash === event.txHash; + }); + + // Asset sent to self - do not notify + if (transfer?.from === event.address) { + return null; + } + + return event; + } + + /** + * Maps {@link PendingTransactionEvent} to {@link ConfirmationRequestNotification} if: + * + * - The Safe has a threshold > 1. + * - The subscriber didn't create the transaction. + * + * @param event - {@link PendingTransactionEvent} to map + * @param subscriber - Subscriber address + * + * @returns - The {@link ConfirmationRequestNotification} if the conditions are met, otherwise null + */ + private async mapPendingMultisigTransactionEventNotification( + event: PendingTransactionEvent, + subscriber: `0x${string}`, + ): Promise { + const safe = await this.safeRepository.getSafe({ + chainId: event.chainId, + address: event.address, + }); + + // Transaction is confirmed and awaiting execution - do not notify + if (safe.threshold === 1) { + return null; + } + + const transaction = await this.safeRepository.getMultiSigTransaction({ + chainId: event.chainId, + safeTransactionHash: event.safeTxHash, + }); + + // Subscriber has already signed - do not notify + const hasSubscriberSigned = transaction.confirmations?.some( + (confirmation) => { + return confirmation.owner === subscriber; + }, + ); + if (hasSubscriberSigned) { + return null; + } + + return { + type: NotificationType.CONFIRMATION_REQUEST, + chainId: event.chainId, + address: event.address, + safeTxHash: event.safeTxHash, + }; + } + + /** + * Maps {@link MessageCreatedEvent} to {@link MessageConfirmationNotification} if: + * + * - The Safe has a threshold > 1. + * - The subscriber didn't create the message. + * + * @param event - {@link MessageCreatedEvent} to map + * @param subscriber - Subscriber address + * + * @returns - The {@link MessageConfirmationNotification} if the conditions are met, otherwise null + */ + private async mapMessageCreatedEventNotification( + event: MessageCreatedEvent, + subscriber: `0x${string}`, + ): Promise { + const safe = await this.safeRepository.getSafe({ + chainId: event.chainId, + address: event.address, + }); + + // Message is confirmed - do not notify + if (safe.threshold === 1) { + return null; + } + + const message = await this.messagesRepository.getMessageByHash({ + chainId: event.chainId, + messageHash: event.messageHash, + }); + + // Subscriber has already signed - do not notify + const hasSubscriberSigned = message.confirmations.some((confirmation) => { + return confirmation.owner === subscriber; + }); + if (hasSubscriberSigned) { + return null; + } + + return { + type: NotificationType.MESSAGE_CONFIRMATION_REQUEST, + chainId: event.chainId, + address: event.address, + messageHash: event.messageHash, + }; + } +} + +@Module({ + imports: [ + DelegatesV2RepositoryModule, + MessagesRepositoryModule, + SafeRepositoryModule, + NotificationsRepositoryV2Module, + ], + providers: [EventNotificationsHelper], + exports: [EventNotificationsHelper], +}) +export class EventNotificationsHelperModule {} diff --git a/src/domain/hooks/hooks.repository.interface.ts b/src/domain/hooks/hooks.repository.interface.ts index a01e3471ae..ce4a117d69 100644 --- a/src/domain/hooks/hooks.repository.interface.ts +++ b/src/domain/hooks/hooks.repository.interface.ts @@ -2,12 +2,12 @@ import { BalancesRepositoryModule } from '@/domain/balances/balances.repository. import { BlockchainRepositoryModule } from '@/domain/blockchain/blockchain.repository.interface'; import { ChainsRepositoryModule } from '@/domain/chains/chains.repository.interface'; import { CollectiblesRepositoryModule } from '@/domain/collectibles/collectibles.repository.interface'; +import { EventNotificationsHelperModule } from '@/domain/hooks/helpers/event-notifications.helper'; import { HooksRepository, HooksRepositoryWithNotifications, } from '@/domain/hooks/hooks.repository'; import { MessagesRepositoryModule } from '@/domain/messages/messages.repository.interface'; -import { NotificationsRepositoryV2Module } from '@/domain/notifications/notifications.repository.v2.interface'; import { QueuesRepositoryModule } from '@/domain/queues/queues-repository.interface'; import { SafeAppsRepositoryModule } from '@/domain/safe-apps/safe-apps.repository.interface'; import { SafeRepositoryModule } from '@/domain/safe/safe.repository.interface'; @@ -28,7 +28,7 @@ export interface IHooksRepository { ChainsRepositoryModule, CollectiblesRepositoryModule, MessagesRepositoryModule, - NotificationsRepositoryV2Module, + EventNotificationsHelperModule, SafeAppsRepositoryModule, SafeRepositoryModule, TransactionsRepositoryModule, diff --git a/src/domain/hooks/hooks.repository.ts b/src/domain/hooks/hooks.repository.ts index 6bec17880b..225ddf3dac 100644 --- a/src/domain/hooks/hooks.repository.ts +++ b/src/domain/hooks/hooks.repository.ts @@ -18,22 +18,7 @@ import { ConsumeMessage } from 'amqplib'; import { EventSchema } from '@/routes/hooks/entities/schemas/event.schema'; import { IBlockchainRepository } from '@/domain/blockchain/blockchain.repository.interface'; import { IHooksRepository } from '@/domain/hooks/hooks.repository.interface'; -import { INotificationsRepositoryV2 } from '@/domain/notifications/notifications.repository.v2.interface'; -import { DeletedMultisigTransactionEvent } from '@/routes/hooks/entities/schemas/deleted-multisig-transaction.schema'; -import { ExecutedTransactionEvent } from '@/routes/hooks/entities/schemas/executed-transaction.schema'; -import { IncomingEtherEvent } from '@/routes/hooks/entities/schemas/incoming-ether.schema'; -import { IncomingTokenEvent } from '@/routes/hooks/entities/schemas/incoming-token.schema'; -import { ModuleTransactionEvent } from '@/routes/hooks/entities/schemas/module-transaction.schema'; -import { PendingTransactionEvent } from '@/routes/hooks/entities/schemas/pending-transaction.schema'; -import { MessageCreatedEvent } from '@/routes/hooks/entities/schemas/message-created.schema'; -import { - IncomingEtherNotification, - IncomingTokenNotification, - ConfirmationRequestNotification, - MessageConfirmationNotification, - Notification, - NotificationType, -} from '@/domain/notifications/entities-v2/notification.entity'; +import { EventNotificationsHelper } from '@/domain/hooks/helpers/event-notifications.helper'; @Injectable() export class HooksRepositoryWithNotifications implements IHooksRepository { @@ -63,8 +48,8 @@ export class HooksRepositoryWithNotifications implements IHooksRepository { private readonly queuesRepository: IQueuesRepository, @Inject(IConfigurationService) private readonly configurationService: IConfigurationService, - @Inject(INotificationsRepositoryV2) - private readonly notificationsRepository: INotificationsRepositoryV2, + @Inject(EventNotificationsHelper) + private readonly eventNotificationsHelper: EventNotificationsHelper, ) { this.queueName = this.configurationService.getOrThrow('amqp.queue'); } @@ -87,7 +72,7 @@ export class HooksRepositoryWithNotifications implements IHooksRepository { async onEvent(event: Event): Promise { return Promise.allSettled([ this.onEventClearCache(event), - this.onEventEnqueueNotifications(event), + this.eventNotificationsHelper.onEventEnqueueNotifications(event), ]).finally(() => { this.onEventLog(event); }); @@ -360,185 +345,6 @@ export class HooksRepositoryWithNotifications implements IHooksRepository { return Promise.all(promises); } - private async onEventEnqueueNotifications(event: Event): Promise { - if ( - // Don't notify about Config events - event.type === ConfigEventType.CHAIN_UPDATE || - event.type === ConfigEventType.SAFE_APPS_UPDATE || - // We already notify about executed multisig/module transactions - event.type === TransactionEventType.OUTGOING_ETHER || - event.type === TransactionEventType.OUTGOING_TOKEN || - // We only notify required confirmations on creation - see PENDING_MULTISIG_TRANSACTION - event.type === TransactionEventType.NEW_CONFIRMATION || - // We only notify required confirmations on required - see MESSAGE_CREATED - event.type === TransactionEventType.MESSAGE_CONFIRMATION || - // You cannot subscribe to Safes-to-be-created - event.type === TransactionEventType.SAFE_CREATED - ) { - return; - } - - const subscriptions = - await this.notificationsRepository.getSubscribersBySafe({ - chainId: event.chainId, - safeAddress: event.address, - }); - - // Enqueue notifications for each subscriber relative to event - return await Promise.allSettled( - subscriptions.map(async (subscription) => { - const data = await this.mapEventNotification( - event, - subscription.subscriber, - ); - - if (!data) { - return; - } - - return this.notificationsRepository - .enqueueNotification({ - token: subscription.cloudMessagingToken, - deviceUuid: subscription.deviceUuid, - notification: { - data, - }, - }) - .then(() => { - this.loggingService.info('Notification sent successfully'); - }) - .catch((e) => { - this.loggingService.error( - `Failed to send notification: ${e.reason}`, - ); - }); - }), - ); - } - - private async mapEventNotification( - event: - | DeletedMultisigTransactionEvent - | ExecutedTransactionEvent - | IncomingEtherEvent - | IncomingTokenEvent - | ModuleTransactionEvent - | MessageCreatedEvent - | PendingTransactionEvent, - subscriber: `0x${string}`, - ): Promise { - if ( - event.type === TransactionEventType.INCOMING_ETHER || - event.type === TransactionEventType.INCOMING_TOKEN - ) { - return await this.mapIncomingAssetEventNotification(event); - } else if ( - event.type === TransactionEventType.PENDING_MULTISIG_TRANSACTION - ) { - return await this.mapPendingMultisigTransactionEventNotification( - event, - subscriber, - ); - } else if (event.type === TransactionEventType.MESSAGE_CREATED) { - return await this.mapMessageCreatedEventNotification(event, subscriber); - } else { - return event; - } - } - - private async mapIncomingAssetEventNotification( - event: IncomingEtherEvent | IncomingTokenEvent, - ): Promise { - const incomingTransfers = await this.safeRepository - .getIncomingTransfers({ - chainId: event.chainId, - safeAddress: event.address, - txHash: event.txHash, - }) - .catch(() => null); - - const transfer = incomingTransfers?.results?.find((result) => { - return result.transactionHash === event.txHash; - }); - - // Asset sent to self - if (transfer?.from === event.address) { - return null; - } - - return event; - } - - private async mapPendingMultisigTransactionEventNotification( - event: PendingTransactionEvent, - subscriber: `0x${string}`, - ): Promise { - const safe = await this.safeRepository.getSafe({ - chainId: event.chainId, - address: event.address, - }); - - // Transaction is confirmed and awaiting execution - if (safe.threshold === 1) { - return null; - } - - const transaction = await this.safeRepository.getMultiSigTransaction({ - chainId: event.chainId, - safeTransactionHash: event.safeTxHash, - }); - - const hasSubscriberSigned = transaction.confirmations?.some( - (confirmation) => { - return confirmation.owner === subscriber; - }, - ); - if (hasSubscriberSigned) { - return null; - } - - return { - type: NotificationType.CONFIRMATION_REQUEST, - chainId: event.chainId, - address: event.address, - safeTxHash: event.safeTxHash, - }; - } - - private async mapMessageCreatedEventNotification( - event: MessageCreatedEvent, - subscriber: `0x${string}`, - ): Promise { - const safe = await this.safeRepository.getSafe({ - chainId: event.chainId, - address: event.address, - }); - - // Message is valid - if (safe.threshold === 1) { - return null; - } - - const message = await this.messagesRepository.getMessageByHash({ - chainId: event.chainId, - messageHash: event.messageHash, - }); - - const hasSubscriberSigned = message.confirmations.some((confirmation) => { - return confirmation.owner === subscriber; - }); - if (hasSubscriberSigned) { - return null; - } - - return { - type: NotificationType.MESSAGE_CONFIRMATION_REQUEST, - chainId: event.chainId, - address: event.address, - messageHash: event.messageHash, - }; - } - private onEventLog(event: Event): void { switch (event.type) { case TransactionEventType.PENDING_MULTISIG_TRANSACTION: diff --git a/src/routes/hooks/hooks-notifications.spec.ts b/src/routes/hooks/hooks-notifications.spec.ts index d7a48c5b12..78cb2788df 100644 --- a/src/routes/hooks/hooks-notifications.spec.ts +++ b/src/routes/hooks/hooks-notifications.spec.ts @@ -50,6 +50,7 @@ import { messageBuilder } from '@/domain/messages/entities/__tests__/message.bui import { messageCreatedEventBuilder } from '@/routes/hooks/entities/__tests__/message-created.builder'; import { messageConfirmationBuilder } from '@/domain/messages/entities/__tests__/message-confirmation.builder'; import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; +import { delegateBuilder } from '@/domain/delegate/entities/__tests__/delegate.builder'; describe('Post Hook Events for Notifications (Unit)', () => { let app: INestApplication; @@ -313,420 +314,1413 @@ describe('Post Hook Events for Notifications (Unit)', () => { }, ); - it("should enqueue PENDING_MULTISIG_TRANSACTION event notifications if the Safe has a threshold > 1 and the subscriber hasn't yet signed", async () => { - const event = pendingTransactionEventBuilder().build(); - const chain = chainBuilder().with('chainId', event.chainId).build(); - const safe = safeBuilder() - .with('address', event.address) - .with('threshold', faker.number.int({ min: 2 })) - .build(); - const multisigTransaction = multisigTransactionBuilder() - .with('safe', event.address) - .build(); - const subscribers = Array.from( - { - length: faker.number.int({ min: 1, max: 5 }), - }, - () => ({ - subscriber: getAddress(faker.finance.ethereumAddress()), - deviceUuid: faker.string.uuid() as Uuid, - cloudMessagingToken: faker.string.alphanumeric(), - }), - ); - notificationsDatasource.getSubscribersBySafe.mockResolvedValue(subscribers); + describe('owners', () => { + it("should enqueue PENDING_MULTISIG_TRANSACTION event notifications if the Safe has a threshold > 1 and the owner hasn't yet signed", async () => { + const event = pendingTransactionEventBuilder().build(); + const chain = chainBuilder().with('chainId', event.chainId).build(); + const multisigTransaction = multisigTransactionBuilder() + .with('safe', event.address) + .build(); + const subscribers = Array.from( + { + length: faker.number.int({ min: 1, max: 5 }), + }, + () => ({ + subscriber: getAddress(faker.finance.ethereumAddress()), + deviceUuid: faker.string.uuid() as Uuid, + cloudMessagingToken: faker.string.alphanumeric(), + }), + ); + const safe = safeBuilder() + .with('address', event.address) + .with( + 'owners', + subscribers.map((subscriber) => subscriber.subscriber), + ) + .with('threshold', faker.number.int({ min: 2 })) + .build(); + notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + subscribers, + ); - networkService.get.mockImplementation(({ url }) => { - if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { - return Promise.resolve({ - data: chain, - status: 200, - }); - } else if ( - url === `${chain.transactionService}/api/v1/safes/${event.address}` - ) { - return Promise.resolve({ - status: 200, - data: safe, - }); - } else if ( - url === - `${chain.transactionService}/api/v1/multisig-transactions/${event.safeTxHash}/` - ) { - return Promise.resolve({ - status: 200, - data: multisigTransaction, - }); - } else { - return Promise.reject(`No matching rule for url: ${url}`); - } - }); + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { + return Promise.resolve({ + data: chain, + status: 200, + }); + } else if ( + url === `${chain.transactionService}/api/v1/safes/${event.address}` + ) { + return Promise.resolve({ + status: 200, + data: safe, + }); + } else if ( + url === + `${chain.transactionService}/api/v1/multisig-transactions/${event.safeTxHash}/` + ) { + return Promise.resolve({ + status: 200, + data: multisigTransaction, + }); + } else { + return Promise.reject(`No matching rule for url: ${url}`); + } + }); - await request(app.getHttpServer()) - .post(`/hooks/events`) - .set('Authorization', `Basic ${authToken}`) - .send(event) - .expect(202); + await request(app.getHttpServer()) + .post(`/hooks/events`) + .set('Authorization', `Basic ${authToken}`) + .send(event) + .expect(202); - expect(pushNotificationsApi.enqueueNotification).toHaveBeenCalledTimes( - subscribers.length, - ); - subscribers.forEach((subscriber, i) => { - expect(pushNotificationsApi.enqueueNotification).toHaveBeenNthCalledWith( - i + 1, - subscriber.cloudMessagingToken, - { + expect(pushNotificationsApi.enqueueNotification).toHaveBeenCalledTimes( + subscribers.length, + ); + subscribers.forEach((subscriber, i) => { + expect( + pushNotificationsApi.enqueueNotification, + ).toHaveBeenNthCalledWith(i + 1, subscriber.cloudMessagingToken, { data: { ...event, type: 'CONFIRMATION_REQUEST', }, + }); + }); + }); + + it('should not enqueue PENDING_MULTISIG_TRANSACTION event notifications if the Safe has a threshold of 1', async () => { + const event = pendingTransactionEventBuilder().build(); + const chain = chainBuilder().with('chainId', event.chainId).build(); + const subscribers = Array.from( + { + length: faker.number.int({ min: 1, max: 5 }), }, + () => ({ + subscriber: getAddress(faker.finance.ethereumAddress()), + deviceUuid: faker.string.uuid() as Uuid, + cloudMessagingToken: faker.string.alphanumeric(), + }), + ); + const safe = safeBuilder() + .with('address', event.address) + .with('threshold', 1) + .with( + 'owners', + subscribers.map((subscriber) => subscriber.subscriber), + ) + .build(); + notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + subscribers, ); - }); - }); - it('should not enqueue PENDING_MULTISIG_TRANSACTION event notifications if the Safe has a threshold of 1', async () => { - const event = pendingTransactionEventBuilder().build(); - const chain = chainBuilder().with('chainId', event.chainId).build(); - const safe = safeBuilder() - .with('address', event.address) - .with('threshold', 1) - .build(); - const subscribers = Array.from( - { - length: faker.number.int({ min: 1, max: 5 }), - }, - () => ({ - subscriber: getAddress(faker.finance.ethereumAddress()), - deviceUuid: faker.string.uuid() as Uuid, - cloudMessagingToken: faker.string.alphanumeric(), - }), - ); - notificationsDatasource.getSubscribersBySafe.mockResolvedValue(subscribers); + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { + return Promise.resolve({ + data: chain, + status: 200, + }); + } else if ( + url === `${chain.transactionService}/api/v1/safes/${event.address}` + ) { + return Promise.resolve({ + status: 200, + data: safe, + }); + } else { + return Promise.reject(`No matching rule for url: ${url}`); + } + }); - networkService.get.mockImplementation(({ url }) => { - if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { - return Promise.resolve({ - data: chain, - status: 200, - }); - } else if ( - url === `${chain.transactionService}/api/v1/safes/${event.address}` - ) { - return Promise.resolve({ - status: 200, - data: safe, - }); - } else { - return Promise.reject(`No matching rule for url: ${url}`); - } + await request(app.getHttpServer()) + .post(`/hooks/events`) + .set('Authorization', `Basic ${authToken}`) + .send(event) + .expect(202); + + expect(pushNotificationsApi.enqueueNotification).not.toHaveBeenCalled(); }); - await request(app.getHttpServer()) - .post(`/hooks/events`) - .set('Authorization', `Basic ${authToken}`) - .send(event) - .expect(202); + it('should not enqueue PENDING_MULTISIG_TRANSACTION event notifications if the Safe has a threshold > 1 but the owner has signed', async () => { + const event = pendingTransactionEventBuilder().build(); + const chain = chainBuilder().with('chainId', event.chainId).build(); + const subscribers = Array.from( + { + length: faker.number.int({ min: 1, max: 5 }), + }, + () => ({ + subscriber: getAddress(faker.finance.ethereumAddress()), + deviceUuid: faker.string.uuid() as Uuid, + cloudMessagingToken: faker.string.alphanumeric(), + }), + ); + const safe = safeBuilder() + .with('address', event.address) + .with('threshold', faker.number.int({ min: 2 })) + .with( + 'owners', + subscribers.map((subscriber) => subscriber.subscriber), + ) + .build(); + notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + subscribers, + ); + const multisigTransaction = multisigTransactionBuilder() + .with( + 'confirmations', + subscribers.map((subscriber) => { + return confirmationBuilder() + .with('owner', subscriber.subscriber) + .build(); + }), + ) + .build(); - expect(pushNotificationsApi.enqueueNotification).not.toHaveBeenCalled(); - }); + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { + return Promise.resolve({ + data: chain, + status: 200, + }); + } else if ( + url === `${chain.transactionService}/api/v1/safes/${event.address}` + ) { + return Promise.resolve({ + status: 200, + data: safe, + }); + } else if ( + url === + `${chain.transactionService}/api/v1/multisig-transactions/${event.safeTxHash}/` + ) { + return Promise.resolve({ + status: 200, + data: multisigTransaction, + }); + } else { + return Promise.reject(`No matching rule for url: ${url}`); + } + }); - it('should not enqueue PENDING_MULTISIG_TRANSACTION event notifications if the Safe has a threshold > 1 but the subscriber has signed', async () => { - const event = pendingTransactionEventBuilder().build(); - const chain = chainBuilder().with('chainId', event.chainId).build(); - const safe = safeBuilder() - .with('address', event.address) - .with('threshold', faker.number.int({ min: 2 })) - .build(); - const subscribers = Array.from( - { - length: faker.number.int({ min: 1, max: 5 }), - }, - () => ({ - subscriber: getAddress(faker.finance.ethereumAddress()), + await request(app.getHttpServer()) + .post(`/hooks/events`) + .set('Authorization', `Basic ${authToken}`) + .send(event) + .expect(202); + + expect(pushNotificationsApi.enqueueNotification).not.toHaveBeenCalled(); + }); + + it("should only enqueue PENDING_MULTISIG_TRANSACTION event notifications for those that haven't signed", async () => { + const event = pendingTransactionEventBuilder().build(); + const chain = chainBuilder().with('chainId', event.chainId).build(); + const owners = [ + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + ]; + const safe = safeBuilder() + .with('address', event.address) + .with('threshold', faker.number.int({ min: 2 })) + .with('owners', owners) + .build(); + const subscribers = owners.map((owner) => ({ + subscriber: owner, deviceUuid: faker.string.uuid() as Uuid, cloudMessagingToken: faker.string.alphanumeric(), - }), - ); - notificationsDatasource.getSubscribersBySafe.mockResolvedValue(subscribers); - const multisigTransaction = multisigTransactionBuilder() - .with( - 'confirmations', - subscribers.map((subscriber) => { - return confirmationBuilder() - .with('owner', subscriber.subscriber) - .build(); - }), - ) - .build(); - - networkService.get.mockImplementation(({ url }) => { - if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { - return Promise.resolve({ - data: chain, - status: 200, + })); + notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + subscribers, + ); + const confirmations = faker.helpers + .arrayElements(owners, { min: 1, max: owners.length - 1 }) + .map((owner) => { + return confirmationBuilder().with('owner', owner).build(); }); - } else if ( - url === `${chain.transactionService}/api/v1/safes/${event.address}` - ) { - return Promise.resolve({ - status: 200, - data: safe, + const multisigTransaction = multisigTransactionBuilder() + .with('safe', event.address) + .with('confirmations', confirmations) + .build(); + + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { + return Promise.resolve({ + data: chain, + status: 200, + }); + } else if ( + url === `${chain.transactionService}/api/v1/safes/${event.address}` + ) { + return Promise.resolve({ + status: 200, + data: safe, + }); + } else if ( + url === + `${chain.transactionService}/api/v1/multisig-transactions/${event.safeTxHash}/` + ) { + return Promise.resolve({ + status: 200, + data: multisigTransaction, + }); + } else { + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/hooks/events`) + .set('Authorization', `Basic ${authToken}`) + .send(event) + .expect(202); + + expect(pushNotificationsApi.enqueueNotification).toHaveBeenCalledTimes( + subscribers.length - confirmations.length, + ); + expect(pushNotificationsApi.enqueueNotification.mock.calls).toStrictEqual( + expect.arrayContaining( + subscribers + .filter((subscriber) => { + return confirmations.every((confirmation) => { + return confirmation.owner !== subscriber.subscriber; + }); + }) + .map((subscriber) => [ + subscriber.cloudMessagingToken, + { + data: { + ...event, + type: 'CONFIRMATION_REQUEST', + }, + }, + ]), + ), + ); + }); + + it("should enqueue MESSAGE_CONFIRMATION_REQUEST event notifications if the Safe has a threshold > 1 and the owner hasn't yet signed", async () => { + const event = messageCreatedEventBuilder().build(); + const chain = chainBuilder().with('chainId', event.chainId).build(); + const message = messageBuilder() + .with('messageHash', event.messageHash as `0x${string}`) + .build(); + const subscribers = Array.from( + { + length: faker.number.int({ min: 1, max: 5 }), + }, + () => ({ + subscriber: getAddress(faker.finance.ethereumAddress()), + deviceUuid: faker.string.uuid() as Uuid, + cloudMessagingToken: faker.string.alphanumeric(), + }), + ); + const safe = safeBuilder() + .with('address', event.address) + .with('threshold', faker.number.int({ min: 2 })) + .with( + 'owners', + subscribers.map((subscriber) => subscriber.subscriber), + ) + .build(); + notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + subscribers, + ); + + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { + return Promise.resolve({ + data: chain, + status: 200, + }); + } else if ( + url === `${chain.transactionService}/api/v1/safes/${event.address}` + ) { + return Promise.resolve({ + status: 200, + data: safe, + }); + } else if ( + url === + `${chain.transactionService}/api/v1/messages/${event.messageHash}` + ) { + return Promise.resolve({ + status: 200, + data: message, + }); + } else { + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/hooks/events`) + .set('Authorization', `Basic ${authToken}`) + .send(event) + .expect(202); + + expect(pushNotificationsApi.enqueueNotification).toHaveBeenCalledTimes( + subscribers.length, + ); + subscribers.forEach((subscriber, i) => { + expect( + pushNotificationsApi.enqueueNotification, + ).toHaveBeenNthCalledWith(i + 1, subscriber.cloudMessagingToken, { + data: { + ...event, + type: 'MESSAGE_CONFIRMATION_REQUEST', + }, + }); + }); + }); + + it('should not enqueue MESSAGE_CONFIRMATION_REQUEST event notifications if the Safe has a threshold of 1', async () => { + const event = messageCreatedEventBuilder().build(); + const chain = chainBuilder().with('chainId', event.chainId).build(); + const subscribers = Array.from( + { + length: faker.number.int({ min: 1, max: 5 }), + }, + () => ({ + subscriber: getAddress(faker.finance.ethereumAddress()), + deviceUuid: faker.string.uuid() as Uuid, + cloudMessagingToken: faker.string.alphanumeric(), + }), + ); + const safe = safeBuilder() + .with('address', event.address) + .with('threshold', 1) + .with( + 'owners', + subscribers.map((subscriber) => subscriber.subscriber), + ) + .build(); + notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + subscribers, + ); + + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { + return Promise.resolve({ + data: chain, + status: 200, + }); + } else if ( + url === `${chain.transactionService}/api/v1/safes/${event.address}` + ) { + return Promise.resolve({ + status: 200, + data: safe, + }); + } else { + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/hooks/events`) + .set('Authorization', `Basic ${authToken}`) + .send(event) + .expect(202); + + expect(pushNotificationsApi.enqueueNotification).not.toHaveBeenCalled(); + }); + + it('should not enqueue MESSAGE_CONFIRMATION_REQUEST event notifications if the Safe has a threshold > 1 but the owner has signed', async () => { + const event = messageCreatedEventBuilder().build(); + const chain = chainBuilder().with('chainId', event.chainId).build(); + const subscribers = Array.from( + { + length: faker.number.int({ min: 1, max: 5 }), + }, + () => ({ + subscriber: getAddress(faker.finance.ethereumAddress()), + deviceUuid: faker.string.uuid() as Uuid, + cloudMessagingToken: faker.string.alphanumeric(), + }), + ); + const safe = safeBuilder() + .with('address', event.address) + .with('threshold', faker.number.int({ min: 2 })) + .with( + 'owners', + subscribers.map((subscriber) => subscriber.subscriber), + ) + .build(); + notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + subscribers, + ); + const message = messageBuilder() + .with('messageHash', event.messageHash as `0x${string}`) + .with( + 'confirmations', + subscribers.map((subscriber) => { + return messageConfirmationBuilder() + .with('owner', subscriber.subscriber) + .build(); + }), + ) + .build(); + + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { + return Promise.resolve({ + data: chain, + status: 200, + }); + } else if ( + url === `${chain.transactionService}/api/v1/safes/${event.address}` + ) { + return Promise.resolve({ + status: 200, + data: safe, + }); + } else if ( + url === + `${chain.transactionService}/api/v1/messages/${event.messageHash}` + ) { + return Promise.resolve({ + status: 200, + data: message, + }); + } else { + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/hooks/events`) + .set('Authorization', `Basic ${authToken}`) + .send(event) + .expect(202); + + expect(pushNotificationsApi.enqueueNotification).not.toHaveBeenCalled(); + }); + + it("should only enqueue MESSAGE_CONFIRMATION_REQUEST event notifications for those that haven't signed", async () => { + const event = messageCreatedEventBuilder().build(); + const chain = chainBuilder().with('chainId', event.chainId).build(); + const owners = [ + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + ]; + const safe = safeBuilder() + .with('address', event.address) + .with('threshold', faker.number.int({ min: 2 })) + .with('owners', owners) + .build(); + const subscribers = owners.map((owner) => ({ + subscriber: owner, + deviceUuid: faker.string.uuid() as Uuid, + cloudMessagingToken: faker.string.alphanumeric(), + })); + notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + subscribers, + ); + const confirmations = faker.helpers + .arrayElements(owners, { min: 1, max: owners.length - 1 }) + .map((owner) => { + return messageConfirmationBuilder().with('owner', owner).build(); + }); + const message = messageBuilder() + .with('messageHash', event.messageHash as `0x${string}`) + .with('confirmations', confirmations) + .build(); + + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { + return Promise.resolve({ + data: chain, + status: 200, + }); + } else if ( + url === `${chain.transactionService}/api/v1/safes/${event.address}` + ) { + return Promise.resolve({ + status: 200, + data: safe, + }); + } else if ( + url === + `${chain.transactionService}/api/v1/messages/${event.messageHash}` + ) { + return Promise.resolve({ + status: 200, + data: message, + }); + } else { + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/hooks/events`) + .set('Authorization', `Basic ${authToken}`) + .send(event) + .expect(202); + + expect(pushNotificationsApi.enqueueNotification).toHaveBeenCalledTimes( + subscribers.length - confirmations.length, + ); + expect(pushNotificationsApi.enqueueNotification.mock.calls).toStrictEqual( + expect.arrayContaining( + subscribers + .filter((subscriber) => { + return confirmations.every((confirmation) => { + return confirmation.owner !== subscriber.subscriber; + }); + }) + .map((subscriber) => [ + subscriber.cloudMessagingToken, + { + data: { + ...event, + type: 'MESSAGE_CONFIRMATION_REQUEST', + }, + }, + ]), + ), + ); + }); + }); + + // Note: many of the following are edge cases that can likely never or are highly unlikely to happen in practice + // but we keep them here for completeness and to ensure the code behaves correctly in all scenarios + describe('delegates', () => { + it("should enqueue PENDING_MULTISIG_TRANSACTION event notifications if the Safe has a threshold > 1 and the delegate hasn't yet signed", async () => { + const event = pendingTransactionEventBuilder().build(); + const chain = chainBuilder().with('chainId', event.chainId).build(); + const safe = safeBuilder() + .with('address', event.address) + .with('threshold', faker.number.int({ min: 2 })) + .build(); + const multisigTransaction = multisigTransactionBuilder() + .with('safe', event.address) + .build(); + const subscribers = Array.from( + { + length: faker.number.int({ min: 1, max: 5 }), + }, + () => ({ + subscriber: getAddress(faker.finance.ethereumAddress()), + deviceUuid: faker.string.uuid() as Uuid, + cloudMessagingToken: faker.string.alphanumeric(), + }), + ); + const delegates = subscribers.map((subscriber) => { + return delegateBuilder() + .with('delegate', subscriber.subscriber) + .with('safe', event.address) + .build(); + }); + notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + subscribers, + ); + + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { + return Promise.resolve({ + data: chain, + status: 200, + }); + } else if ( + url === `${chain.transactionService}/api/v1/safes/${event.address}` + ) { + return Promise.resolve({ + status: 200, + data: safe, + }); + } else if (url === `${chain.transactionService}/api/v2/delegates/`) { + return Promise.resolve({ + status: 200, + data: pageBuilder().with('results', delegates).build(), + }); + } else if ( + url === + `${chain.transactionService}/api/v1/multisig-transactions/${event.safeTxHash}/` + ) { + return Promise.resolve({ + status: 200, + data: multisigTransaction, + }); + } else { + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/hooks/events`) + .set('Authorization', `Basic ${authToken}`) + .send(event) + .expect(202); + + expect(pushNotificationsApi.enqueueNotification).toHaveBeenCalledTimes( + subscribers.length, + ); + subscribers.forEach((subscriber, i) => { + expect( + pushNotificationsApi.enqueueNotification, + ).toHaveBeenNthCalledWith(i + 1, subscriber.cloudMessagingToken, { + data: { + ...event, + type: 'CONFIRMATION_REQUEST', + }, }); - } else if ( - url === - `${chain.transactionService}/api/v1/multisig-transactions/${event.safeTxHash}/` - ) { - return Promise.resolve({ - status: 200, - data: multisigTransaction, + }); + }); + + it('should not enqueue PENDING_MULTISIG_TRANSACTION event notifications if the Safe has a threshold of 1', async () => { + const event = pendingTransactionEventBuilder().build(); + const chain = chainBuilder().with('chainId', event.chainId).build(); + const safe = safeBuilder() + .with('address', event.address) + .with('threshold', 1) + .build(); + const subscribers = Array.from( + { + length: faker.number.int({ min: 1, max: 5 }), + }, + () => ({ + subscriber: getAddress(faker.finance.ethereumAddress()), + deviceUuid: faker.string.uuid() as Uuid, + cloudMessagingToken: faker.string.alphanumeric(), + }), + ); + const delegates = subscribers.map((subscriber) => { + return delegateBuilder() + .with('delegate', subscriber.subscriber) + .with('safe', event.address) + .build(); + }); + notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + subscribers, + ); + + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { + return Promise.resolve({ + data: chain, + status: 200, + }); + } else if ( + url === `${chain.transactionService}/api/v1/safes/${event.address}` + ) { + return Promise.resolve({ + status: 200, + data: safe, + }); + } else if (url === `${chain.transactionService}/api/v2/delegates/`) { + return Promise.resolve({ + status: 200, + data: pageBuilder().with('results', delegates).build(), + }); + } else { + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/hooks/events`) + .set('Authorization', `Basic ${authToken}`) + .send(event) + .expect(202); + + expect(pushNotificationsApi.enqueueNotification).not.toHaveBeenCalled(); + }); + + it('should not enqueue PENDING_MULTISIG_TRANSACTION event notifications if the Safe has a threshold > 1 but the delegate has signed', async () => { + const event = pendingTransactionEventBuilder().build(); + const chain = chainBuilder().with('chainId', event.chainId).build(); + const safe = safeBuilder() + .with('address', event.address) + .with('threshold', faker.number.int({ min: 2 })) + .build(); + const subscribers = Array.from( + { + length: faker.number.int({ min: 1, max: 5 }), + }, + () => ({ + subscriber: getAddress(faker.finance.ethereumAddress()), + deviceUuid: faker.string.uuid() as Uuid, + cloudMessagingToken: faker.string.alphanumeric(), + }), + ); + notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + subscribers, + ); + const delegates = subscribers.map((subscriber) => { + return delegateBuilder() + .with('delegate', subscriber.subscriber) + .with('safe', event.address) + .build(); + }); + const multisigTransaction = multisigTransactionBuilder() + .with( + 'confirmations', + subscribers.map((subscriber) => { + return confirmationBuilder() + .with('owner', subscriber.subscriber) + .build(); + }), + ) + .build(); + + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { + return Promise.resolve({ + data: chain, + status: 200, + }); + } else if ( + url === `${chain.transactionService}/api/v1/safes/${event.address}` + ) { + return Promise.resolve({ + status: 200, + data: safe, + }); + } else if (url === `${chain.transactionService}/api/v2/delegates/`) { + return Promise.resolve({ + status: 200, + data: pageBuilder().with('results', delegates).build(), + }); + } else if ( + url === + `${chain.transactionService}/api/v1/multisig-transactions/${event.safeTxHash}/` + ) { + return Promise.resolve({ + status: 200, + data: multisigTransaction, + }); + } else { + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/hooks/events`) + .set('Authorization', `Basic ${authToken}`) + .send(event) + .expect(202); + + expect(pushNotificationsApi.enqueueNotification).not.toHaveBeenCalled(); + }); + + it("should only enqueue PENDING_MULTISIG_TRANSACTION event notifications for those that haven't signed", async () => { + const event = pendingTransactionEventBuilder().build(); + const chain = chainBuilder().with('chainId', event.chainId).build(); + const owners = [ + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + ]; + const safe = safeBuilder() + .with('address', event.address) + .with('threshold', faker.number.int({ min: 2 })) + .with('owners', owners) + .build(); + const subscribers = owners.map((owner) => ({ + subscriber: owner, + deviceUuid: faker.string.uuid() as Uuid, + cloudMessagingToken: faker.string.alphanumeric(), + })); + notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + subscribers, + ); + const delegates = subscribers.map((subscriber) => { + return delegateBuilder() + .with('delegate', subscriber.subscriber) + .with('safe', event.address) + .build(); + }); + const confirmations = faker.helpers + .arrayElements(owners, { min: 1, max: owners.length - 1 }) + .map((owner) => { + return confirmationBuilder().with('owner', owner).build(); }); - } else { - return Promise.reject(`No matching rule for url: ${url}`); - } + const multisigTransaction = multisigTransactionBuilder() + .with('safe', event.address) + .with('confirmations', confirmations) + .build(); + + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { + return Promise.resolve({ + data: chain, + status: 200, + }); + } else if ( + url === `${chain.transactionService}/api/v1/safes/${event.address}` + ) { + return Promise.resolve({ + status: 200, + data: safe, + }); + } else if (url === `${chain.transactionService}/api/v2/delegates/`) { + return Promise.resolve({ + status: 200, + data: pageBuilder().with('results', delegates).build(), + }); + } else if ( + url === + `${chain.transactionService}/api/v1/multisig-transactions/${event.safeTxHash}/` + ) { + return Promise.resolve({ + status: 200, + data: multisigTransaction, + }); + } else { + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/hooks/events`) + .set('Authorization', `Basic ${authToken}`) + .send(event) + .expect(202); + + expect(pushNotificationsApi.enqueueNotification).toHaveBeenCalledTimes( + subscribers.length - confirmations.length, + ); + expect(pushNotificationsApi.enqueueNotification.mock.calls).toStrictEqual( + expect.arrayContaining( + subscribers + .filter((subscriber) => { + return confirmations.every((confirmation) => { + return confirmation.owner !== subscriber.subscriber; + }); + }) + .map((subscriber) => [ + subscriber.cloudMessagingToken, + { + data: { + ...event, + type: 'CONFIRMATION_REQUEST', + }, + }, + ]), + ), + ); + }); + + it("should enqueue MESSAGE_CONFIRMATION_REQUEST event notifications if the Safe has a threshold > 1 and the delegate hasn't yet signed", async () => { + const event = messageCreatedEventBuilder().build(); + const chain = chainBuilder().with('chainId', event.chainId).build(); + const safe = safeBuilder() + .with('address', event.address) + .with('threshold', faker.number.int({ min: 2 })) + .build(); + const message = messageBuilder() + .with('messageHash', event.messageHash as `0x${string}`) + .build(); + const subscribers = Array.from( + { + length: faker.number.int({ min: 1, max: 5 }), + }, + () => ({ + subscriber: getAddress(faker.finance.ethereumAddress()), + deviceUuid: faker.string.uuid() as Uuid, + cloudMessagingToken: faker.string.alphanumeric(), + }), + ); + const delegates = subscribers.map((subscriber) => { + return delegateBuilder() + .with('delegate', subscriber.subscriber) + .with('safe', event.address) + .build(); + }); + notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + subscribers, + ); + + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { + return Promise.resolve({ + data: chain, + status: 200, + }); + } else if ( + url === `${chain.transactionService}/api/v1/safes/${event.address}` + ) { + return Promise.resolve({ + status: 200, + data: safe, + }); + } else if (url === `${chain.transactionService}/api/v2/delegates/`) { + return Promise.resolve({ + status: 200, + data: pageBuilder().with('results', delegates).build(), + }); + } else if ( + url === + `${chain.transactionService}/api/v1/messages/${event.messageHash}` + ) { + return Promise.resolve({ + status: 200, + data: message, + }); + } else { + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/hooks/events`) + .set('Authorization', `Basic ${authToken}`) + .send(event) + .expect(202); + + expect(pushNotificationsApi.enqueueNotification).toHaveBeenCalledTimes( + subscribers.length, + ); + subscribers.forEach((subscriber, i) => { + expect( + pushNotificationsApi.enqueueNotification, + ).toHaveBeenNthCalledWith(i + 1, subscriber.cloudMessagingToken, { + data: { + ...event, + type: 'MESSAGE_CONFIRMATION_REQUEST', + }, + }); + }); }); - await request(app.getHttpServer()) - .post(`/hooks/events`) - .set('Authorization', `Basic ${authToken}`) - .send(event) - .expect(202); + it('should not enqueue MESSAGE_CONFIRMATION_REQUEST event notifications if the Safe has a threshold of 1', async () => { + const event = messageCreatedEventBuilder().build(); + const chain = chainBuilder().with('chainId', event.chainId).build(); + const safe = safeBuilder() + .with('address', event.address) + .with('threshold', 1) + .build(); + const subscribers = Array.from( + { + length: faker.number.int({ min: 1, max: 5 }), + }, + () => ({ + subscriber: getAddress(faker.finance.ethereumAddress()), + deviceUuid: faker.string.uuid() as Uuid, + cloudMessagingToken: faker.string.alphanumeric(), + }), + ); + notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + subscribers, + ); + const delegates = subscribers.map((subscriber) => { + return delegateBuilder() + .with('delegate', subscriber.subscriber) + .with('safe', event.address) + .build(); + }); + + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { + return Promise.resolve({ + data: chain, + status: 200, + }); + } else if ( + url === `${chain.transactionService}/api/v1/safes/${event.address}` + ) { + return Promise.resolve({ + status: 200, + data: safe, + }); + } else if (url === `${chain.transactionService}/api/v2/delegates/`) { + return Promise.resolve({ + status: 200, + data: pageBuilder().with('results', delegates).build(), + }); + } else { + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/hooks/events`) + .set('Authorization', `Basic ${authToken}`) + .send(event) + .expect(202); + + expect(pushNotificationsApi.enqueueNotification).not.toHaveBeenCalled(); + }); - expect(pushNotificationsApi.enqueueNotification).not.toHaveBeenCalled(); - }); + it('should not enqueue MESSAGE_CONFIRMATION_REQUEST event notifications if the Safe has a threshold > 1 but the delegate has signed', async () => { + const event = messageCreatedEventBuilder().build(); + const chain = chainBuilder().with('chainId', event.chainId).build(); + const safe = safeBuilder() + .with('address', event.address) + .with('threshold', faker.number.int({ min: 2 })) + .build(); + const subscribers = Array.from( + { + length: faker.number.int({ min: 1, max: 5 }), + }, + () => ({ + subscriber: getAddress(faker.finance.ethereumAddress()), + deviceUuid: faker.string.uuid() as Uuid, + cloudMessagingToken: faker.string.alphanumeric(), + }), + ); + notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + subscribers, + ); + const delegates = subscribers.map((subscriber) => { + return delegateBuilder() + .with('delegate', subscriber.subscriber) + .with('safe', event.address) + .build(); + }); + const message = messageBuilder() + .with('messageHash', event.messageHash as `0x${string}`) + .with( + 'confirmations', + subscribers.map((subscriber) => { + return messageConfirmationBuilder() + .with('owner', subscriber.subscriber) + .build(); + }), + ) + .build(); - it("should only enqueue PENDING_MULTISIG_TRANSACTION event notifications for those that haven't signed", async () => { - const event = pendingTransactionEventBuilder().build(); - const chain = chainBuilder().with('chainId', event.chainId).build(); - const owners = [ - getAddress(faker.finance.ethereumAddress()), - getAddress(faker.finance.ethereumAddress()), - getAddress(faker.finance.ethereumAddress()), - getAddress(faker.finance.ethereumAddress()), - getAddress(faker.finance.ethereumAddress()), - ]; - const safe = safeBuilder() - .with('address', event.address) - .with('threshold', faker.number.int({ min: 2 })) - .with('owners', owners) - .build(); - const subscribers = owners.map((owner) => ({ - subscriber: owner, - deviceUuid: faker.string.uuid() as Uuid, - cloudMessagingToken: faker.string.alphanumeric(), - })); - notificationsDatasource.getSubscribersBySafe.mockResolvedValue(subscribers); - const confirmations = faker.helpers - .arrayElements(owners, { min: 1, max: owners.length - 1 }) - .map((owner) => { - return confirmationBuilder().with('owner', owner).build(); + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { + return Promise.resolve({ + data: chain, + status: 200, + }); + } else if ( + url === `${chain.transactionService}/api/v1/safes/${event.address}` + ) { + return Promise.resolve({ + status: 200, + data: safe, + }); + } else if (url === `${chain.transactionService}/api/v2/delegates/`) { + return Promise.resolve({ + status: 200, + data: pageBuilder().with('results', delegates).build(), + }); + } else if ( + url === + `${chain.transactionService}/api/v1/messages/${event.messageHash}` + ) { + return Promise.resolve({ + status: 200, + data: message, + }); + } else { + return Promise.reject(`No matching rule for url: ${url}`); + } }); - const multisigTransaction = multisigTransactionBuilder() - .with('safe', event.address) - .with('confirmations', confirmations) - .build(); - networkService.get.mockImplementation(({ url }) => { - if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { - return Promise.resolve({ - data: chain, - status: 200, - }); - } else if ( - url === `${chain.transactionService}/api/v1/safes/${event.address}` - ) { - return Promise.resolve({ - status: 200, - data: safe, - }); - } else if ( - url === - `${chain.transactionService}/api/v1/multisig-transactions/${event.safeTxHash}/` - ) { - return Promise.resolve({ - status: 200, - data: multisigTransaction, - }); - } else { - return Promise.reject(`No matching rule for url: ${url}`); - } + await request(app.getHttpServer()) + .post(`/hooks/events`) + .set('Authorization', `Basic ${authToken}`) + .send(event) + .expect(202); + + expect(pushNotificationsApi.enqueueNotification).not.toHaveBeenCalled(); }); - await request(app.getHttpServer()) - .post(`/hooks/events`) - .set('Authorization', `Basic ${authToken}`) - .send(event) - .expect(202); + it("should only enqueue MESSAGE_CONFIRMATION_REQUEST event notifications for those that haven't signed", async () => { + const event = messageCreatedEventBuilder().build(); + const chain = chainBuilder().with('chainId', event.chainId).build(); + const owners = [ + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + getAddress(faker.finance.ethereumAddress()), + ]; + const safe = safeBuilder() + .with('address', event.address) + .with('threshold', faker.number.int({ min: 2 })) + .with('owners', owners) + .build(); + const subscribers = owners.map((owner) => ({ + subscriber: owner, + deviceUuid: faker.string.uuid() as Uuid, + cloudMessagingToken: faker.string.alphanumeric(), + })); + notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + subscribers, + ); + const delegates = subscribers.map((subscriber) => { + return delegateBuilder() + .with('delegate', subscriber.subscriber) + .with('safe', event.address) + .build(); + }); + const confirmations = faker.helpers + .arrayElements(owners, { min: 1, max: owners.length - 1 }) + .map((owner) => { + return messageConfirmationBuilder().with('owner', owner).build(); + }); + const message = messageBuilder() + .with('messageHash', event.messageHash as `0x${string}`) + .with('confirmations', confirmations) + .build(); - expect(pushNotificationsApi.enqueueNotification).toHaveBeenCalledTimes( - subscribers.length - confirmations.length, - ); - expect(pushNotificationsApi.enqueueNotification.mock.calls).toStrictEqual( - expect.arrayContaining( - subscribers - .filter((subscriber) => { - return confirmations.every((confirmation) => { - return confirmation.owner !== subscriber.subscriber; - }); - }) - .map((subscriber) => [ - subscriber.cloudMessagingToken, - { - data: { - ...event, - type: 'CONFIRMATION_REQUEST', + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { + return Promise.resolve({ + data: chain, + status: 200, + }); + } else if ( + url === `${chain.transactionService}/api/v1/safes/${event.address}` + ) { + return Promise.resolve({ + status: 200, + data: safe, + }); + } else if (url === `${chain.transactionService}/api/v2/delegates/`) { + return Promise.resolve({ + status: 200, + data: pageBuilder().with('results', delegates).build(), + }); + } else if ( + url === + `${chain.transactionService}/api/v1/messages/${event.messageHash}` + ) { + return Promise.resolve({ + status: 200, + data: message, + }); + } else { + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/hooks/events`) + .set('Authorization', `Basic ${authToken}`) + .send(event) + .expect(202); + + expect(pushNotificationsApi.enqueueNotification).toHaveBeenCalledTimes( + subscribers.length - confirmations.length, + ); + expect(pushNotificationsApi.enqueueNotification.mock.calls).toStrictEqual( + expect.arrayContaining( + subscribers + .filter((subscriber) => { + return confirmations.every((confirmation) => { + return confirmation.owner !== subscriber.subscriber; + }); + }) + .map((subscriber) => [ + subscriber.cloudMessagingToken, + { + data: { + ...event, + type: 'MESSAGE_CONFIRMATION_REQUEST', + }, }, - }, - ]), - ), - ); + ]), + ), + ); + }); }); - it("should enqueue MESSAGE_CONFIRMATION_REQUEST event notifications if the Safe has a threshold > 1 and the subscriber hasn't yet signed", async () => { - const event = messageCreatedEventBuilder().build(); - const chain = chainBuilder().with('chainId', event.chainId).build(); - const safe = safeBuilder() - .with('address', event.address) - .with('threshold', faker.number.int({ min: 2 })) - .build(); - const message = messageBuilder() - .with('messageHash', event.messageHash as `0x${string}`) - .build(); - const subscribers = Array.from( - { - length: faker.number.int({ min: 1, max: 5 }), - }, - () => ({ - subscriber: getAddress(faker.finance.ethereumAddress()), - deviceUuid: faker.string.uuid() as Uuid, - cloudMessagingToken: faker.string.alphanumeric(), - }), - ); - notificationsDatasource.getSubscribersBySafe.mockResolvedValue(subscribers); + describe('non-owners/delegates', () => { + it("should not enqueue PENDING_MULTISIG_TRANSACTION event notifications if the Safe has a threshold > 1 and the subscriber hasn't yet signed", async () => { + const event = pendingTransactionEventBuilder().build(); + const chain = chainBuilder().with('chainId', event.chainId).build(); + const safe = safeBuilder() + .with('address', event.address) + .with('threshold', faker.number.int({ min: 2 })) + .build(); + const multisigTransaction = multisigTransactionBuilder() + .with('safe', event.address) + .build(); + const subscribers = Array.from( + { + length: faker.number.int({ min: 1, max: 5 }), + }, + () => ({ + subscriber: getAddress(faker.finance.ethereumAddress()), + deviceUuid: faker.string.uuid() as Uuid, + cloudMessagingToken: faker.string.alphanumeric(), + }), + ); + notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + subscribers, + ); - networkService.get.mockImplementation(({ url }) => { - if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { - return Promise.resolve({ - data: chain, - status: 200, - }); - } else if ( - url === `${chain.transactionService}/api/v1/safes/${event.address}` - ) { - return Promise.resolve({ - status: 200, - data: safe, - }); - } else if ( - url === - `${chain.transactionService}/api/v1/messages/${event.messageHash}` - ) { - return Promise.resolve({ - status: 200, - data: message, - }); - } else { - return Promise.reject(`No matching rule for url: ${url}`); - } + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { + return Promise.resolve({ + data: chain, + status: 200, + }); + } else if ( + url === `${chain.transactionService}/api/v1/safes/${event.address}` + ) { + return Promise.resolve({ + status: 200, + data: safe, + }); + } else if (url === `${chain.transactionService}/api/v2/delegates/`) { + return Promise.resolve({ + status: 200, + data: pageBuilder().with('results', []).build(), + }); + } else if ( + url === + `${chain.transactionService}/api/v1/multisig-transactions/${event.safeTxHash}/` + ) { + return Promise.resolve({ + status: 200, + data: multisigTransaction, + }); + } else { + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/hooks/events`) + .set('Authorization', `Basic ${authToken}`) + .send(event) + .expect(202); + + expect(pushNotificationsApi.enqueueNotification).not.toHaveBeenCalled(); }); - await request(app.getHttpServer()) - .post(`/hooks/events`) - .set('Authorization', `Basic ${authToken}`) - .send(event) - .expect(202); + it("should not enqueue MESSAGE_CONFIRMATION_REQUEST event notifications if the Safe has a threshold > 1 and the subscriber hasn't yet signed", async () => { + const event = messageCreatedEventBuilder().build(); + const chain = chainBuilder().with('chainId', event.chainId).build(); + const safe = safeBuilder() + .with('address', event.address) + .with('threshold', faker.number.int({ min: 2 })) + .build(); + const message = messageBuilder() + .with('messageHash', event.messageHash as `0x${string}`) + .build(); + const subscribers = Array.from( + { + length: faker.number.int({ min: 1, max: 5 }), + }, + () => ({ + subscriber: getAddress(faker.finance.ethereumAddress()), + deviceUuid: faker.string.uuid() as Uuid, + cloudMessagingToken: faker.string.alphanumeric(), + }), + ); + notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + subscribers, + ); + + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { + return Promise.resolve({ + data: chain, + status: 200, + }); + } else if ( + url === `${chain.transactionService}/api/v1/safes/${event.address}` + ) { + return Promise.resolve({ + status: 200, + data: safe, + }); + } else if (url === `${chain.transactionService}/api/v2/delegates/`) { + return Promise.resolve({ + status: 200, + data: pageBuilder().with('results', []).build(), + }); + } else if ( + url === + `${chain.transactionService}/api/v1/messages/${event.messageHash}` + ) { + return Promise.resolve({ + status: 200, + data: message, + }); + } else { + return Promise.reject(`No matching rule for url: ${url}`); + } + }); + + await request(app.getHttpServer()) + .post(`/hooks/events`) + .set('Authorization', `Basic ${authToken}`) + .send(event) + .expect(202); - expect(pushNotificationsApi.enqueueNotification).toHaveBeenCalledTimes( - subscribers.length, - ); - subscribers.forEach((subscriber, i) => { - expect(pushNotificationsApi.enqueueNotification).toHaveBeenNthCalledWith( - i + 1, - subscriber.cloudMessagingToken, - { - data: { - ...event, - type: 'MESSAGE_CONFIRMATION_REQUEST', - }, - }, - ); + expect(pushNotificationsApi.enqueueNotification).not.toHaveBeenCalled(); }); }); - it('should not enqueue MESSAGE_CONFIRMATION_REQUEST event notifications if the Safe has a threshold of 1', async () => { - const event = messageCreatedEventBuilder().build(); + it('should enqueue CONFIRMATION_REQUEST event notifications accordingly for a mixture of subscribers: owners, delegates and non-owner/delegates', async () => { + const event = pendingTransactionEventBuilder().build(); const chain = chainBuilder().with('chainId', event.chainId).build(); - const safe = safeBuilder() - .with('address', event.address) - .with('threshold', 1) - .build(); - const subscribers = Array.from( + const ownerSubscriptions = [ { - length: faker.number.int({ min: 1, max: 5 }), + subscriber: getAddress(faker.finance.ethereumAddress()), + deviceUuid: faker.string.uuid() as Uuid, + cloudMessagingToken: faker.string.alphanumeric(), }, - () => ({ + { subscriber: getAddress(faker.finance.ethereumAddress()), deviceUuid: faker.string.uuid() as Uuid, cloudMessagingToken: faker.string.alphanumeric(), - }), - ); - notificationsDatasource.getSubscribersBySafe.mockResolvedValue(subscribers); - - networkService.get.mockImplementation(({ url }) => { - if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { - return Promise.resolve({ - data: chain, - status: 200, - }); - } else if ( - url === `${chain.transactionService}/api/v1/safes/${event.address}` - ) { - return Promise.resolve({ - status: 200, - data: safe, - }); - } else { - return Promise.reject(`No matching rule for url: ${url}`); - } - }); - - await request(app.getHttpServer()) - .post(`/hooks/events`) - .set('Authorization', `Basic ${authToken}`) - .send(event) - .expect(202); - - expect(pushNotificationsApi.enqueueNotification).not.toHaveBeenCalled(); - }); - - it('should not enqueue MESSAGE_CONFIRMATION_REQUEST event notifications if the Safe has a threshold > 1 but the subscriber has signed', async () => { - const event = messageCreatedEventBuilder().build(); - const chain = chainBuilder().with('chainId', event.chainId).build(); - const safe = safeBuilder() - .with('address', event.address) - .with('threshold', faker.number.int({ min: 2 })) - .build(); - const subscribers = Array.from( + }, + ]; + const delegateSubscriptions = [ { - length: faker.number.int({ min: 1, max: 5 }), + subscriber: getAddress(faker.finance.ethereumAddress()), + deviceUuid: faker.string.uuid() as Uuid, + cloudMessagingToken: faker.string.alphanumeric(), }, - () => ({ + { subscriber: getAddress(faker.finance.ethereumAddress()), deviceUuid: faker.string.uuid() as Uuid, cloudMessagingToken: faker.string.alphanumeric(), - }), - ); - notificationsDatasource.getSubscribersBySafe.mockResolvedValue(subscribers); - const message = messageBuilder() - .with('messageHash', event.messageHash as `0x${string}`) + }, + ]; + const nonOwnerDelegateSubscriptions = [ + { + subscriber: getAddress(faker.finance.ethereumAddress()), + deviceUuid: faker.string.uuid() as Uuid, + cloudMessagingToken: faker.string.alphanumeric(), + }, + { + subscriber: getAddress(faker.finance.ethereumAddress()), + deviceUuid: faker.string.uuid() as Uuid, + cloudMessagingToken: faker.string.alphanumeric(), + }, + ]; + const safe = safeBuilder() + .with('address', event.address) + .with('threshold', 2) .with( - 'confirmations', - subscribers.map((subscriber) => { - return messageConfirmationBuilder() - .with('owner', subscriber.subscriber) - .build(); - }), + 'owners', + ownerSubscriptions.map((subscription) => subscription.subscriber), ) .build(); + const multisigTransaction = multisigTransactionBuilder() + .with('safe', event.address) + .with('confirmations', [ + confirmationBuilder() + .with('owner', ownerSubscriptions[0].subscriber) + .build(), + ]) + .build(); + notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + ownerSubscriptions.concat( + delegateSubscriptions, + nonOwnerDelegateSubscriptions, + ), + ); networkService.get.mockImplementation(({ url }) => { if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { @@ -741,13 +1735,28 @@ describe('Post Hook Events for Notifications (Unit)', () => { status: 200, data: safe, }); + } else if (url === `${chain.transactionService}/api/v2/delegates/`) { + return Promise.resolve({ + status: 200, + data: pageBuilder() + .with( + 'results', + delegateSubscriptions.map((subscription) => { + return delegateBuilder() + .with('delegate', subscription.subscriber) + .with('safe', safe.address) + .build(); + }), + ) + .build(), + }); } else if ( url === - `${chain.transactionService}/api/v1/messages/${event.messageHash}` + `${chain.transactionService}/api/v1/multisig-transactions/${event.safeTxHash}/` ) { return Promise.resolve({ status: 200, - data: message, + data: multisigTransaction, }); } else { return Promise.reject(`No matching rule for url: ${url}`); @@ -760,39 +1769,100 @@ describe('Post Hook Events for Notifications (Unit)', () => { .send(event) .expect(202); - expect(pushNotificationsApi.enqueueNotification).not.toHaveBeenCalled(); + expect(pushNotificationsApi.enqueueNotification).toHaveBeenCalledTimes(3); + expect(pushNotificationsApi.enqueueNotification).toHaveBeenNthCalledWith( + 1, + ownerSubscriptions[1].cloudMessagingToken, + { + data: { + ...event, + type: 'CONFIRMATION_REQUEST', + }, + }, + ); + expect(pushNotificationsApi.enqueueNotification).toHaveBeenNthCalledWith( + 2, + delegateSubscriptions[0].cloudMessagingToken, + { + data: { + ...event, + type: 'CONFIRMATION_REQUEST', + }, + }, + ); + expect(pushNotificationsApi.enqueueNotification).toHaveBeenNthCalledWith( + 3, + delegateSubscriptions[1].cloudMessagingToken, + { + data: { + ...event, + type: 'CONFIRMATION_REQUEST', + }, + }, + ); }); - it("should only enqueue MESSAGE_CONFIRMATION_REQUEST event notifications for those that haven't signed", async () => { + it('should enqueue MESSAGE_CONFIRMATION_REQUEST event notifications accordingly for a mixture of subscribers: owners, delegates and non-owner/delegates', async () => { const event = messageCreatedEventBuilder().build(); const chain = chainBuilder().with('chainId', event.chainId).build(); - const owners = [ - getAddress(faker.finance.ethereumAddress()), - getAddress(faker.finance.ethereumAddress()), - getAddress(faker.finance.ethereumAddress()), - getAddress(faker.finance.ethereumAddress()), - getAddress(faker.finance.ethereumAddress()), + const ownerSubscriptions = [ + { + subscriber: getAddress(faker.finance.ethereumAddress()), + deviceUuid: faker.string.uuid() as Uuid, + cloudMessagingToken: faker.string.alphanumeric(), + }, + { + subscriber: getAddress(faker.finance.ethereumAddress()), + deviceUuid: faker.string.uuid() as Uuid, + cloudMessagingToken: faker.string.alphanumeric(), + }, + ]; + const delegateSubscriptions = [ + { + subscriber: getAddress(faker.finance.ethereumAddress()), + deviceUuid: faker.string.uuid() as Uuid, + cloudMessagingToken: faker.string.alphanumeric(), + }, + { + subscriber: getAddress(faker.finance.ethereumAddress()), + deviceUuid: faker.string.uuid() as Uuid, + cloudMessagingToken: faker.string.alphanumeric(), + }, + ]; + const nonOwnerDelegateSubscriptions = [ + { + subscriber: getAddress(faker.finance.ethereumAddress()), + deviceUuid: faker.string.uuid() as Uuid, + cloudMessagingToken: faker.string.alphanumeric(), + }, + { + subscriber: getAddress(faker.finance.ethereumAddress()), + deviceUuid: faker.string.uuid() as Uuid, + cloudMessagingToken: faker.string.alphanumeric(), + }, ]; const safe = safeBuilder() .with('address', event.address) - .with('threshold', faker.number.int({ min: 2 })) - .with('owners', owners) + .with('threshold', 2) + .with( + 'owners', + ownerSubscriptions.map((subscription) => subscription.subscriber), + ) .build(); - const subscribers = owners.map((owner) => ({ - subscriber: owner, - deviceUuid: faker.string.uuid() as Uuid, - cloudMessagingToken: faker.string.alphanumeric(), - })); - notificationsDatasource.getSubscribersBySafe.mockResolvedValue(subscribers); - const confirmations = faker.helpers - .arrayElements(owners, { min: 1, max: owners.length - 1 }) - .map((owner) => { - return messageConfirmationBuilder().with('owner', owner).build(); - }); const message = messageBuilder() .with('messageHash', event.messageHash as `0x${string}`) - .with('confirmations', confirmations) + .with('confirmations', [ + messageConfirmationBuilder() + .with('owner', ownerSubscriptions[0].subscriber) + .build(), + ]) .build(); + notificationsDatasource.getSubscribersBySafe.mockResolvedValue( + ownerSubscriptions.concat( + delegateSubscriptions, + nonOwnerDelegateSubscriptions, + ), + ); networkService.get.mockImplementation(({ url }) => { if (url === `${safeConfigUrl}/api/v1/chains/${event.chainId}`) { @@ -807,6 +1877,21 @@ describe('Post Hook Events for Notifications (Unit)', () => { status: 200, data: safe, }); + } else if (url === `${chain.transactionService}/api/v2/delegates/`) { + return Promise.resolve({ + status: 200, + data: pageBuilder() + .with( + 'results', + delegateSubscriptions.map((subscription) => { + return delegateBuilder() + .with('delegate', subscription.subscriber) + .with('safe', safe.address) + .build(); + }), + ) + .build(), + }); } else if ( url === `${chain.transactionService}/api/v1/messages/${event.messageHash}` @@ -826,27 +1911,36 @@ describe('Post Hook Events for Notifications (Unit)', () => { .send(event) .expect(202); - expect(pushNotificationsApi.enqueueNotification).toHaveBeenCalledTimes( - subscribers.length - confirmations.length, + expect(pushNotificationsApi.enqueueNotification).toHaveBeenCalledTimes(3); + expect(pushNotificationsApi.enqueueNotification).toHaveBeenNthCalledWith( + 1, + ownerSubscriptions[1].cloudMessagingToken, + { + data: { + ...event, + type: 'MESSAGE_CONFIRMATION_REQUEST', + }, + }, ); - expect(pushNotificationsApi.enqueueNotification.mock.calls).toStrictEqual( - expect.arrayContaining( - subscribers - .filter((subscriber) => { - return confirmations.every((confirmation) => { - return confirmation.owner !== subscriber.subscriber; - }); - }) - .map((subscriber) => [ - subscriber.cloudMessagingToken, - { - data: { - ...event, - type: 'MESSAGE_CONFIRMATION_REQUEST', - }, - }, - ]), - ), + expect(pushNotificationsApi.enqueueNotification).toHaveBeenNthCalledWith( + 2, + delegateSubscriptions[0].cloudMessagingToken, + { + data: { + ...event, + type: 'MESSAGE_CONFIRMATION_REQUEST', + }, + }, + ); + expect(pushNotificationsApi.enqueueNotification).toHaveBeenNthCalledWith( + 3, + delegateSubscriptions[1].cloudMessagingToken, + { + data: { + ...event, + type: 'MESSAGE_CONFIRMATION_REQUEST', + }, + }, ); }); @@ -894,16 +1988,95 @@ describe('Post Hook Events for Notifications (Unit)', () => { }); it('should not fail to send all notifications if one throws', async () => { + const chain = chainBuilder().build(); + const safe = safeBuilder().with('threshold', 2).build(); + const deletedTransactionEvent = deletedMultisigTransactionEventBuilder() + .with('address', safe.address) + .with('chainId', chain.chainId) + .build(); + const executedTransactionEvent = executedTransactionEventBuilder() + .with('address', safe.address) + .with('chainId', chain.chainId) + .build(); + const incomingEtherEvent = incomingEtherEventBuilder() + .with('address', safe.address) + .with('chainId', chain.chainId) + .build(); + const incomingTokenEvent = incomingTokenEventBuilder() + .with('address', safe.address) + .with('chainId', chain.chainId) + .build(); + const moduleTransactionEvent = moduleTransactionEventBuilder() + .with('address', safe.address) + .with('chainId', chain.chainId) + .build(); + const message = messageBuilder().with('safe', safe.address).build(); + const messageCreatedEvent = messageCreatedEventBuilder() + .with('address', safe.address) + .with('chainId', chain.chainId) + .with('messageHash', message.messageHash) + .build(); + const multisigTransaction = multisigTransactionBuilder() + .with('safe', safe.address) + .build(); + const pendingTransactionEvent = pendingTransactionEventBuilder() + .with('address', safe.address) + .with('chainId', chain.chainId) + .with('safeTxHash', multisigTransaction.safeTxHash) + .build(); const events = [ - chainUpdateEventBuilder().build(), - safeAppsEventBuilder().build(), - outgoingEtherEventBuilder().build(), - outgoingTokenEventBuilder().build(), - newConfirmationEventBuilder().build(), - newMessageConfirmationEventBuilder().build(), - safeCreatedEventBuilder().build(), + deletedTransactionEvent, + executedTransactionEvent, + incomingEtherEvent, + incomingTokenEvent, + moduleTransactionEvent, + messageCreatedEvent, + pendingTransactionEvent, ]; - + const subscribers = Array.from( + { + length: safe.owners.length, + }, + (_, i) => ({ + subscriber: safe.owners[i], + deviceUuid: faker.string.uuid() as Uuid, + cloudMessagingToken: faker.string.alphanumeric(), + }), + ); + notificationsDatasource.getSubscribersBySafe.mockResolvedValue(subscribers); + networkService.get.mockImplementation(({ url }) => { + if (url === `${safeConfigUrl}/api/v1/chains/${chain.chainId}`) { + return Promise.resolve({ + data: chain, + status: 200, + }); + } else if ( + url === `${chain.transactionService}/api/v1/safes/${safe.address}` + ) { + return Promise.resolve({ + status: 200, + data: safe, + }); + } else if ( + url === + `${chain.transactionService}/api/v1/multisig-transactions/${multisigTransaction.safeTxHash}/` + ) { + return Promise.resolve({ + status: 200, + data: multisigTransaction, + }); + } else if ( + url === + `${chain.transactionService}/api/v1/messages/${message.messageHash}` + ) { + return Promise.resolve({ + status: 200, + data: message, + }); + } else { + return Promise.reject(`No matching rule for url: ${url}`); + } + }); pushNotificationsApi.enqueueNotification .mockRejectedValueOnce(new Error('Error enqueueing notification')) .mockResolvedValueOnce() @@ -918,5 +2091,9 @@ describe('Post Hook Events for Notifications (Unit)', () => { // Doesn't throw .expect(202); } + + expect(pushNotificationsApi.enqueueNotification).toHaveBeenCalledTimes( + events.length, + ); }); }); From d49aca2e6a097d8ac438fa5876d39f1b2006bf3f Mon Sep 17 00:00:00 2001 From: iamacook Date: Fri, 2 Aug 2024 12:42:35 +0200 Subject: [PATCH 34/37] Improve tests and remove unnecessary injection --- .../notifications.datasource.spec.ts | 55 ++++++++----------- .../notifications/notifications.datasource.ts | 3 - 2 files changed, 24 insertions(+), 34 deletions(-) diff --git a/src/datasources/notifications/notifications.datasource.spec.ts b/src/datasources/notifications/notifications.datasource.spec.ts index 46ea0f88c1..b2241a0b0f 100644 --- a/src/datasources/notifications/notifications.datasource.spec.ts +++ b/src/datasources/notifications/notifications.datasource.spec.ts @@ -1,6 +1,5 @@ import { TestDbFactory } from '@/__tests__/db.factory'; import { IConfigurationService } from '@/config/configuration.service.interface'; -import { AccountsDatasource } from '@/datasources/accounts/accounts.datasource'; import { upsertSubscriptionsDtoBuilder } from '@/datasources/notifications/__tests__/upsert-subscriptions.dto.entity.builder'; import { NotificationsDatasource } from '@/datasources/notifications/notifications.datasource'; import { FakeCacheService } from '@/datasources/cache/__tests__/fake.cache.service'; @@ -11,7 +10,6 @@ import { ILoggingService } from '@/logging/logging.interface'; import { faker } from '@faker-js/faker'; import postgres from 'postgres'; import { getAddress } from 'viem'; -import { CachedQueryResolver } from '@/datasources/db/cached-query-resolver'; const mockLoggingService = { debug: jest.fn(), @@ -38,18 +36,7 @@ describe('NotificationsDatasource', () => { mockConfigurationService.getOrThrow.mockImplementation((key) => { if (key === 'expirationTimeInSeconds.default') return faker.number.int(); }); - const accountsDatasource = new AccountsDatasource( - fakeCacheService, - sql, - new CachedQueryResolver(mockLoggingService, fakeCacheService), - mockLoggingService, - mockConfigurationService, - ); - target = new NotificationsDatasource( - sql, - mockLoggingService, - accountsDatasource, - ); + target = new NotificationsDatasource(sql, mockLoggingService); }); afterEach(async () => { @@ -135,7 +122,7 @@ describe('NotificationsDatasource', () => { const secondSubscriptionsDto = upsertSubscriptionsDtoBuilder() .with('deviceUuid', upsertSubscriptionsDto.deviceUuid) .build(); - await target.upsertSubscriptions({ + const { deviceUuid } = await target.upsertSubscriptions({ signerAddress, upsertSubscriptionsDto, }); @@ -147,14 +134,14 @@ describe('NotificationsDatasource', () => { upsertSubscriptionsDto: secondSubscriptionsDto, }), ).resolves.not.toThrow(); - // Device UUID should have updated await expect( sql`SELECT * FROM push_notification_devices`, ).resolves.toStrictEqual([ { id: 1, device_type: secondSubscriptionsDto.deviceType, - device_uuid: expect.any(String), + // Device UUID shouldn't have updated + device_uuid: deviceUuid, cloud_messaging_token: secondSubscriptionsDto.cloudMessagingToken, created_at: expect.any(Date), updated_at: expect.any(Date), @@ -171,12 +158,14 @@ describe('NotificationsDatasource', () => { address: getAddress(faker.finance.ethereumAddress()), notificationTypes: faker.helpers.arrayElements( Object.values(NotificationType), + { min: 3, max: 3 }, ), }, ]) .build(); const newNotificationTypes = faker.helpers.arrayElements( Object.values(NotificationType), + { min: 1, max: 2 }, ); await target.upsertSubscriptions({ signerAddress, @@ -199,20 +188,17 @@ describe('NotificationsDatasource', () => { sql`SELECT * FROM notification_types`, sql`SELECT * FROM notification_subscription_notification_types`, ]).then(([notificationTypes, subscribedNotifications]) => { + expect(subscribedNotifications.length).toBe(1); // Only new notification types should be subscribed to - expect(subscribedNotifications).toStrictEqual( - expect.arrayContaining( - newNotificationTypes.map((type) => { - return { - id: expect.any(Number), - notification_subscription_id: 1, - notification_type_id: notificationTypes.find( - (t) => t.name === type, - )?.id, - }; - }), - ), - ); + expect(subscribedNotifications).toStrictEqual([ + { + id: expect.any(Number), + notification_subscription_id: 1, + notification_type_id: notificationTypes.find( + (t) => t.name === newNotificationTypes[0], + )?.id, + }, + ]); }); }); @@ -334,7 +320,7 @@ describe('NotificationsDatasource', () => { }); await target.upsertSubscriptions({ signerAddress: secondSignerAddress, - upsertSubscriptionsDto: upsertSubscriptionsDto, + upsertSubscriptionsDto, }); // Ensure correct database structure @@ -450,6 +436,9 @@ describe('NotificationsDatasource', () => { const secondUpsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() .with('safes', upsertSubscriptionsDto.safes) .build(); + const thirdSignerAddress = getAddress(faker.finance.ethereumAddress()); + const thirdUpsertSubscriptionsDto = + upsertSubscriptionsDtoBuilder().build(); await target.upsertSubscriptions({ signerAddress, upsertSubscriptionsDto, @@ -458,6 +447,10 @@ describe('NotificationsDatasource', () => { signerAddress: secondSignerAddress, upsertSubscriptionsDto: secondUpsertSubscriptionsDto, }); + await target.upsertSubscriptions({ + signerAddress: thirdSignerAddress, + upsertSubscriptionsDto: thirdUpsertSubscriptionsDto, + }); const safe = upsertSubscriptionsDto.safes[0]; await expect( diff --git a/src/datasources/notifications/notifications.datasource.ts b/src/datasources/notifications/notifications.datasource.ts index 3d81920d90..2ef9e839c6 100644 --- a/src/datasources/notifications/notifications.datasource.ts +++ b/src/datasources/notifications/notifications.datasource.ts @@ -1,6 +1,5 @@ import { NotificationType as DomainNotificationType } from '@/domain/notifications/entities-v2/notification-type.entity'; import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; -import { IAccountsDatasource } from '@/domain/interfaces/accounts.datasource.interface'; import { INotificationsDatasource } from '@/domain/interfaces/notifications.datasource.interface'; import { LoggingService, ILoggingService } from '@/logging/logging.interface'; import { asError } from '@/logging/utils'; @@ -20,8 +19,6 @@ export class NotificationsDatasource implements INotificationsDatasource { private readonly sql: postgres.Sql, @Inject(LoggingService) private readonly loggingService: ILoggingService, - @Inject(IAccountsDatasource) - private readonly accountsDatasource: IAccountsDatasource, ) {} /** From 2ef00581f88820dacd560260ed6f81fa8a032e75 Mon Sep 17 00:00:00 2001 From: iamacook Date: Fri, 2 Aug 2024 12:55:49 +0200 Subject: [PATCH 35/37] Fix lint --- src/datasources/notifications/notifications.datasource.spec.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/datasources/notifications/notifications.datasource.spec.ts b/src/datasources/notifications/notifications.datasource.spec.ts index b2241a0b0f..356e3d24c3 100644 --- a/src/datasources/notifications/notifications.datasource.spec.ts +++ b/src/datasources/notifications/notifications.datasource.spec.ts @@ -2,7 +2,6 @@ import { TestDbFactory } from '@/__tests__/db.factory'; import { IConfigurationService } from '@/config/configuration.service.interface'; import { upsertSubscriptionsDtoBuilder } from '@/datasources/notifications/__tests__/upsert-subscriptions.dto.entity.builder'; import { NotificationsDatasource } from '@/datasources/notifications/notifications.datasource'; -import { FakeCacheService } from '@/datasources/cache/__tests__/fake.cache.service'; import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; import { NotificationType } from '@/domain/notifications/entities-v2/notification-type.entity'; import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; @@ -22,14 +21,12 @@ const mockConfigurationService = jest.mocked({ } as jest.MockedObjectDeep); describe('NotificationsDatasource', () => { - let fakeCacheService: FakeCacheService; let migrator: PostgresDatabaseMigrator; let sql: postgres.Sql; const testDbFactory = new TestDbFactory(); let target: NotificationsDatasource; beforeAll(async () => { - fakeCacheService = new FakeCacheService(); sql = await testDbFactory.createTestDatabase(faker.string.uuid()); migrator = new PostgresDatabaseMigrator(sql); await migrator.migrate(); From 2026c12100b0a440e295471cda6e86952dbd310c Mon Sep 17 00:00:00 2001 From: iamacook Date: Fri, 2 Aug 2024 12:59:54 +0200 Subject: [PATCH 36/37] Merge branch 'notifications-database' into notifications-domain --- .../notifications.datasource.spec.ts | 60 ++++++++----------- .../notifications/notifications.datasource.ts | 3 - 2 files changed, 25 insertions(+), 38 deletions(-) diff --git a/src/datasources/notifications/notifications.datasource.spec.ts b/src/datasources/notifications/notifications.datasource.spec.ts index 3f0202a301..b7df0b90e3 100644 --- a/src/datasources/notifications/notifications.datasource.spec.ts +++ b/src/datasources/notifications/notifications.datasource.spec.ts @@ -1,9 +1,7 @@ import { TestDbFactory } from '@/__tests__/db.factory'; import { IConfigurationService } from '@/config/configuration.service.interface'; -import { AccountsDatasource } from '@/datasources/accounts/accounts.datasource'; -import { upsertSubscriptionsDtoBuilder } from '@/domain/notifications/entities-v2/__tests__/upsert-subscriptions.dto.entity.builder'; +import { upsertSubscriptionsDtoBuilder } from '@/datasources/notifications/__tests__/upsert-subscriptions.dto.entity.builder'; import { NotificationsDatasource } from '@/datasources/notifications/notifications.datasource'; -import { FakeCacheService } from '@/datasources/cache/__tests__/fake.cache.service'; import { PostgresDatabaseMigrator } from '@/datasources/db/postgres-database.migrator'; import { NotificationType } from '@/domain/notifications/entities-v2/notification-type.entity'; import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; @@ -11,7 +9,6 @@ import { ILoggingService } from '@/logging/logging.interface'; import { faker } from '@faker-js/faker'; import postgres from 'postgres'; import { getAddress } from 'viem'; -import { CachedQueryResolver } from '@/datasources/db/cached-query-resolver'; const mockLoggingService = { debug: jest.fn(), @@ -24,32 +21,19 @@ const mockConfigurationService = jest.mocked({ } as jest.MockedObjectDeep); describe('NotificationsDatasource', () => { - let fakeCacheService: FakeCacheService; let migrator: PostgresDatabaseMigrator; let sql: postgres.Sql; const testDbFactory = new TestDbFactory(); let target: NotificationsDatasource; beforeAll(async () => { - fakeCacheService = new FakeCacheService(); sql = await testDbFactory.createTestDatabase(faker.string.uuid()); migrator = new PostgresDatabaseMigrator(sql); await migrator.migrate(); mockConfigurationService.getOrThrow.mockImplementation((key) => { if (key === 'expirationTimeInSeconds.default') return faker.number.int(); }); - const accountsDatasource = new AccountsDatasource( - fakeCacheService, - sql, - new CachedQueryResolver(mockLoggingService, fakeCacheService), - mockLoggingService, - mockConfigurationService, - ); - target = new NotificationsDatasource( - sql, - mockLoggingService, - accountsDatasource, - ); + target = new NotificationsDatasource(sql, mockLoggingService); }); afterEach(async () => { @@ -135,7 +119,7 @@ describe('NotificationsDatasource', () => { const secondSubscriptionsDto = upsertSubscriptionsDtoBuilder() .with('deviceUuid', upsertSubscriptionsDto.deviceUuid) .build(); - await target.upsertSubscriptions({ + const { deviceUuid } = await target.upsertSubscriptions({ signerAddress, upsertSubscriptionsDto, }); @@ -147,14 +131,14 @@ describe('NotificationsDatasource', () => { upsertSubscriptionsDto: secondSubscriptionsDto, }), ).resolves.not.toThrow(); - // Device UUID should have updated await expect( sql`SELECT * FROM push_notification_devices`, ).resolves.toStrictEqual([ { id: 1, device_type: secondSubscriptionsDto.deviceType, - device_uuid: expect.any(String), + // Device UUID shouldn't have updated + device_uuid: deviceUuid, cloud_messaging_token: secondSubscriptionsDto.cloudMessagingToken, created_at: expect.any(Date), updated_at: expect.any(Date), @@ -171,12 +155,14 @@ describe('NotificationsDatasource', () => { address: getAddress(faker.finance.ethereumAddress()), notificationTypes: faker.helpers.arrayElements( Object.values(NotificationType), + { min: 3, max: 3 }, ), }, ]) .build(); const newNotificationTypes = faker.helpers.arrayElements( Object.values(NotificationType), + { min: 1, max: 2 }, ); await target.upsertSubscriptions({ signerAddress, @@ -199,20 +185,17 @@ describe('NotificationsDatasource', () => { sql`SELECT * FROM notification_types`, sql`SELECT * FROM notification_subscription_notification_types`, ]).then(([notificationTypes, subscribedNotifications]) => { + expect(subscribedNotifications.length).toBe(1); // Only new notification types should be subscribed to - expect(subscribedNotifications).toStrictEqual( - expect.arrayContaining( - newNotificationTypes.map((type) => { - return { - id: expect.any(Number), - notification_subscription_id: 1, - notification_type_id: notificationTypes.find( - (t) => t.name === type, - )?.id, - }; - }), - ), - ); + expect(subscribedNotifications).toStrictEqual([ + { + id: expect.any(Number), + notification_subscription_id: 1, + notification_type_id: notificationTypes.find( + (t) => t.name === newNotificationTypes[0], + )?.id, + }, + ]); }); }); @@ -334,7 +317,7 @@ describe('NotificationsDatasource', () => { }); await target.upsertSubscriptions({ signerAddress: secondSignerAddress, - upsertSubscriptionsDto: upsertSubscriptionsDto, + upsertSubscriptionsDto, }); // Ensure correct database structure @@ -450,6 +433,9 @@ describe('NotificationsDatasource', () => { const secondUpsertSubscriptionsDto = upsertSubscriptionsDtoBuilder() .with('safes', upsertSubscriptionsDto.safes) .build(); + const thirdSignerAddress = getAddress(faker.finance.ethereumAddress()); + const thirdUpsertSubscriptionsDto = + upsertSubscriptionsDtoBuilder().build(); await target.upsertSubscriptions({ signerAddress, upsertSubscriptionsDto, @@ -458,6 +444,10 @@ describe('NotificationsDatasource', () => { signerAddress: secondSignerAddress, upsertSubscriptionsDto: secondUpsertSubscriptionsDto, }); + await target.upsertSubscriptions({ + signerAddress: thirdSignerAddress, + upsertSubscriptionsDto: thirdUpsertSubscriptionsDto, + }); const safe = upsertSubscriptionsDto.safes[0]; await expect( diff --git a/src/datasources/notifications/notifications.datasource.ts b/src/datasources/notifications/notifications.datasource.ts index 15b875a48a..038c55ea4d 100644 --- a/src/datasources/notifications/notifications.datasource.ts +++ b/src/datasources/notifications/notifications.datasource.ts @@ -1,6 +1,5 @@ import { NotificationType as DomainNotificationType } from '@/domain/notifications/entities-v2/notification-type.entity'; import { Uuid } from '@/domain/notifications/entities-v2/uuid.entity'; -import { IAccountsDatasource } from '@/domain/interfaces/accounts.datasource.interface'; import { INotificationsDatasource } from '@/domain/interfaces/notifications.datasource.interface'; import { LoggingService, ILoggingService } from '@/logging/logging.interface'; import { asError } from '@/logging/utils'; @@ -21,8 +20,6 @@ export class NotificationsDatasource implements INotificationsDatasource { private readonly sql: postgres.Sql, @Inject(LoggingService) private readonly loggingService: ILoggingService, - @Inject(IAccountsDatasource) - private readonly accountsDatasource: IAccountsDatasource, ) {} /** From cd6ae6c04d32b352fea978a36da60025b2373c85 Mon Sep 17 00:00:00 2001 From: iamacook Date: Fri, 2 Aug 2024 13:06:28 +0200 Subject: [PATCH 37/37] Fix mock --- src/datasources/notifications/notifications.datasource.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/datasources/notifications/notifications.datasource.spec.ts b/src/datasources/notifications/notifications.datasource.spec.ts index 356e3d24c3..4b8c461354 100644 --- a/src/datasources/notifications/notifications.datasource.spec.ts +++ b/src/datasources/notifications/notifications.datasource.spec.ts @@ -162,7 +162,7 @@ describe('NotificationsDatasource', () => { .build(); const newNotificationTypes = faker.helpers.arrayElements( Object.values(NotificationType), - { min: 1, max: 2 }, + { min: 1, max: 1 }, ); await target.upsertSubscriptions({ signerAddress,