From 38ea971a4f5cf650b6b8c17e0a3b43b886210265 Mon Sep 17 00:00:00 2001 From: Hamed Valiollahi Bayeki Date: Fri, 29 Nov 2024 09:37:16 -0800 Subject: [PATCH 1/5] feat: implement notifications settings page for IDIR and BCeID roles --- .../versions/2024-11-27-20-48_b69a33bbd135.py | 34 ++ .../versions/2024-11-29-02-20_d4104af84f2b.py | 87 ++++ .../models/notification/NotificationType.py | 20 +- backend/lcfs/db/models/user/UserProfile.py | 3 + .../common/notification_type_seeder.py | 127 +++++ backend/lcfs/db/seeders/common_seeder.py | 3 + .../test_notification_services.py | 137 ++---- .../notification/test_notification_views.py | 58 +-- backend/lcfs/tests/user/test_user_repo.py | 41 ++ backend/lcfs/tests/user/test_user_views.py | 21 + backend/lcfs/tests/user/user_payloads.py | 4 +- backend/lcfs/web/api/notification/repo.py | 38 +- backend/lcfs/web/api/notification/schema.py | 8 +- backend/lcfs/web/api/notification/services.py | 85 +++- backend/lcfs/web/api/notification/views.py | 19 +- backend/lcfs/web/api/user/repo.py | 26 + backend/lcfs/web/api/user/schema.py | 6 + backend/lcfs/web/api/user/services.py | 14 + backend/lcfs/web/api/user/views.py | 20 + frontend/src/App.jsx | 8 +- frontend/src/assets/locales/en/common.json | 3 +- .../src/assets/locales/en/notifications.json | 94 ++++ frontend/src/components/Role.jsx | 4 - frontend/src/constants/notificationTypes.js | 31 ++ frontend/src/constants/routes/apiRoutes.js | 6 +- frontend/src/constants/routes/routes.js | 2 +- frontend/src/hooks/useNotifications.js | 77 +++ frontend/src/i18n.js | 4 +- .../layouts/MainLayout/components/Logout.jsx | 43 -- .../layouts/MainLayout/components/Navbar.jsx | 9 +- .../components/UserProfileActions.jsx | 122 +++++ .../NotificationMenu/NotificationMenu.jsx | 76 +++ .../components/BCeIDNotificationSettings.jsx | 36 ++ .../IDIRAnalystNotificationSettings.jsx | 34 ++ ...RComplianceManagerNotificationSettings.jsx | 20 + .../IDIRDirectorNotificationSettings.jsx | 26 + .../components/NotificationSettings.jsx | 34 ++ .../components/NotificationSettingsForm.jsx | 465 ++++++++++++++++++ .../components/NotificationTabPanel.jsx | 25 + .../components/Notifications.jsx | 14 + .../NotificationSettingsForm.test.jsx | 153 ++++++ .../__tests__/NotificationTabPanel.test.jsx | 49 ++ .../Notifications/NotificationMenu/index.js | 3 + .../NotificationSettings.jsx | 3 - .../src/views/Notifications/Notifications.jsx | 3 - .../__tests__/NotificationSettings.test.jsx | 1 - .../__tests__/Notifications.test.jsx | 1 - frontend/src/views/Notifications/index.js | 2 - 48 files changed, 1846 insertions(+), 253 deletions(-) create mode 100644 backend/lcfs/db/migrations/versions/2024-11-27-20-48_b69a33bbd135.py create mode 100644 backend/lcfs/db/migrations/versions/2024-11-29-02-20_d4104af84f2b.py create mode 100644 backend/lcfs/db/seeders/common/notification_type_seeder.py create mode 100644 frontend/src/assets/locales/en/notifications.json create mode 100644 frontend/src/constants/notificationTypes.js create mode 100644 frontend/src/hooks/useNotifications.js delete mode 100644 frontend/src/layouts/MainLayout/components/Logout.jsx create mode 100644 frontend/src/layouts/MainLayout/components/UserProfileActions.jsx create mode 100644 frontend/src/views/Notifications/NotificationMenu/NotificationMenu.jsx create mode 100644 frontend/src/views/Notifications/NotificationMenu/components/BCeIDNotificationSettings.jsx create mode 100644 frontend/src/views/Notifications/NotificationMenu/components/IDIRAnalystNotificationSettings.jsx create mode 100644 frontend/src/views/Notifications/NotificationMenu/components/IDIRComplianceManagerNotificationSettings.jsx create mode 100644 frontend/src/views/Notifications/NotificationMenu/components/IDIRDirectorNotificationSettings.jsx create mode 100644 frontend/src/views/Notifications/NotificationMenu/components/NotificationSettings.jsx create mode 100644 frontend/src/views/Notifications/NotificationMenu/components/NotificationSettingsForm.jsx create mode 100644 frontend/src/views/Notifications/NotificationMenu/components/NotificationTabPanel.jsx create mode 100644 frontend/src/views/Notifications/NotificationMenu/components/Notifications.jsx create mode 100644 frontend/src/views/Notifications/NotificationMenu/components/__tests__/NotificationSettingsForm.test.jsx create mode 100644 frontend/src/views/Notifications/NotificationMenu/components/__tests__/NotificationTabPanel.test.jsx create mode 100644 frontend/src/views/Notifications/NotificationMenu/index.js delete mode 100644 frontend/src/views/Notifications/NotificationSettings/NotificationSettings.jsx delete mode 100644 frontend/src/views/Notifications/Notifications.jsx delete mode 100644 frontend/src/views/Notifications/__tests__/NotificationSettings.test.jsx delete mode 100644 frontend/src/views/Notifications/__tests__/Notifications.test.jsx delete mode 100644 frontend/src/views/Notifications/index.js diff --git a/backend/lcfs/db/migrations/versions/2024-11-27-20-48_b69a33bbd135.py b/backend/lcfs/db/migrations/versions/2024-11-27-20-48_b69a33bbd135.py new file mode 100644 index 000000000..4437a4e23 --- /dev/null +++ b/backend/lcfs/db/migrations/versions/2024-11-27-20-48_b69a33bbd135.py @@ -0,0 +1,34 @@ +"""Add notifications email to user_profile + +Revision ID: b69a33bbd135 +Revises: 0775a141d335 +Create Date: 2024-11-27 20:48:24.724112 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "b69a33bbd135" +down_revision = "0775a141d335" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add notifications_email column to user_profile table + op.add_column( + "user_profile", + sa.Column( + "notifications_email", + sa.String(length=255), + nullable=True, + comment="Email address used for notifications", + ), + ) + + +def downgrade() -> None: + # Remove notifications_email column from user_profile table + op.drop_column("user_profile", "notifications_email") diff --git a/backend/lcfs/db/migrations/versions/2024-11-29-02-20_d4104af84f2b.py b/backend/lcfs/db/migrations/versions/2024-11-29-02-20_d4104af84f2b.py new file mode 100644 index 000000000..83bee6bda --- /dev/null +++ b/backend/lcfs/db/migrations/versions/2024-11-29-02-20_d4104af84f2b.py @@ -0,0 +1,87 @@ +"""Update Notification Types and remove data + +Revision ID: d4104af84f2b +Revises: b69a33bbd135 +Create Date: 2024-11-29 02:20:33.898150 + +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import ENUM + +# Revision identifiers, used by Alembic. +revision = "d4104af84f2b" +down_revision = "b69a33bbd135" +branch_labels = None +depends_on = None + + +def upgrade(): + # Remove the notification types added in the previous migration + op.execute("DELETE FROM notification_type;") + + # Alter the `name` column in `notification_type` to be a VARCHAR + with op.batch_alter_table("notification_type") as batch_op: + batch_op.alter_column( + "name", + existing_type=ENUM( + "TRANSFER_PARTNER_UPDATE", + "TRANSFER_DIRECTOR_REVIEW", + "INITIATIVE_APPROVED", + "INITIATIVE_DA_REQUEST", + "SUPPLEMENTAL_REQUESTED", + "DIRECTOR_ASSESSMENT", + name="notification_type_enum_v2", + ), + type_=sa.String(length=255), + existing_nullable=False, + ) + + # Drop the old enum types + op.execute("DROP TYPE IF EXISTS notification_type_enum_v2;") + + +def downgrade(): + # Re-create the old enum type + notification_type_enum_v2 = ENUM( + "TRANSFER_PARTNER_UPDATE", + "TRANSFER_DIRECTOR_REVIEW", + "INITIATIVE_APPROVED", + "INITIATIVE_DA_REQUEST", + "SUPPLEMENTAL_REQUESTED", + "DIRECTOR_ASSESSMENT", + name="notification_type_enum_v2", + ) + notification_type_enum_v2.create(op.get_bind(), checkfirst=False) + + # Alter the `name` column back to the old enum + with op.batch_alter_table("notification_type") as batch_op: + batch_op.alter_column( + "name", + type_=notification_type_enum_v2, + existing_type=sa.String(length=255), + postgresql_using="name::text::notification_type_enum_v2", + existing_nullable=False, + ) + + # Re-insert the previous notification types + op.execute( + """ + INSERT INTO notification_type (notification_type_id, name, description, email_content, create_user, update_user) + VALUES + (1, 'TRANSFER_PARTNER_UPDATE', 'Transfer partner update notification', 'Email content for transfer partner update', 'system', 'system'), + (2, 'TRANSFER_DIRECTOR_REVIEW', 'Director review notification', 'Email content for director review', 'system', 'system'), + (3, 'INITIATIVE_APPROVED', 'Initiative approved notification', 'Email content for initiative approval', 'system', 'system'), + (4, 'INITIATIVE_DA_REQUEST', 'DA request notification', 'Email content for DA request', 'system', 'system'), + (5, 'SUPPLEMENTAL_REQUESTED', 'Supplemental requested notification', 'Email content for supplemental request', 'system', 'system'), + (6, 'DIRECTOR_ASSESSMENT', 'Director assessment notification', 'Email content for director assessment', 'system', 'system'); + """ + ) + + # Reset the sequence for the id column + op.execute( + """ + SELECT setval('notification_type_id_seq', (SELECT MAX(notification_type_id) FROM notification_type)); + """ + ) diff --git a/backend/lcfs/db/models/notification/NotificationType.py b/backend/lcfs/db/models/notification/NotificationType.py index 2311d834d..f3f6f03f5 100644 --- a/backend/lcfs/db/models/notification/NotificationType.py +++ b/backend/lcfs/db/models/notification/NotificationType.py @@ -1,28 +1,18 @@ import enum from lcfs.db.base import BaseModel, Auditable -from sqlalchemy import Column, Integer, Enum, Text +from sqlalchemy import Column, Integer, Text, String from sqlalchemy.orm import relationship -class NotificationTypeEnum(enum.Enum): - TRANSFER_PARTNER_UPDATE = "Transfer partner proposed, declined, rescinded, or signed" - TRANSFER_DIRECTOR_REVIEW = "Director recorded/refused" - INITIATIVE_APPROVED = "Director approved" - INITIATIVE_DA_REQUEST = "DA request" - SUPPLEMENTAL_REQUESTED = "Supplemental requested" - DIRECTOR_ASSESSMENT = "Director assessment" - - class NotificationType(BaseModel, Auditable): __tablename__ = "notification_type" __table_args__ = {"comment": "Represents a Notification type"} notification_type_id = Column(Integer, primary_key=True, autoincrement=True) - name = Column( - Enum(NotificationTypeEnum, name="notification_type_enum", create_type=True), - nullable=False, - ) + name = Column(String(255), nullable=False) description = Column(Text, nullable=True) email_content = Column(Text, nullable=True) - subscriptions = relationship("NotificationChannelSubscription", back_populates="notification_type") + subscriptions = relationship( + "NotificationChannelSubscription", back_populates="notification_type" + ) diff --git a/backend/lcfs/db/models/user/UserProfile.py b/backend/lcfs/db/models/user/UserProfile.py index 7fecca2f8..681d19aa0 100644 --- a/backend/lcfs/db/models/user/UserProfile.py +++ b/backend/lcfs/db/models/user/UserProfile.py @@ -26,6 +26,9 @@ class UserProfile(BaseModel, Auditable): String(150), unique=True, nullable=False, comment="keycloak Username" ) email = Column(String(255), nullable=True, comment="Primary email address") + notifications_email = Column( + String(255), nullable=True, comment="Email address used for notifications" + ) title = Column(String(100), nullable=True, comment="Professional Title") phone = Column(String(50), nullable=True, comment="Primary phone number") mobile_phone = Column(String(50), nullable=True, comment="Mobile phone number") diff --git a/backend/lcfs/db/seeders/common/notification_type_seeder.py b/backend/lcfs/db/seeders/common/notification_type_seeder.py new file mode 100644 index 000000000..367d8ac0c --- /dev/null +++ b/backend/lcfs/db/seeders/common/notification_type_seeder.py @@ -0,0 +1,127 @@ +import structlog +from sqlalchemy import select +from lcfs.db.models.notification.NotificationType import ( + NotificationType, +) + +logger = structlog.get_logger(__name__) + + +async def seed_notification_types(session): + """ + Seeds the notification types into the database. + + Args: + session: The database session for committing the new records. + """ + notification_types_to_seed = [ + { + "notification_type_id": 1, + "name": "BCEID__CR__DIRECTOR_ASSESSMENT", + "description": "Director assessment", + }, + { + "notification_type_id": 2, + "name": "BCEID__IA__DIRECTOR_APPROVAL", + "description": "Director approved", + }, + { + "notification_type_id": 3, + "name": "BCEID__TR__DIRECTOR_DECISION", + "description": "Director recorded/refused", + }, + { + "notification_type_id": 4, + "name": "BCEID__TR__PARTNER_ACTIONS", + "description": "Transfer partner proposed, declined, rescinded or signed & submitted", + }, + { + "notification_type_id": 5, + "name": "IDIR_A__CR__DIRECTOR_DECISION", + "description": "Director assessment", + }, + { + "notification_type_id": 6, + "name": "IDIR_A__CR__MANAGER_RECOMMENDATION", + "description": "Recommended by compliance manager", + }, + { + "notification_type_id": 7, + "name": "IDIR_A__CR__SUBMITTED_FOR_REVIEW", + "description": "Submitted to government for analyst review (or returned by compliance manager)", + }, + { + "notification_type_id": 8, + "name": "IDIR_A__IA__RETURNED_TO_ANALYST", + "description": "Director approved/returned to analyst", + }, + { + "notification_type_id": 9, + "name": "IDIR_A__TR__DIRECTOR_RECORDED", + "description": "Director recorded/refused", + }, + { + "notification_type_id": 10, + "name": "IDIR_A__TR__RESCINDED_ACTION", + "description": "Rescinded by either transfer partner", + }, + { + "notification_type_id": 11, + "name": "IDIR_A__TR__SUBMITTED_FOR_REVIEW", + "description": "Submitted to government for analyst review", + }, + { + "notification_type_id": 12, + "name": "IDIR_CM__CR__ANALYST_RECOMMENDATION", + "description": "Analyst recommendation (or returned by the director)", + }, + { + "notification_type_id": 13, + "name": "IDIR_CM__CR__DIRECTOR_ASSESSMENT", + "description": "Director assessment", + }, + { + "notification_type_id": 14, + "name": "IDIR_CM__CR__SUBMITTED_FOR_REVIEW", + "description": "Submitted to government for analyst review", + }, + { + "notification_type_id": 15, + "name": "IDIR_D__CR__MANAGER_RECOMMENDATION", + "description": "Compliance manager recommendation", + }, + { + "notification_type_id": 16, + "name": "IDIR_D__IA__ANALYST_RECOMMENDATION", + "description": "Analyst recommendation", + }, + { + "notification_type_id": 17, + "name": "IDIR_D__TR__ANALYST_RECOMMENDATION", + "description": "Analyst recommendation", + }, + ] + + try: + for type_data in notification_types_to_seed: + exists = await session.execute( + select(NotificationType).where( + NotificationType.notification_type_id + == type_data["notification_type_id"] + ) + ) + if not exists.scalars().first(): + notification_type = NotificationType(**type_data) + session.add(notification_type) + + except Exception as e: + context = { + "function": "seed_notification_types", + } + logger.error( + "Error occurred while seeding notification types", + error=str(e), + exc_info=e, + **context, + ) + raise diff --git a/backend/lcfs/db/seeders/common_seeder.py b/backend/lcfs/db/seeders/common_seeder.py index b64a5eb1e..8da01b5a4 100644 --- a/backend/lcfs/db/seeders/common_seeder.py +++ b/backend/lcfs/db/seeders/common_seeder.py @@ -22,6 +22,7 @@ from lcfs.db.seeders.common.allocation_agreement_seeder import ( seed_allocation_transaction_types, ) +from lcfs.db.seeders.common.notification_type_seeder import seed_notification_types logger = structlog.get_logger(__name__) @@ -53,6 +54,7 @@ async def update_sequences(session): "transfer_category": "transfer_category_id", "transfer_status": "transfer_status_id", "role": "role_id", + "notification_type": "notification_type_id", } for table, column in sequences.items(): @@ -78,6 +80,7 @@ async def seed_common(session: AsyncSession): await seed_static_fuel_data(session) await seed_compliance_report_statuses(session) await seed_allocation_transaction_types(session) + await seed_notification_types(session) # Update sequences after all seeders have run await update_sequences(session) diff --git a/backend/lcfs/tests/notification/test_notification_services.py b/backend/lcfs/tests/notification/test_notification_services.py index 2d2cc6ef7..c112b6658 100644 --- a/backend/lcfs/tests/notification/test_notification_services.py +++ b/backend/lcfs/tests/notification/test_notification_services.py @@ -272,49 +272,43 @@ async def test_create_notification_channel_subscription(notification_service): subscription_data = SubscriptionSchema( is_enabled=True, - user_profile_id=1, - notification_type_id=2, - notification_channel_id=3, + notification_channel_key="email", + notification_type_key="new_message", ) + user_profile_id = 1 + + # Mock the methods that fetch IDs from keys + service.get_notification_channel_id_by_key = AsyncMock(return_value=3) + service.get_notification_type_id_by_key = AsyncMock(return_value=2) + # Mock the repo method created_subscription = NotificationChannelSubscription( notification_channel_subscription_id=123, is_enabled=True, - user_profile_id=1, + user_profile_id=user_profile_id, notification_type_id=2, notification_channel_id=3, ) - mock_repo.create_notification_channel_subscription = AsyncMock( return_value=created_subscription ) - result = await service.create_notification_channel_subscription(subscription_data) + result = await service.create_notification_channel_subscription( + subscription_data, user_profile_id + ) + + # Assertions + assert isinstance(result, SubscriptionSchema) + assert result.notification_channel_subscription_id == 123 + assert result.is_enabled == True + # Verify that the repo method was called with the correct subscription called_args, _ = mock_repo.create_notification_channel_subscription.await_args passed_subscription = called_args[0] - - assert ( - result.notification_channel_subscription_id - == created_subscription.notification_channel_subscription_id - ) - assert result.is_enabled == created_subscription.is_enabled - assert result.user_profile_id == created_subscription.user_profile_id - assert result.notification_type_id == created_subscription.notification_type_id - assert ( - result.notification_channel_id == created_subscription.notification_channel_id - ) - - assert passed_subscription.is_enabled == subscription_data.is_enabled - assert passed_subscription.user_profile_id == subscription_data.user_profile_id - assert ( - passed_subscription.notification_type_id - == subscription_data.notification_type_id - ) - assert ( - passed_subscription.notification_channel_id - == subscription_data.notification_channel_id - ) + assert passed_subscription.is_enabled == True + assert passed_subscription.user_profile_id == user_profile_id + assert passed_subscription.notification_channel_id == 3 + assert passed_subscription.notification_type_id == 2 @pytest.mark.anyio @@ -322,23 +316,33 @@ async def test_get_notification_channel_subscriptions_by_user_id(notification_se service, mock_repo = notification_service user_id = 1 - expected_subscriptions = [ - NotificationChannelSubscription( - notification_channel_subscription_id=123, - is_enabled=True, - user_profile_id=user_id, - notification_type_id=2, - notification_channel_id=3, - ) - ] + # Mock subscription data + mock_subscription = MagicMock(spec=NotificationChannelSubscription) + mock_subscription.notification_channel_subscription_id = 123 + mock_subscription.user_profile_id = user_id + mock_subscription.is_enabled = True + + # Mock associated channel and type + mock_channel = MagicMock() + mock_channel.channel_name.name = "email" + mock_subscription.notification_channel = mock_channel + + mock_type = MagicMock() + mock_type.name = "new_message" + mock_subscription.notification_type = mock_type mock_repo.get_notification_channel_subscriptions_by_user = AsyncMock( - return_value=expected_subscriptions + return_value=[mock_subscription] ) result = await service.get_notification_channel_subscriptions_by_user_id(user_id) - assert result == expected_subscriptions + assert len(result) == 1 + subscription = result[0] + assert subscription["notification_channel_subscription_id"] == 123 + assert subscription["notification_channel_key"] == "email" + assert subscription["notification_type_key"] == "new_message" + mock_repo.get_notification_channel_subscriptions_by_user.assert_awaited_once_with( user_id ) @@ -369,63 +373,16 @@ async def test_get_notification_channel_subscription_by_id(notification_service) ) -@pytest.mark.anyio -async def test_update_notification_channel_subscription(notification_service): - service, mock_repo = notification_service - - subscription_data = SubscriptionSchema( - notification_channel_subscription_id=123, - is_enabled=False, - user_profile_id=1, - notification_type_id=2, - notification_channel_id=3, - ) - - updated_subscription = NotificationChannelSubscription( - notification_channel_subscription_id=123, - is_enabled=False, - user_profile_id=1, - notification_type_id=2, - notification_channel_id=3, - ) - - mock_repo.update_notification_channel_subscription = AsyncMock( - return_value=updated_subscription - ) - - result = await service.update_notification_channel_subscription(subscription_data) - - called_args, _ = mock_repo.update_notification_channel_subscription.await_args - passed_subscription = called_args[0] - assert ( - passed_subscription.notification_channel_subscription_id - == updated_subscription.notification_channel_subscription_id - ) - assert passed_subscription.is_enabled == subscription_data.is_enabled - assert passed_subscription.user_profile_id == subscription_data.user_profile_id - assert ( - passed_subscription.notification_type_id - == subscription_data.notification_type_id - ) - assert ( - passed_subscription.notification_channel_id - == subscription_data.notification_channel_id - ) - - @pytest.mark.anyio async def test_delete_notification_channel_subscription(notification_service): service, mock_repo = notification_service - user_id = 1 + user_profile_id = 1 subscription_id = 456 mock_subscription_data = NotificationChannelSubscription( notification_channel_subscription_id=subscription_id, - is_enabled=True, - user_profile_id=user_id, - notification_type_id=2, - notification_channel_id=3, + user_profile_id=user_profile_id, ) mock_repo.get_notification_channel_subscription_by_id = AsyncMock( @@ -433,7 +390,9 @@ async def test_delete_notification_channel_subscription(notification_service): ) mock_repo.delete_notification_channel_subscription = AsyncMock() - await service.delete_notification_channel_subscription(subscription_id) + await service.delete_notification_channel_subscription( + subscription_id, user_profile_id + ) mock_repo.get_notification_channel_subscription_by_id.assert_awaited_once_with( subscription_id diff --git a/backend/lcfs/tests/notification/test_notification_views.py b/backend/lcfs/tests/notification/test_notification_views.py index 9ce72fa86..bc028fe11 100644 --- a/backend/lcfs/tests/notification/test_notification_views.py +++ b/backend/lcfs/tests/notification/test_notification_views.py @@ -172,11 +172,9 @@ async def test_get_notification_channel_subscription_by_id( mock_subscription = SubscriptionSchema( notification_channel_subscription_id=1, user_profile_id=1, # Match mock user - channel_name="Test Channel", - is_active=True, - notification_type_id=1, - created_at="2023-01-01T00:00:00", - updated_at="2023-01-01T00:00:00", + notification_channel_key="email", + notification_type_key="new_message", + is_enabled=True, ) mock_get_subscription.return_value = mock_subscription @@ -200,7 +198,6 @@ async def test_get_notification_channel_subscription_by_id( ) - @pytest.mark.anyio async def test_create_subscription(client, fastapi_app, set_mock_user): with patch( @@ -233,16 +230,19 @@ async def test_delete_subscription(client, fastapi_app, set_mock_user): "lcfs.web.api.notification.views.NotificationService.delete_notification_channel_subscription", return_value=None, ) as mock_delete_subscription: - set_mock_user(fastapi_app, [RoleEnum.GOVERNMENT]) - - mock_delete_subscription.return_value = { - "message": "Notification Subscription deleted successfully" - } - - subscription_data = {"notification_channel_subscription_id": 1, "deleted": True} + set_mock_user( + fastapi_app, + [RoleEnum.GOVERNMENT], + user_details={"user_profile_id": 1}, + ) url = fastapi_app.url_path_for("save_subscription") + subscription_data = { + "notification_channel_subscription_id": 1, + "deleted": True, + } + response = await client.post(url, json=subscription_data) assert response.status_code == 200 @@ -250,32 +250,6 @@ async def test_delete_subscription(client, fastapi_app, set_mock_user): response.json()["message"] == "Notification Subscription deleted successfully" ) - mock_delete_subscription.assert_called_once_with(1) - - -@pytest.mark.anyio -async def test_update_subscription(client, fastapi_app, set_mock_user): - with patch( - "lcfs.web.api.notification.views.NotificationService.update_notification_channel_subscription" - ) as mock_update_subscription: - updated_subscription_data = { - "notification_channel_subscription_id": 1, - "is_enabled": False, - "notification_channel_id": 1, - "user_profile_id": 1, - "notification_type_id": 1, - } - - mock_update_subscription.return_value = updated_subscription_data - - set_mock_user(fastapi_app, [RoleEnum.GOVERNMENT]) - - url = fastapi_app.url_path_for("save_subscription") - - response = await client.post(url, json=updated_subscription_data) - - assert response.status_code == 200 - assert response.json()["isEnabled"] is False - mock_update_subscription.assert_called_once_with( - SubscriptionSchema(**updated_subscription_data) - ) + mock_delete_subscription.assert_called_once_with( + 1, 1 + ) # subscription_id, user_profile_id diff --git a/backend/lcfs/tests/user/test_user_repo.py b/backend/lcfs/tests/user/test_user_repo.py index 19468ebc3..ba5b955ac 100644 --- a/backend/lcfs/tests/user/test_user_repo.py +++ b/backend/lcfs/tests/user/test_user_repo.py @@ -5,6 +5,7 @@ from lcfs.db.models import UserProfile, UserLoginHistory from lcfs.web.api.user.repo import UserRepository from lcfs.tests.user.user_payloads import user_orm_model +from lcfs.web.exception.exceptions import DataNotFoundException @pytest.fixture @@ -46,3 +47,43 @@ async def test_create_login_history(dbsession, user_repo): assert added_object.external_username == user.keycloak_username assert added_object.keycloak_user_id == user.keycloak_user_id assert added_object.is_login_successful is True + + +@pytest.mark.anyio +async def test_update_notifications_email_success(dbsession, user_repo): + # Arrange: Create a user in the database + user = UserProfile( + keycloak_user_id="user_id_1", + keycloak_email="user1@domain.com", + keycloak_username="username1", + email="user1@domain.com", + notifications_email=None, + title="Developer", + phone="1234567890", + mobile_phone="0987654321", + first_name="John", + last_name="Doe", + is_active=True, + organization_id=1, + ) + dbsession.add(user) + await dbsession.commit() + await dbsession.refresh(user) + + # Act: Update the notifications email + updated_user = await user_repo.update_notifications_email( + user_profile_id=1, email="new_email@domain.com" + ) + + # Assert: Check if the notifications email was updated + assert updated_user.notifications_email == "new_email@domain.com" + + +@pytest.mark.anyio +async def test_update_notifications_email_user_not_found(dbsession, user_repo): + # Act and Assert: Try to update a non-existent user and expect an exception + with pytest.raises(DataNotFoundException) as exc_info: + await user_repo.update_notifications_email( + user_profile_id=9999, email="new_email@domain.com" + ) + assert "User with id '9999' not found." in str(exc_info.value) diff --git a/backend/lcfs/tests/user/test_user_views.py b/backend/lcfs/tests/user/test_user_views.py index 2d4f1bafa..75a228c0c 100644 --- a/backend/lcfs/tests/user/test_user_views.py +++ b/backend/lcfs/tests/user/test_user_views.py @@ -22,6 +22,7 @@ UserActivitiesResponseSchema, UserLoginHistoryResponseSchema, ) +from lcfs.web.exception.exceptions import DataNotFoundException @pytest.mark.anyio @@ -464,3 +465,23 @@ async def test_track_logged_in_success(client: AsyncClient, fastapi_app, set_moc # Extract the first argument of the first call user_profile = mock_track_user_login.call_args[0][0] assert isinstance(user_profile, UserProfile) + + +@pytest.mark.anyio +async def test_update_notifications_email_success( + client: AsyncClient, + fastapi_app, + set_mock_user, + add_models, +): + set_mock_user(fastapi_app, [RoleEnum.GOVERNMENT]) + + # Prepare request data + request_data = {"notifications_email": "new_email@domain.com"} + + # Act: Send POST request to the endpoint + url = fastapi_app.url_path_for("update_notifications_email") + response = await client.post(url, json=request_data) + + # Assert: Check response status and content + assert response.status_code == status.HTTP_200_OK diff --git a/backend/lcfs/tests/user/user_payloads.py b/backend/lcfs/tests/user/user_payloads.py index 6361299a0..3491cfd5e 100644 --- a/backend/lcfs/tests/user/user_payloads.py +++ b/backend/lcfs/tests/user/user_payloads.py @@ -5,9 +5,11 @@ keycloak_user_id="user_id", keycloak_email="email@domain.com", keycloak_username="username", + email="email@domain.com", + notifications_email=None, title="Developer", phone="1234567890", - mobile_phone="1234567890", + mobile_phone="0987654321", first_name="John", last_name="Smith", is_active=True, diff --git a/backend/lcfs/web/api/notification/repo.py b/backend/lcfs/web/api/notification/repo.py index 12788666e..84cf5c53b 100644 --- a/backend/lcfs/web/api/notification/repo.py +++ b/backend/lcfs/web/api/notification/repo.py @@ -1,6 +1,8 @@ from lcfs.db.models.notification import ( NotificationChannelSubscription, NotificationMessage, + NotificationChannel, + NotificationType, ) from lcfs.web.api.notification.schema import NotificationMessageSchema import structlog @@ -162,12 +164,17 @@ async def create_notification_channel_subscription( @repo_handler async def get_notification_channel_subscriptions_by_user( self, user_profile_id: int - ) -> Optional[NotificationChannelSubscription]: + ) -> List[NotificationChannelSubscription]: """ - Retrieve channel subscriptions for a user + Retrieve channel subscriptions for a user, including channel key and notification type key. """ - query = select(NotificationChannelSubscription).where( - NotificationChannelSubscription.user_profile_id == user_profile_id + query = ( + select(NotificationChannelSubscription) + .options( + selectinload(NotificationChannelSubscription.notification_channel), + selectinload(NotificationChannelSubscription.notification_type), + ) + .where(NotificationChannelSubscription.user_profile_id == user_profile_id) ) result = await self.db.execute(query) subscriptions = result.scalars().all() @@ -227,3 +234,26 @@ async def delete_notification_channel_subscription( ) await self.db.execute(query) await self.db.flush() + + @repo_handler + async def get_notification_type_by_key(self, key: str) -> Optional[int]: + """ + Retrieve a NotificationType by its key + """ + query = select(NotificationType.notification_type_id).where( + NotificationType.name == key + ) + result = await self.db.execute(query) + x = result.scalars().first() + return x + + @repo_handler + async def get_notification_channel_by_key(self, key: str) -> Optional[int]: + """ + Retrieve a NotificationChannel by its key + """ + query = select(NotificationChannel.notification_channel_id).where( + NotificationChannel.channel_name == key + ) + result = await self.db.execute(query) + return result.scalars().first() diff --git a/backend/lcfs/web/api/notification/schema.py b/backend/lcfs/web/api/notification/schema.py index e6a17504c..65ebafc11 100644 --- a/backend/lcfs/web/api/notification/schema.py +++ b/backend/lcfs/web/api/notification/schema.py @@ -16,9 +16,11 @@ class NotificationMessageSchema(BaseSchema): notification_type_id: Optional[int] = None deleted: Optional[bool] = None + class NotificationCountSchema(BaseSchema): count: int + class DeleteNotificationMessageSchema(BaseSchema): notification_message_id: int deleted: bool @@ -31,9 +33,9 @@ class DeleteNotificationMessageResponseSchema(BaseSchema): class SubscriptionSchema(BaseSchema): notification_channel_subscription_id: Optional[int] = None is_enabled: Optional[bool] = True - notification_channel_id: Optional[int] = None - user_profile_id: Optional[int]= None - notification_type_id: Optional[int] = None + notification_channel_key: Optional[str] = None + user_profile_id: Optional[int] = None + notification_type_key: Optional[str] = None deleted: Optional[bool] = None diff --git a/backend/lcfs/web/api/notification/services.py b/backend/lcfs/web/api/notification/services.py index 5340df504..25ccd7f2c 100644 --- a/backend/lcfs/web/api/notification/services.py +++ b/backend/lcfs/web/api/notification/services.py @@ -106,21 +106,41 @@ async def delete_notification_message(self, notification_id: int): else: raise DataNotFoundException(f"Notification with ID {notification_id}.") + @service_handler + async def get_notification_type_id_by_key(self, key: str) -> int: + notification_type = await self.repo.get_notification_type_by_key(key) + if not notification_type: + raise ValueError(f"Invalid notification type key: {key}") + return notification_type + + @service_handler + async def get_notification_channel_id_by_key(self, key: str) -> int: + notification_channel = await self.repo.get_notification_channel_by_key(key) + if not notification_channel: + raise ValueError(f"Invalid notification channel key: {key}") + return notification_channel + @service_handler async def create_notification_channel_subscription( - self, subscription_data: SubscriptionSchema + self, subscription_data: SubscriptionSchema, user_profile_id: int ): - """ - Create a new notification channel subscription. - """ + notification_channel_id = await self.get_notification_channel_id_by_key( + subscription_data.notification_channel_key + ) + notification_type_id = await self.get_notification_type_id_by_key( + subscription_data.notification_type_key + ) + subscription = NotificationChannelSubscription( - **subscription_data.model_dump(exclude={"deleted"}) + user_profile_id=user_profile_id, + notification_channel_id=notification_channel_id, + notification_type_id=notification_type_id, + is_enabled=subscription_data.is_enabled, ) created_subscription = await self.repo.create_notification_channel_subscription( subscription ) - - # Convert the SQLAlchemy model instance to the Pydantic model + x = 1 return SubscriptionSchema.model_validate(created_subscription) @service_handler @@ -128,7 +148,20 @@ async def get_notification_channel_subscriptions_by_user_id(self, user_id: int): """ Retrieve all notification channel subscriptions for a user. """ - return await self.repo.get_notification_channel_subscriptions_by_user(user_id) + subscriptions = await self.repo.get_notification_channel_subscriptions_by_user( + user_id + ) + + subscriptions_with_keys = [ + { + "notification_channel_subscription_id": subscription.notification_channel_subscription_id, + "notification_channel_key": subscription.notification_channel.channel_name.name, + "notification_type_key": subscription.notification_type.name, + } + for subscription in subscriptions + ] + + return subscriptions_with_keys @service_handler async def get_notification_channel_subscription_by_id( @@ -143,34 +176,40 @@ async def get_notification_channel_subscription_by_id( @service_handler async def update_notification_channel_subscription( - self, subscription_data: SubscriptionSchema + self, subscription_data: SubscriptionSchema, user_profile_id: int ): - """ - Update an existing notification channel subscription. - """ + notification_channel_subscription_id = ( + await self.get_notification_channel_id_by_key( + subscription_data.notification_channel_subscription_id + ) + ) + existing_subscription = ( + await self.repo.get_notification_channel_subscription_by_id( + notification_channel_subscription_id + ) + ) + if existing_subscription.user_profile_id != user_profile_id: + raise DataNotFoundException( + "You are not authorized to update this subscription." + ) + subscription = NotificationChannelSubscription( **subscription_data.model_dump(exclude={"deleted"}) ) + subscription.user_profile_id = user_profile_id # Ensure correct user return await self.repo.update_notification_channel_subscription(subscription) @service_handler async def delete_notification_channel_subscription( - self, subscription_id: int + self, subscription_id: int, user_profile_id: int ): - """ - Delete a notification channel subscription. - """ - # Ensure the subscription exists subscription = await self.repo.get_notification_channel_subscription_by_id( subscription_id ) - if not subscription: + if not subscription or subscription.user_profile_id != user_profile_id: raise DataNotFoundException( - f"Subscription with ID {subscription_id} not found." + "Subscription not found or you are not authorized to delete it." ) - # Proceed to delete if it exists and belongs to the user await self.repo.delete_notification_channel_subscription(subscription_id) - logger.info( - f"Deleted notification channel subscription {subscription_id}." - ) + logger.info(f"Deleted notification channel subscription {subscription_id}.") diff --git a/backend/lcfs/web/api/notification/views.py b/backend/lcfs/web/api/notification/views.py index 6f4828d32..4602df595 100644 --- a/backend/lcfs/web/api/notification/views.py +++ b/backend/lcfs/web/api/notification/views.py @@ -49,7 +49,7 @@ async def get_notification_messages_by_user_id( response_model=NotificationCountSchema, status_code=status.HTTP_200_OK, ) -@view_handler([RoleEnum.GOVERNMENT]) +@view_handler(["*"]) async def get_unread_notifications( request: Request, service: NotificationService = Depends() ): @@ -68,11 +68,10 @@ async def get_unread_notifications( response_model=List[SubscriptionSchema], status_code=status.HTTP_200_OK, ) -@view_handler([RoleEnum.GOVERNMENT]) +@view_handler(["*"]) async def get_notifications_channel_subscriptions_by_user_id( request: Request, service: NotificationService = Depends() ): - return await service.get_notification_channel_subscriptions_by_user_id( user_id=request.user.user_profile_id ) @@ -118,25 +117,29 @@ async def save_notification( ], status_code=status.HTTP_200_OK, ) -@view_handler([RoleEnum.GOVERNMENT]) +@view_handler(["*"]) async def save_subscription( request: Request, request_data: Union[SubscriptionSchema, DeleteSubscriptionSchema] = Body(...), service: NotificationService = Depends(), ): + user_profile_id = request.user.user_profile_id subscription_id = request_data.notification_channel_subscription_id + if request_data.deleted: try: - await service.delete_notification_channel_subscription(subscription_id) + await service.delete_notification_channel_subscription( + subscription_id, user_profile_id + ) return DeleteNotificationChannelSubscriptionResponseSchema( message="Notification Subscription deleted successfully" ) except DataNotFoundException as e: raise HTTPException(status_code=404, detail=str(e)) - elif subscription_id: - return await service.update_notification_channel_subscription(request_data) else: - return await service.create_notification_channel_subscription(request_data) + return await service.create_notification_channel_subscription( + request_data, user_profile_id + ) @router.get( diff --git a/backend/lcfs/web/api/user/repo.py b/backend/lcfs/web/api/user/repo.py index ab3b76485..1d79974b5 100644 --- a/backend/lcfs/web/api/user/repo.py +++ b/backend/lcfs/web/api/user/repo.py @@ -14,9 +14,11 @@ func, cast, String, + update, ) from sqlalchemy.orm import joinedload from sqlalchemy.ext.asyncio import AsyncSession +from lcfs.web.exception.exceptions import DataNotFoundException from lcfs.services.keycloak.dependencies import parse_external_username from lcfs.web.core.decorators import repo_handler @@ -665,3 +667,27 @@ async def create_login_history(self, user: UserProfile): ) self.db.add(login_history) + + @repo_handler + async def update_notifications_email( + self, user_profile_id: int, email: str + ) -> UserProfile: + # Fetch the user profile + query = select(UserProfile).where( + UserProfile.user_profile_id == user_profile_id + ) + result = await self.db.execute(query) + user_profile = result.scalar_one_or_none() + + if not user_profile: + logger.warning(f"User with ID {user_profile_id} not found.") + raise DataNotFoundException(f"User with id '{user_profile_id}' not found.") + + # Update the notifications_email field + user_profile.notifications_email = email + + # Flush and refresh without committing + await self.db.flush() + await self.db.refresh(user_profile) + + return user_profile diff --git a/backend/lcfs/web/api/user/schema.py b/backend/lcfs/web/api/user/schema.py index 8ad458383..6cf34fe46 100644 --- a/backend/lcfs/web/api/user/schema.py +++ b/backend/lcfs/web/api/user/schema.py @@ -40,6 +40,7 @@ class UserBaseSchema(BaseSchema): keycloak_username: str keycloak_email: EmailStr email: Optional[EmailStr] = None + notifications_email: Optional[EmailStr] = None title: Optional[str] = None phone: Optional[str] = None first_name: Optional[str] = None @@ -80,6 +81,7 @@ class UserActivitiesResponseSchema(BaseSchema): activities: List[UserActivitySchema] pagination: PaginationResponseSchema + class UserLoginHistorySchema(BaseSchema): user_login_history_id: int keycloak_email: str @@ -93,3 +95,7 @@ class UserLoginHistorySchema(BaseSchema): class UserLoginHistoryResponseSchema(BaseSchema): histories: List[UserLoginHistorySchema] pagination: PaginationResponseSchema + + +class UpdateNotificationsEmailSchema(BaseSchema): + notifications_email: EmailStr diff --git a/backend/lcfs/web/api/user/services.py b/backend/lcfs/web/api/user/services.py index aad7ed006..48bad17de 100644 --- a/backend/lcfs/web/api/user/services.py +++ b/backend/lcfs/web/api/user/services.py @@ -332,3 +332,17 @@ async def _has_access_to_user_activities( async def track_user_login(self, user: UserProfile): await self.repo.create_login_history(user) + + @service_handler + async def update_notifications_email(self, user_id: int, email: str): + try: + # Update the notifications_email field of the user + return await self.repo.update_notifications_email(user_id, email) + # Return the updated user + return UserBaseSchema.model_validate(user) + except DataNotFoundException as e: + logger.error(f"User not found: {e}") + raise HTTPException(status_code=404, detail=str(e)) + except Exception as e: + logger.error(f"Error updating notifications email: {e}") + raise HTTPException(status_code=500, detail="Internal Server Error") diff --git a/backend/lcfs/web/api/user/views.py b/backend/lcfs/web/api/user/views.py index 54ae147e0..ff1a69aca 100644 --- a/backend/lcfs/web/api/user/views.py +++ b/backend/lcfs/web/api/user/views.py @@ -9,6 +9,7 @@ Response, Depends, Query, + HTTPException, ) from fastapi.responses import StreamingResponse @@ -22,6 +23,7 @@ UserLoginHistoryResponseSchema, UsersSchema, UserActivitiesResponseSchema, + UpdateNotificationsEmailSchema, ) from lcfs.web.api.user.services import UserServices from lcfs.web.core.decorators import view_handler @@ -250,3 +252,21 @@ async def get_all_user_login_history( """ current_user = request.user return await service.get_all_user_login_history(current_user, pagination) + + +@router.post( + "/update-notifications-email", + response_model=UpdateNotificationsEmailSchema, + status_code=status.HTTP_200_OK, +) +@view_handler(["*"]) +async def update_notifications_email( + request: Request, + email_data: UpdateNotificationsEmailSchema = Body(...), + service: UserServices = Depends(), +): + user_id = request.user.user_profile_id + email = email_data.notifications_email + + user = await service.update_notifications_email(user_id, email) + return user diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 62681cd1b..7ae1ab60c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -8,7 +8,7 @@ import { ComplianceReports } from './views/ComplianceReports' import { Dashboard } from './views/Dashboard' import { FileSubmissions } from './views/FileSubmissions' import { FuelCodes, AddFuelCode, ViewFuelCode } from './views/FuelCodes' -import { Notifications, NotificationSettings } from './views/Notifications' +import { NotificationMenu } from '@/views/Notifications/NotificationMenu' import { Organizations, AddEditOrg, @@ -201,13 +201,13 @@ const router = createBrowserRouter([ }, { path: ROUTES.NOTIFICATIONS, - element: , + element: , handle: { title: 'Notifications' } }, { path: ROUTES.NOTIFICATIONS_SETTINGS, - element: , - handle: { title: 'Notification Settings' } + element: , + handle: { title: 'Configure notifications' } }, { path: ROUTES.ADMIN, diff --git a/frontend/src/assets/locales/en/common.json b/frontend/src/assets/locales/en/common.json index 7277d6d16..fa6f2b947 100644 --- a/frontend/src/assets/locales/en/common.json +++ b/frontend/src/assets/locales/en/common.json @@ -42,5 +42,6 @@ "FuelCodes": "Fuel codes", "lcfsEmail": "lcfs@gov.bc.ca", "scrollToTop": "Scroll to top", - "scrollToBottom": "Scroll to bottom" + "scrollToBottom": "Scroll to bottom", + "Notifications": "Notifications" } diff --git a/frontend/src/assets/locales/en/notifications.json b/frontend/src/assets/locales/en/notifications.json new file mode 100644 index 000000000..5f985416c --- /dev/null +++ b/frontend/src/assets/locales/en/notifications.json @@ -0,0 +1,94 @@ +{ + "title": { + "ConfigureNotifications": "Configure notifications", + "Notifications": "Notifications" + }, + "tabs": { + "configuration": "Configuration", + "notifications": "Notifications" + }, + "emailNotification": "Email notification", + "inAppNotification": "In-app notification", + "saveButton": "Save", + "notificationsEmail": "Notifications email", + "messages": { + "subscriptionUpdated": "Subscription updated successfully.", + "emailSaved": "Email address saved successfully." + }, + "errors": { + "invalidEmail": "Please enter a valid email address.", + "emailRequired": "Email address is required.", + "loadingNotificationSettings": "Error loading notification settings.", + "operationFailed": "An error occurred while processing your request. Please try again." + }, + "loading": { + "savingPreferences": "Updating your subscription preferences...", + "notificationSettings": "Loading notification settings..." + }, + "aria": { + "notificationsTabs": "Notifications Tabs" + }, + "bceid": { + "categories": { + "transfers": { + "title": "Transfers", + "partnerActions": "Transfer partner proposed, declined, rescinded or signed & submitted", + "directorDecision": "Director recorded/refused" + }, + "initiativeAgreements": { + "title": "Initiative agreements and other transactions", + "directorApproval": "Director approved" + }, + "complianceReports": { + "title": "Compliance & supplemental reports", + "directorAssessment": "Director assessment" + } + } + }, + "idirAnalyst": { + "categories": { + "transfers": { + "title": "Transfers", + "submittedForReview": "Submitted to government for analyst review", + "rescindedAction": "Rescinded by either transfer partner", + "directorRecorded": "Director recorded/refused" + }, + "initiativeAgreements": { + "title": "Initiative agreements and other transactions", + "returnedToAnalyst": "Director approved/returned to analyst" + }, + "complianceReports": { + "title": "Compliance & supplemental reports", + "submittedForReview": "Submitted to government for analyst review (or returned by compliance manager)", + "managerRecommendation": "Recommended by compliance manager", + "directorDecision": "Director assessment" + } + } + }, + "idirComplianceManager": { + "categories": { + "complianceReports": { + "title": "Compliance & supplemental reports", + "submittedForReview": "Submitted to government for analyst review", + "analystRecommendation": "Analyst recommendation (or returned by the director)", + "directorAssessment": "Director assessment" + } + } + }, + "idirDirector": { + "categories": { + "transfers": { + "title": "Transfers", + "analystRecommendation": "Analyst recommendation" + }, + "initiativeAgreements": { + "title": "Initiative agreements and other transactions", + "analystRecommendation": "Analyst recommendation" + }, + "complianceReports": { + "title": "Compliance & supplemental reports", + "managerRecommendation": "Compliance manager recommendation" + } + } + } +} diff --git a/frontend/src/components/Role.jsx b/frontend/src/components/Role.jsx index b62c14e1b..4f2fb4f84 100644 --- a/frontend/src/components/Role.jsx +++ b/frontend/src/components/Role.jsx @@ -7,10 +7,6 @@ export const Role = ({ children, roles }) => { const isAuthorized = roles?.length > 0 ? roles.some((role) => userRoles.includes(role)) : true - if (!currentUser) { - return
Loading...
- } - if (!isAuthorized) { return null } diff --git a/frontend/src/constants/notificationTypes.js b/frontend/src/constants/notificationTypes.js new file mode 100644 index 000000000..ea7cee8ac --- /dev/null +++ b/frontend/src/constants/notificationTypes.js @@ -0,0 +1,31 @@ +export const notificationTypes = { + // BCEID + BCEID__CR__DIRECTOR_ASSESSMENT: 'BCEID__CR__DIRECTOR_ASSESSMENT', + BCEID__IA__DIRECTOR_APPROVAL: 'BCEID__IA__DIRECTOR_APPROVAL', + BCEID__TR__DIRECTOR_DECISION: 'BCEID__TR__DIRECTOR_DECISION', + BCEID__TR__PARTNER_ACTIONS: 'BCEID__TR__PARTNER_ACTIONS', + + // IDIR ANALYST + IDIR_A__CR__DIRECTOR_DECISION: 'IDIR_A__CR__DIRECTOR_DECISION', + IDIR_A__CR__MANAGER_RECOMMENDATION: 'IDIR_A__CR__MANAGER_RECOMMENDATION', + IDIR_A__CR__SUBMITTED_FOR_REVIEW: 'IDIR_A__CR__SUBMITTED_FOR_REVIEW', + IDIR_A__IA__RETURNED_TO_ANALYST: 'IDIR_A__IA__RETURNED_TO_ANALYST', + IDIR_A__TR__DIRECTOR_RECORDED: 'IDIR_A__TR__DIRECTOR_RECORDED', + IDIR_A__TR__RESCINDED_ACTION: 'IDIR_A__TR__RESCINDED_ACTION', + IDIR_A__TR__SUBMITTED_FOR_REVIEW: 'IDIR_A__TR__SUBMITTED_FOR_REVIEW', + + // IDIR COMPLIANCE_MANAGER + IDIR_CM__CR__ANALYST_RECOMMENDATION: 'IDIR_CM__CR__ANALYST_RECOMMENDATION', + IDIR_CM__CR__DIRECTOR_ASSESSMENT: 'IDIR_CM__CR__DIRECTOR_ASSESSMENT', + IDIR_CM__CR__SUBMITTED_FOR_REVIEW: 'IDIR_CM__CR__SUBMITTED_FOR_REVIEW', + + // IDIR DIRECTOR + IDIR_D__CR__MANAGER_RECOMMENDATION: 'IDIR_D__CR__MANAGER_RECOMMENDATION', + IDIR_D__IA__ANALYST_RECOMMENDATION: 'IDIR_D__IA__ANALYST_RECOMMENDATION', + IDIR_D__TR__ANALYST_RECOMMENDATION: 'IDIR_D__TR__ANALYST_RECOMMENDATION' +} + +export const notificationChannels = { + EMAIL: 'EMAIL', + IN_APP: 'IN_APP' +} diff --git a/frontend/src/constants/routes/apiRoutes.js b/frontend/src/constants/routes/apiRoutes.js index 4dc22657e..6b40b1675 100644 --- a/frontend/src/constants/routes/apiRoutes.js +++ b/frontend/src/constants/routes/apiRoutes.js @@ -71,5 +71,9 @@ export const apiRoutes = { trackUserLogin: '/users/logged-in', getUserLoginHistories: '/users/login-history', getAuditLogs: '/audit-log/list', - getAuditLog: '/audit-log/:auditLogId' + getAuditLog: '/audit-log/:auditLogId', + getNotificationsCount: '/notifications/count', + getNotificationSubscriptions: '/notifications/subscriptions', + saveNotificationSubscriptions: '/notifications/subscriptions/save', + updateNotificationsEmail: '/users/update-notifications-email' } diff --git a/frontend/src/constants/routes/routes.js b/frontend/src/constants/routes/routes.js index 51a4382e4..13d8c8e82 100644 --- a/frontend/src/constants/routes/routes.js +++ b/frontend/src/constants/routes/routes.js @@ -45,7 +45,7 @@ export const REPORTS_ADD_OTHER_USE_FUELS = `${REPORTS_VIEW}/fuels-other-use` export const REPORTS_ADD_FUEL_EXPORTS = `${REPORTS_VIEW}/fuel-exports` export const NOTIFICATIONS = '/notifications' -export const NOTIFICATIONS_SETTINGS = `${NOTIFICATIONS}/settings` +export const NOTIFICATIONS_SETTINGS = `${NOTIFICATIONS}/configure` export const ADMIN = '/admin' export const ADMIN_USERS = `${ADMIN}/users` diff --git a/frontend/src/hooks/useNotifications.js b/frontend/src/hooks/useNotifications.js new file mode 100644 index 000000000..87b286a87 --- /dev/null +++ b/frontend/src/hooks/useNotifications.js @@ -0,0 +1,77 @@ +import { apiRoutes } from '@/constants/routes' +import { useApiService } from '@/services/useApiService' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' + +export const useNotificationsCount = (options) => { + const client = useApiService() + return useQuery({ + queryKey: ['notifications-count'], + queryFn: async () => { + const response = await client.get(apiRoutes.getNotificationsCount) + return response.data + }, + staleTime: 1 * 60 * 1000, // 1 minute + cacheTime: 5 * 60 * 1000, // 5 minutes + refetchInterval: 1 * 60 * 1000, // 1 minute + ...options + }) +} + +export const useNotificationSubscriptions = (options) => { + const client = useApiService() + return useQuery({ + queryKey: ['notification-subscriptions'], + queryFn: async () => { + try { + const response = await client.get( + apiRoutes.getNotificationSubscriptions + ) + return response.data + } catch (error) { + if (error.response && error.response.status === 404) { + // Return an empty array if 404 is returned + return [] + } + throw error + } + }, + ...options + }) +} + +export const useCreateSubscription = (options) => { + const client = useApiService() + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (data) => + client.post(apiRoutes.saveNotificationSubscriptions, data), + onSuccess: () => { + queryClient.invalidateQueries(['notification-subscriptions']) + }, + ...options + }) +} + +export const useDeleteSubscription = (options) => { + const client = useApiService() + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (subscriptionId) => + client.post(apiRoutes.saveNotificationSubscriptions, { + notificationChannelSubscriptionId: subscriptionId, + deleted: true + }), + onSuccess: () => { + queryClient.invalidateQueries(['notification-subscriptions']) + }, + ...options + }) +} + +export const useUpdateNotificationsEmail = (options) => { + const client = useApiService() + return useMutation({ + mutationFn: (data) => client.post(apiRoutes.updateNotificationsEmail, data), + ...options + }) +} diff --git a/frontend/src/i18n.js b/frontend/src/i18n.js index 296fe8fd9..5eed4a07a 100644 --- a/frontend/src/i18n.js +++ b/frontend/src/i18n.js @@ -17,6 +17,7 @@ import fuelSupplyEn from '@/assets/locales/en/fuelSupply.json' import fuelExportEn from '@/assets/locales/en/fuelExport.json' import dashboardEn from '@/assets/locales/en/dashboard.json' import allocationAgreementEn from '@/assets/locales/en/allocationAgreement.json' +import notificationsEn from '@/assets/locales/en/notifications.json' // manage translations separated from your code: https://react.i18next.com/guides/multiple-translation-files) const resources = { @@ -38,7 +39,8 @@ const resources = { fuelSupply: fuelSupplyEn, fuelExport: fuelExportEn, dashboard: dashboardEn, - allocationAgreement: allocationAgreementEn + allocationAgreement: allocationAgreementEn, + notifications: notificationsEn } } diff --git a/frontend/src/layouts/MainLayout/components/Logout.jsx b/frontend/src/layouts/MainLayout/components/Logout.jsx deleted file mode 100644 index cc5fd5c22..000000000 --- a/frontend/src/layouts/MainLayout/components/Logout.jsx +++ /dev/null @@ -1,43 +0,0 @@ -import { logout } from '@/utils/keycloak' -import { useKeycloak } from '@react-keycloak/web' -import { useTranslation } from 'react-i18next' -// @mui components -import BCBox from '@/components/BCBox' -import BCButton from '@/components/BCButton' -import BCTypography from '@/components/BCTypography' - -import { useCurrentUser } from '@/hooks/useCurrentUser' - -export const Logout = () => { - const { t } = useTranslation() - const { data: currentUser } = useCurrentUser() - const { keycloak } = useKeycloak() - - return ( - keycloak.authenticated && ( - - {currentUser?.firstName && ( - - {currentUser?.firstName + ' ' + currentUser?.lastName} - - )} - { - logout() - }} - color="light" - size="small" - variant="outlined" - data-test="logout-button" - > - {t('logout')} - - - ) - ) -} diff --git a/frontend/src/layouts/MainLayout/components/Navbar.jsx b/frontend/src/layouts/MainLayout/components/Navbar.jsx index fca17b941..ab55f9aa7 100644 --- a/frontend/src/layouts/MainLayout/components/Navbar.jsx +++ b/frontend/src/layouts/MainLayout/components/Navbar.jsx @@ -5,7 +5,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { HeaderComponent } from './HeaderComponent' -import { Logout } from './Logout' +import { UserProfileActions } from './UserProfileActions' import { useMediaQuery, useTheme } from '@mui/material' export const Navbar = () => { @@ -36,7 +36,10 @@ export const Navbar = () => { { name: t('ComplianceReporting'), route: ROUTES.REPORTS }, { name: t('Organization'), route: ROUTES.ORGANIZATION } ] - const mobileRoutes = [{ name: t('logout'), route: ROUTES.LOG_OUT }] + const mobileRoutes = [ + { name: t('Notifications'), route: ROUTES.NOTIFICATIONS }, + { name: t('logout'), route: ROUTES.LOG_OUT } + ] const activeRoutes = currentUser?.isGovernmentUser ? idirRoutes @@ -55,7 +58,7 @@ export const Navbar = () => { beta={true} data-test="main-layout-navbar" headerRightPart={} - menuRightPart={} + menuRightPart={} /> ) } diff --git a/frontend/src/layouts/MainLayout/components/UserProfileActions.jsx b/frontend/src/layouts/MainLayout/components/UserProfileActions.jsx new file mode 100644 index 000000000..bea4ace6c --- /dev/null +++ b/frontend/src/layouts/MainLayout/components/UserProfileActions.jsx @@ -0,0 +1,122 @@ +import { useEffect } from 'react' +import { logout } from '@/utils/keycloak' +import { useKeycloak } from '@react-keycloak/web' +import { useTranslation } from 'react-i18next' +import BCBox from '@/components/BCBox' +import BCButton from '@/components/BCButton' +import BCTypography from '@/components/BCTypography' +import { useCurrentUser } from '@/hooks/useCurrentUser' +import { useNotificationsCount } from '@/hooks/useNotifications' +import { + Badge, + IconButton, + Divider, + CircularProgress, + Tooltip +} from '@mui/material' +import NotificationsIcon from '@mui/icons-material/Notifications' +import { useNavigate, useLocation } from 'react-router-dom' +import { ROUTES } from '@/constants/routes' + +export const UserProfileActions = () => { + const { t } = useTranslation() + const { data: currentUser } = useCurrentUser() + const { keycloak } = useKeycloak() + const navigate = useNavigate() + const location = useLocation() + + // TODO: + // Automatically refetch every 1 minute (60000ms) for real-time updates. + // Alternatively, for better efficiency and scalability, consider implementing + // server-side push mechanisms (e.g., WebSockets, Server-Sent Events) to notify + // the client of updates as they occur, reducing unnecessary polling. + const { + data: notificationsData, + isLoading, + refetch + } = useNotificationsCount({ + refetchInterval: 60000 // Automatically refetch every 1 minute (60000ms) + }) + const notificationsCount = notificationsData?.count || 0 + console.log(notificationsData) + + // Call refetch whenever the route changes + useEffect(() => { + refetch() + }, [location, refetch]) + + return ( + keycloak.authenticated && ( + + {currentUser?.firstName && ( + <> + + {`${currentUser.firstName} ${currentUser.lastName}`} + + ({ + backgroundColor: secondary.main, + height: '60%', + alignSelf: 'center', + marginLeft: 1, + marginRight: 1 + })} + /> + + )} + <> + {isLoading ? ( + + ) : ( + + navigate(ROUTES.NOTIFICATIONS)} + aria-label={t('Notifications')} + > + 0 ? notificationsCount : null + } + color="error" + > + + + + + )} + ({ + backgroundColor: secondary.main, + height: '60%', + alignSelf: 'center', + marginLeft: 1, + marginRight: 3 + })} + /> + + + {t('logout')} + + + ) + ) +} diff --git a/frontend/src/views/Notifications/NotificationMenu/NotificationMenu.jsx b/frontend/src/views/Notifications/NotificationMenu/NotificationMenu.jsx new file mode 100644 index 000000000..a6c2974de --- /dev/null +++ b/frontend/src/views/Notifications/NotificationMenu/NotificationMenu.jsx @@ -0,0 +1,76 @@ +import BCBox from '@/components/BCBox' +import { + NOTIFICATIONS, + NOTIFICATIONS_SETTINGS +} from '@/constants/routes/routes' +import breakpoints from '@/themes/base/breakpoints' +import { NotificationTabPanel } from './components/NotificationTabPanel' +import { AppBar, Tab, Tabs } from '@mui/material' +import { PropTypes } from 'prop-types' +import { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router-dom' +import { Notifications, NotificationSettings } from '.' + +function a11yProps(index) { + return { + id: `full-width-tab-${index}`, + 'aria-controls': `full-width-notifications-tabs-${index}` + } +} + +export function NotificationMenu({ tabIndex }) { + const { t } = useTranslation(['notifications']) + const [tabsOrientation, setTabsOrientation] = useState('horizontal') + const navigate = useNavigate() + const paths = useMemo(() => [NOTIFICATIONS, NOTIFICATIONS_SETTINGS], []) + + useEffect(() => { + // A function that sets the orientation state of the tabs. + function handleTabsOrientation() { + return window.innerWidth < breakpoints.values.lg + ? setTabsOrientation('vertical') + : setTabsOrientation('horizontal') + } + + // The event listener that's calling the handleTabsOrientation function when resizing the window. + window.addEventListener('resize', handleTabsOrientation) + + // Call the handleTabsOrientation function to set the state with the initial value. + handleTabsOrientation() + + // Remove event listener on cleanup + return () => window.removeEventListener('resize', handleTabsOrientation) + }, [tabsOrientation]) + + const handleSetTabValue = (event, newValue) => { + navigate(paths[newValue]) + } + + return ( + + + + + + + + + + + + + + + ) +} + +NotificationMenu.propTypes = { + tabIndex: PropTypes.number.isRequired +} diff --git a/frontend/src/views/Notifications/NotificationMenu/components/BCeIDNotificationSettings.jsx b/frontend/src/views/Notifications/NotificationMenu/components/BCeIDNotificationSettings.jsx new file mode 100644 index 000000000..35d566455 --- /dev/null +++ b/frontend/src/views/Notifications/NotificationMenu/components/BCeIDNotificationSettings.jsx @@ -0,0 +1,36 @@ +import { useCurrentUser } from '@/hooks/useCurrentUser' +import NotificationSettingsForm from './NotificationSettingsForm' + +const BCeIDNotificationSettings = () => { + const { data: currentUser } = useCurrentUser() + + // Categories for BCeID users + const categories = { + 'bceid.categories.transfers': { + title: 'bceid.categories.transfers.title', + BCEID__TR__PARTNER_ACTIONS: 'bceid.categories.transfers.partnerActions', + BCEID__TR__DIRECTOR_DECISION: + 'bceid.categories.transfers.directorDecision' + }, + 'bceid.categories.initiativeAgreements': { + title: 'bceid.categories.initiativeAgreements.title', + BCEID__IA__DIRECTOR_APPROVAL: + 'bceid.categories.initiativeAgreements.directorApproval' + }, + 'bceid.categories.complianceReports': { + title: 'bceid.categories.complianceReports.title', + BCEID__CR__DIRECTOR_ASSESSMENT: + 'bceid.categories.complianceReports.directorAssessment' + } + } + + return ( + + ) +} + +export default BCeIDNotificationSettings diff --git a/frontend/src/views/Notifications/NotificationMenu/components/IDIRAnalystNotificationSettings.jsx b/frontend/src/views/Notifications/NotificationMenu/components/IDIRAnalystNotificationSettings.jsx new file mode 100644 index 000000000..cb3403e52 --- /dev/null +++ b/frontend/src/views/Notifications/NotificationMenu/components/IDIRAnalystNotificationSettings.jsx @@ -0,0 +1,34 @@ +import NotificationSettingsForm from './NotificationSettingsForm' + +const IDIRAnalystNotificationSettings = () => { + // Categories for IDIR Analyst + const categories = { + 'idirAnalyst.categories.transfers': { + title: 'idirAnalyst.categories.transfers.title', + IDIR_A__TR__SUBMITTED_FOR_REVIEW: + 'idirAnalyst.categories.transfers.submittedForReview', + IDIR_A__TR__RESCINDED_ACTION: + 'idirAnalyst.categories.transfers.rescindedAction', + IDIR_A__TR__DIRECTOR_RECORDED: + 'idirAnalyst.categories.transfers.directorRecorded' + }, + 'idirAnalyst.categories.initiativeAgreements': { + title: 'idirAnalyst.categories.initiativeAgreements.title', + IDIR_A__IA__RETURNED_TO_ANALYST: + 'idirAnalyst.categories.initiativeAgreements.returnedToAnalyst' + }, + 'idirAnalyst.categories.complianceReports': { + title: 'idirAnalyst.categories.complianceReports.title', + IDIR_A__CR__SUBMITTED_FOR_REVIEW: + 'idirAnalyst.categories.complianceReports.submittedForReview', + IDIR_A__CR__MANAGER_RECOMMENDATION: + 'idirAnalyst.categories.complianceReports.managerRecommendation', + IDIR_A__CR__DIRECTOR_DECISION: + 'idirAnalyst.categories.complianceReports.directorDecision' + } + } + + return +} + +export default IDIRAnalystNotificationSettings diff --git a/frontend/src/views/Notifications/NotificationMenu/components/IDIRComplianceManagerNotificationSettings.jsx b/frontend/src/views/Notifications/NotificationMenu/components/IDIRComplianceManagerNotificationSettings.jsx new file mode 100644 index 000000000..91be06073 --- /dev/null +++ b/frontend/src/views/Notifications/NotificationMenu/components/IDIRComplianceManagerNotificationSettings.jsx @@ -0,0 +1,20 @@ +import NotificationSettingsForm from './NotificationSettingsForm' + +const IDIRComplianceManagerNotificationSettings = () => { + // Categories for IDIR Compliance Manager + const categories = { + 'idirComplianceManager.categories.complianceReports': { + title: 'idirComplianceManager.categories.complianceReports.title', + IDIR_CM__CR__SUBMITTED_FOR_REVIEW: + 'idirComplianceManager.categories.complianceReports.submittedForReview', + IDIR_CM__CR__ANALYST_RECOMMENDATION: + 'idirComplianceManager.categories.complianceReports.analystRecommendation', + IDIR_CM__CR__DIRECTOR_ASSESSMENT: + 'idirComplianceManager.categories.complianceReports.directorAssessment' + } + } + + return +} + +export default IDIRComplianceManagerNotificationSettings diff --git a/frontend/src/views/Notifications/NotificationMenu/components/IDIRDirectorNotificationSettings.jsx b/frontend/src/views/Notifications/NotificationMenu/components/IDIRDirectorNotificationSettings.jsx new file mode 100644 index 000000000..6f9c6148b --- /dev/null +++ b/frontend/src/views/Notifications/NotificationMenu/components/IDIRDirectorNotificationSettings.jsx @@ -0,0 +1,26 @@ +import NotificationSettingsForm from './NotificationSettingsForm' + +const IDIRDirectorNotificationSettings = () => { + // Categories for IDIR Director + const categories = { + 'idirDirector.categories.transfers': { + title: 'idirDirector.categories.transfers.title', + IDIR_D__TR__ANALYST_RECOMMENDATION: + 'idirDirector.categories.transfers.analystRecommendation' + }, + 'idirDirector.categories.initiativeAgreements': { + title: 'idirDirector.categories.initiativeAgreements.title', + IDIR_D__IA__ANALYST_RECOMMENDATION: + 'idirDirector.categories.initiativeAgreements.analystRecommendation' + }, + 'idirDirector.categories.complianceReports': { + title: 'idirDirector.categories.complianceReports.title', + IDIR_D__CR__MANAGER_RECOMMENDATION: + 'idirDirector.categories.complianceReports.managerRecommendation' + } + } + + return +} + +export default IDIRDirectorNotificationSettings diff --git a/frontend/src/views/Notifications/NotificationMenu/components/NotificationSettings.jsx b/frontend/src/views/Notifications/NotificationMenu/components/NotificationSettings.jsx new file mode 100644 index 000000000..17e541aca --- /dev/null +++ b/frontend/src/views/Notifications/NotificationMenu/components/NotificationSettings.jsx @@ -0,0 +1,34 @@ +import { roles } from '@/constants/roles' +import { Role } from '@/components/Role' + +// Import user-type-specific components +import BCeIDNotificationSettings from './BCeIDNotificationSettings' +import IDIRAnalystNotificationSettings from './IDIRAnalystNotificationSettings' +import IDIRComplianceManagerNotificationSettings from './IDIRComplianceManagerNotificationSettings' +import IDIRDirectorNotificationSettings from './IDIRDirectorNotificationSettings' + +export const NotificationSettings = () => { + return ( + <> + {/* BCeID User */} + + + + + {/* IDIR Director */} + + + + + {/* IDIR Compliance Manager */} + + + + + {/* IDIR Analyst */} + + + + + ) +} diff --git a/frontend/src/views/Notifications/NotificationMenu/components/NotificationSettingsForm.jsx b/frontend/src/views/Notifications/NotificationMenu/components/NotificationSettingsForm.jsx new file mode 100644 index 000000000..ad29117d9 --- /dev/null +++ b/frontend/src/views/Notifications/NotificationMenu/components/NotificationSettingsForm.jsx @@ -0,0 +1,465 @@ +import React, { useEffect, useState } from 'react' +import PropTypes from 'prop-types' +import { useForm, FormProvider, Controller } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import { + Checkbox, + TextField, + Table, + TableBody, + TableCell, + TableContainer, + TableRow, + InputLabel +} from '@mui/material' +import { yupResolver } from '@hookform/resolvers/yup' +import * as Yup from 'yup' +import Loading from '@/components/Loading' +import { + useNotificationSubscriptions, + useCreateSubscription, + useDeleteSubscription, + useUpdateNotificationsEmail +} from '@/hooks/useNotifications' +import { + notificationTypes, + notificationChannels +} from '@/constants/notificationTypes' +import { useNavigate } from 'react-router-dom' +import { ROUTES } from '@/constants/routes' +import MailIcon from '@mui/icons-material/Mail' +import NotificationsIcon from '@mui/icons-material/Notifications' +import BCButton from '@/components/BCButton' +import BCBox from '@/components/BCBox' +import BCAlert from '@/components/BCAlert' +import BCTypography from '@/components/BCTypography' + +const NotificationSettingsForm = ({ + categories, + showEmailField, + initialEmail +}) => { + const { t } = useTranslation(['notifications']) + const navigate = useNavigate() + const [isFormLoading, setIsFormLoading] = useState(false) + const [message, setMessage] = useState('') + const { data: subscriptionsData, isLoading: isSubscriptionsLoading } = + useNotificationSubscriptions() + const createSubscription = useCreateSubscription() + const deleteSubscription = useDeleteSubscription() + const updateNotificationsEmail = useUpdateNotificationsEmail() + + // Validation schema + const validationSchema = Yup.object().shape({ + ...(showEmailField && { + notificationEmail: Yup.string() + .email(t('errors.invalidEmail')) + .required(t('errors.emailRequired')) + }) + }) + + const formMethods = useForm({ + resolver: yupResolver(validationSchema), + mode: 'onChange' + }) + + const { handleSubmit, control, setValue } = formMethods + + useEffect(() => { + if (subscriptionsData) { + subscriptionsData.forEach((subscription) => { + const { notificationTypeKey, notificationChannelKey, isEnabled } = + subscription + const fieldName = `${notificationTypeKey}_${notificationChannelKey}` + setValue(fieldName, isEnabled) + }) + } + if (showEmailField && initialEmail) { + setValue('notificationEmail', initialEmail) + } + }, [subscriptionsData, showEmailField, initialEmail, setValue]) + + const handleCheckboxChange = async ( + notificationTypeKey, + notificationChannelKey, + isChecked + ) => { + setIsFormLoading(true) + setMessage('') // Clear any existing messages + try { + if (isChecked) { + // Create the subscription + await createSubscription.mutateAsync({ + notificationTypeKey, + notificationChannelKey, + isEnabled: true + }) + } else { + // Find the subscription ID + const subscription = subscriptionsData.find( + (sub) => + sub.notificationTypeKey === notificationTypeKey && + sub.notificationChannelKey === notificationChannelKey + ) + if (subscription) { + // Delete the subscription + await deleteSubscription.mutateAsync( + subscription.notificationChannelSubscriptionId + ) + } + } + setMessage(t('messages.subscriptionUpdated')) + } catch (error) { + setMessage(t('errors.operationFailed')) + } finally { + setIsFormLoading(false) + } + } + + const onSubmit = async (data) => { + setIsFormLoading(true) + setMessage('') // Clear any existing messages + try { + if (showEmailField) { + // BCeID user, save the email address + await updateNotificationsEmail.mutateAsync({ + notifications_email: data.notificationEmail + }) + setMessage(t('messages.emailSaved')) + } + + if (!showEmailField) { + navigate(ROUTES.NOTIFICATIONS) + } + } catch (err) { + setMessage(t('errors.operationFailed')) + } finally { + setIsFormLoading(false) + } + } + + if (isSubscriptionsLoading) { + return + } + + return ( + + {isFormLoading && ( + + )} + + + {t('title.ConfigureNotifications')} + + + + + + {/* Email Notification */} + + + + {t('emailNotification')} + + + + {/* In-App Notification */} + + + + {t('inAppNotification')} + + + + + +
+ + + + {Object.entries(categories).map( + ([categoryKey, category], index) => ( + + {/* Category Header */} + + + + + + + + + + {t(`${categoryKey}.title`)} + + + + {/* Notifications */} + {Object.entries(category) + .filter(([key]) => key !== 'title') + .map(([notificationTypeName, notificationLabelKey]) => { + const notificationTypeKey = notificationTypeName + const notificationTypeId = + notificationTypes[notificationTypeName] + if (!notificationTypeId) return null + + return ( + + {/* Email Checkbox */} + + ( + { + field.onChange(e) + handleCheckboxChange( + notificationTypeKey, + notificationChannels.EMAIL, + e.target.checked + ) + }} + color="primary" + disabled={isFormLoading} // Disable during loading + /> + )} + /> + + {/* In-App Checkbox */} + + ( + { + field.onChange(e) + handleCheckboxChange( + notificationTypeKey, + notificationChannels.IN_APP, + e.target.checked + ) + }} + color="primary" + disabled={isFormLoading} // Disable during loading + /> + )} + /> + + {/* Label */} + + {t(notificationLabelKey)} + + + ) + })} + + ) + )} + {/* Submit Button and Email Field */} + + + + {t('saveButton')} + + + + {showEmailField && ( + + + {t('notificationsEmail')}: + + ( + + )} + /> + + )} + + + +
+
+
+
+ + {/* Success/Error Message */} + {message && ( + + + {message} + + + )} +
+ ) +} + +NotificationSettingsForm.propTypes = { + categories: PropTypes.object.isRequired, + showEmailField: PropTypes.bool, + initialEmail: PropTypes.string +} + +NotificationSettingsForm.defaultProps = { + showEmailField: false, + initialEmail: '' +} + +export default NotificationSettingsForm diff --git a/frontend/src/views/Notifications/NotificationMenu/components/NotificationTabPanel.jsx b/frontend/src/views/Notifications/NotificationMenu/components/NotificationTabPanel.jsx new file mode 100644 index 000000000..7c1669dd2 --- /dev/null +++ b/frontend/src/views/Notifications/NotificationMenu/components/NotificationTabPanel.jsx @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types' +import BCBox from '@/components/BCBox' + +export function NotificationTabPanel(props) { + const { children, value, index, ...other } = props + + return ( + + ) +} + +NotificationTabPanel.propTypes = { + children: PropTypes.node, + index: PropTypes.number.isRequired, + value: PropTypes.number.isRequired +} diff --git a/frontend/src/views/Notifications/NotificationMenu/components/Notifications.jsx b/frontend/src/views/Notifications/NotificationMenu/components/Notifications.jsx new file mode 100644 index 000000000..13d413031 --- /dev/null +++ b/frontend/src/views/Notifications/NotificationMenu/components/Notifications.jsx @@ -0,0 +1,14 @@ +import { useTranslation } from 'react-i18next' +import BCTypography from '@/components/BCTypography' + +export const Notifications = () => { + const { t } = useTranslation(['notifications']) + + return ( + <> + + {t('title.Notifications')} + + + ) +} diff --git a/frontend/src/views/Notifications/NotificationMenu/components/__tests__/NotificationSettingsForm.test.jsx b/frontend/src/views/Notifications/NotificationMenu/components/__tests__/NotificationSettingsForm.test.jsx new file mode 100644 index 000000000..60f2e8f7a --- /dev/null +++ b/frontend/src/views/Notifications/NotificationMenu/components/__tests__/NotificationSettingsForm.test.jsx @@ -0,0 +1,153 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import NotificationSettingsForm from '../NotificationSettingsForm' +import { ThemeProvider } from '@mui/material/styles' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import theme from '@/themes' +import * as useNotificationsHook from '@/hooks/useNotifications' +import { MemoryRouter } from 'react-router-dom' + +// Updated mock translation function with meaningful translations +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key) => { + const translations = { + notificationsEmail: 'Notifications Email', + 'title.ConfigureNotifications': 'Configure Notifications', + 'general.title': 'General Notifications', + 'notifications:notificationType1': 'Notification Type 1', + 'notifications:notificationType2': 'Notification Type 2', + saveButton: 'Save', + emailNotification: 'Email Notification', + inAppNotification: 'In-App Notification' + // Add other translations as needed + } + return translations[key] || key + }, + i18n: { language: 'en' } + }) +})) + +vi.mock('@/constants/notificationTypes', () => ({ + notificationTypes: { + notificationType1: 'NOTIFICATION_TYPE_1', + notificationType2: 'NOTIFICATION_TYPE_2' + }, + notificationChannels: { + EMAIL: 'EMAIL', + IN_APP: 'IN_APP' + } +})) + +const customRender = (ui, options = {}) => { + const queryClient = new QueryClient() + const AllTheProviders = ({ children }) => ( + + + {children} + + + ) + + return render(ui, { wrapper: AllTheProviders, ...options }) +} + +describe('NotificationSettingsForm Component', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders correctly', () => { + // Mock data + const categories = { + general: { + title: 'general.title', + notificationType1: 'notifications:notificationType1', + notificationType2: 'notifications:notificationType2' + } + } + + const subscriptionsData = [ + { + notificationTypeKey: 'notificationType1', + notificationChannelKey: 'EMAIL', + isEnabled: true, + notificationChannelSubscriptionId: '1' + }, + { + notificationTypeKey: 'notificationType2', + notificationChannelKey: 'IN_APP', + isEnabled: true, + notificationChannelSubscriptionId: '2' + } + ] + + vi.spyOn( + useNotificationsHook, + 'useNotificationSubscriptions' + ).mockReturnValue({ + data: subscriptionsData, + isLoading: false + }) + + vi.spyOn(useNotificationsHook, 'useCreateSubscription').mockReturnValue({ + mutateAsync: vi.fn() + }) + + vi.spyOn(useNotificationsHook, 'useDeleteSubscription').mockReturnValue({ + mutateAsync: vi.fn() + }) + + vi.spyOn( + useNotificationsHook, + 'useUpdateNotificationsEmail' + ).mockReturnValue({ + mutateAsync: vi.fn() + }) + + customRender() + + expect(screen.getByText('Configure Notifications')).toBeInTheDocument() + expect(screen.getByText('General Notifications')).toBeInTheDocument() + expect(screen.getByText('Notification Type 1')).toBeInTheDocument() + expect(screen.getByText('Notification Type 2')).toBeInTheDocument() + }) + + it('handles checkbox changes correctly', async () => { + const categories = { + general: { + title: 'general.title', + notificationType1: 'notifications:notificationType1' + } + } + + const subscriptionsData = [] + + const mockMutateAsync = vi.fn() + + vi.spyOn( + useNotificationsHook, + 'useNotificationSubscriptions' + ).mockReturnValue({ + data: subscriptionsData, + isLoading: false + }) + + vi.spyOn(useNotificationsHook, 'useCreateSubscription').mockReturnValue({ + mutateAsync: mockMutateAsync + }) + + vi.spyOn(useNotificationsHook, 'useDeleteSubscription').mockReturnValue({ + mutateAsync: mockMutateAsync + }) + + customRender() + + const checkbox = screen.getAllByRole('checkbox')[0] + fireEvent.click(checkbox) + + await waitFor(() => { + expect(mockMutateAsync).toHaveBeenCalled() + }) + }) +}) diff --git a/frontend/src/views/Notifications/NotificationMenu/components/__tests__/NotificationTabPanel.test.jsx b/frontend/src/views/Notifications/NotificationMenu/components/__tests__/NotificationTabPanel.test.jsx new file mode 100644 index 000000000..7fc65a438 --- /dev/null +++ b/frontend/src/views/Notifications/NotificationMenu/components/__tests__/NotificationTabPanel.test.jsx @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { NotificationTabPanel } from '../NotificationTabPanel' +import { ThemeProvider } from '@mui/material/styles' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import theme from '@/themes' + +// Custom render function with all necessary providers +const customRender = (ui, options = {}) => { + const queryClient = new QueryClient() + const AllTheProviders = ({ children }) => ( + + {children} + + ) + + return render(ui, { wrapper: AllTheProviders, ...options }) +} + +describe('NotificationTabPanel Component', () => { + it('renders children when selected', () => { + customRender( + +
Test Content
+
+ ) + expect(screen.getByText('Test Content')).toBeInTheDocument() + }) + + it('does not render children when not selected', () => { + customRender( + +
Test Content
+
+ ) + expect(screen.queryByText('Test Content')).not.toBeInTheDocument() + }) + + it('has correct ARIA attributes', () => { + customRender( + +
Test Content
+
+ ) + const panel = screen.getByRole('NotificationTabPanel') + expect(panel).toHaveAttribute('aria-labelledby', 'full-width-tab-0') + expect(panel).toHaveAttribute('id', 'full-width-NotificationTabPanel-0') + }) +}) diff --git a/frontend/src/views/Notifications/NotificationMenu/index.js b/frontend/src/views/Notifications/NotificationMenu/index.js new file mode 100644 index 000000000..2b919a36b --- /dev/null +++ b/frontend/src/views/Notifications/NotificationMenu/index.js @@ -0,0 +1,3 @@ +export { NotificationMenu } from './NotificationMenu' +export { Notifications } from './components/Notifications' +export { NotificationSettings } from './components/NotificationSettings' diff --git a/frontend/src/views/Notifications/NotificationSettings/NotificationSettings.jsx b/frontend/src/views/Notifications/NotificationSettings/NotificationSettings.jsx deleted file mode 100644 index 150188e4a..000000000 --- a/frontend/src/views/Notifications/NotificationSettings/NotificationSettings.jsx +++ /dev/null @@ -1,3 +0,0 @@ -export const NotificationSettings = () => { - return
NotificationSettings
-} diff --git a/frontend/src/views/Notifications/Notifications.jsx b/frontend/src/views/Notifications/Notifications.jsx deleted file mode 100644 index 5841ee3c7..000000000 --- a/frontend/src/views/Notifications/Notifications.jsx +++ /dev/null @@ -1,3 +0,0 @@ -export const Notifications = () => { - return
Notifications
-} diff --git a/frontend/src/views/Notifications/__tests__/NotificationSettings.test.jsx b/frontend/src/views/Notifications/__tests__/NotificationSettings.test.jsx deleted file mode 100644 index 1d74ce903..000000000 --- a/frontend/src/views/Notifications/__tests__/NotificationSettings.test.jsx +++ /dev/null @@ -1 +0,0 @@ -describe.todo() diff --git a/frontend/src/views/Notifications/__tests__/Notifications.test.jsx b/frontend/src/views/Notifications/__tests__/Notifications.test.jsx deleted file mode 100644 index 1d74ce903..000000000 --- a/frontend/src/views/Notifications/__tests__/Notifications.test.jsx +++ /dev/null @@ -1 +0,0 @@ -describe.todo() diff --git a/frontend/src/views/Notifications/index.js b/frontend/src/views/Notifications/index.js deleted file mode 100644 index fca16690f..000000000 --- a/frontend/src/views/Notifications/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export { Notifications } from './Notifications' -export { NotificationSettings } from './NotificationSettings/NotificationSettings' From 4988100c05e804d4c5649097013d48b9f46daa84 Mon Sep 17 00:00:00 2001 From: Hamed Valiollahi Bayeki Date: Mon, 2 Dec 2024 14:40:20 -0800 Subject: [PATCH 2/5] fix: correct migration order, update seeders, fix failing tests, and remove extra code --- ...35.py => 2024-11-29-02-19_b69a33bbd135.py} | 4 +- .../common/notification_type_seeder.py | 127 ------------------ .../db/seeders/common/notifications_seeder.py | 111 ++++++++++++--- backend/lcfs/db/seeders/common_seeder.py | 1 - .../notification/test_notification_repo.py | 97 ++++++------- backend/lcfs/web/api/notification/repo.py | 23 +--- backend/lcfs/web/api/notification/services.py | 26 ---- backend/lcfs/web/api/notification/views.py | 3 +- frontend/src/App.jsx | 1 - 9 files changed, 145 insertions(+), 248 deletions(-) rename backend/lcfs/db/migrations/versions/{2024-11-27-20-48_b69a33bbd135.py => 2024-11-29-02-19_b69a33bbd135.py} (93%) delete mode 100644 backend/lcfs/db/seeders/common/notification_type_seeder.py diff --git a/backend/lcfs/db/migrations/versions/2024-11-27-20-48_b69a33bbd135.py b/backend/lcfs/db/migrations/versions/2024-11-29-02-19_b69a33bbd135.py similarity index 93% rename from backend/lcfs/db/migrations/versions/2024-11-27-20-48_b69a33bbd135.py rename to backend/lcfs/db/migrations/versions/2024-11-29-02-19_b69a33bbd135.py index 4437a4e23..5e4a9a538 100644 --- a/backend/lcfs/db/migrations/versions/2024-11-27-20-48_b69a33bbd135.py +++ b/backend/lcfs/db/migrations/versions/2024-11-29-02-19_b69a33bbd135.py @@ -1,7 +1,7 @@ """Add notifications email to user_profile Revision ID: b69a33bbd135 -Revises: 0775a141d335 +Revises: b4da565bb711 Create Date: 2024-11-27 20:48:24.724112 """ @@ -11,7 +11,7 @@ # revision identifiers, used by Alembic. revision = "b69a33bbd135" -down_revision = "0775a141d335" +down_revision = "b4da565bb711" branch_labels = None depends_on = None diff --git a/backend/lcfs/db/seeders/common/notification_type_seeder.py b/backend/lcfs/db/seeders/common/notification_type_seeder.py deleted file mode 100644 index 367d8ac0c..000000000 --- a/backend/lcfs/db/seeders/common/notification_type_seeder.py +++ /dev/null @@ -1,127 +0,0 @@ -import structlog -from sqlalchemy import select -from lcfs.db.models.notification.NotificationType import ( - NotificationType, -) - -logger = structlog.get_logger(__name__) - - -async def seed_notification_types(session): - """ - Seeds the notification types into the database. - - Args: - session: The database session for committing the new records. - """ - notification_types_to_seed = [ - { - "notification_type_id": 1, - "name": "BCEID__CR__DIRECTOR_ASSESSMENT", - "description": "Director assessment", - }, - { - "notification_type_id": 2, - "name": "BCEID__IA__DIRECTOR_APPROVAL", - "description": "Director approved", - }, - { - "notification_type_id": 3, - "name": "BCEID__TR__DIRECTOR_DECISION", - "description": "Director recorded/refused", - }, - { - "notification_type_id": 4, - "name": "BCEID__TR__PARTNER_ACTIONS", - "description": "Transfer partner proposed, declined, rescinded or signed & submitted", - }, - { - "notification_type_id": 5, - "name": "IDIR_A__CR__DIRECTOR_DECISION", - "description": "Director assessment", - }, - { - "notification_type_id": 6, - "name": "IDIR_A__CR__MANAGER_RECOMMENDATION", - "description": "Recommended by compliance manager", - }, - { - "notification_type_id": 7, - "name": "IDIR_A__CR__SUBMITTED_FOR_REVIEW", - "description": "Submitted to government for analyst review (or returned by compliance manager)", - }, - { - "notification_type_id": 8, - "name": "IDIR_A__IA__RETURNED_TO_ANALYST", - "description": "Director approved/returned to analyst", - }, - { - "notification_type_id": 9, - "name": "IDIR_A__TR__DIRECTOR_RECORDED", - "description": "Director recorded/refused", - }, - { - "notification_type_id": 10, - "name": "IDIR_A__TR__RESCINDED_ACTION", - "description": "Rescinded by either transfer partner", - }, - { - "notification_type_id": 11, - "name": "IDIR_A__TR__SUBMITTED_FOR_REVIEW", - "description": "Submitted to government for analyst review", - }, - { - "notification_type_id": 12, - "name": "IDIR_CM__CR__ANALYST_RECOMMENDATION", - "description": "Analyst recommendation (or returned by the director)", - }, - { - "notification_type_id": 13, - "name": "IDIR_CM__CR__DIRECTOR_ASSESSMENT", - "description": "Director assessment", - }, - { - "notification_type_id": 14, - "name": "IDIR_CM__CR__SUBMITTED_FOR_REVIEW", - "description": "Submitted to government for analyst review", - }, - { - "notification_type_id": 15, - "name": "IDIR_D__CR__MANAGER_RECOMMENDATION", - "description": "Compliance manager recommendation", - }, - { - "notification_type_id": 16, - "name": "IDIR_D__IA__ANALYST_RECOMMENDATION", - "description": "Analyst recommendation", - }, - { - "notification_type_id": 17, - "name": "IDIR_D__TR__ANALYST_RECOMMENDATION", - "description": "Analyst recommendation", - }, - ] - - try: - for type_data in notification_types_to_seed: - exists = await session.execute( - select(NotificationType).where( - NotificationType.notification_type_id - == type_data["notification_type_id"] - ) - ) - if not exists.scalars().first(): - notification_type = NotificationType(**type_data) - session.add(notification_type) - - except Exception as e: - context = { - "function": "seed_notification_types", - } - logger.error( - "Error occurred while seeding notification types", - error=str(e), - exc_info=e, - **context, - ) - raise diff --git a/backend/lcfs/db/seeders/common/notifications_seeder.py b/backend/lcfs/db/seeders/common/notifications_seeder.py index e1bfe6fc8..87fd449d0 100644 --- a/backend/lcfs/db/seeders/common/notifications_seeder.py +++ b/backend/lcfs/db/seeders/common/notifications_seeder.py @@ -58,47 +58,124 @@ async def seed_notification_types(session): """ types_to_seed = [ { - "name": "TRANSFER_PARTNER_UPDATE", - "description": "Transfer partner update notification", - "email_content": "Email content for transfer partner update", + "name": "BCEID__CR__DIRECTOR_ASSESSMENT", + "description": "Director assessment", + "email_content": "Email content for director assessment", + "create_user": "system", + "update_user": "system", + }, + { + "name": "BCEID__IA__DIRECTOR_APPROVAL", + "description": "Director approved", + "email_content": "Email content for director approval", + "create_user": "system", + "update_user": "system", + }, + { + "name": "BCEID__TR__DIRECTOR_DECISION", + "description": "Director recorded/refused", + "email_content": "Email content for director decision", + "create_user": "system", + "update_user": "system", + }, + { + "name": "BCEID__TR__PARTNER_ACTIONS", + "description": "Transfer partner proposed, declined, rescinded or signed & submitted", + "email_content": "Email content for partner actions", + "create_user": "system", + "update_user": "system", + }, + { + "name": "IDIR_A__CR__DIRECTOR_DECISION", + "description": "Director assessment", + "email_content": "Email content for director assessment", + "create_user": "system", + "update_user": "system", + }, + { + "name": "IDIR_A__CR__MANAGER_RECOMMENDATION", + "description": "Recommended by compliance manager", + "email_content": "Email content for manager recommendation", + "create_user": "system", + "update_user": "system", + }, + { + "name": "IDIR_A__CR__SUBMITTED_FOR_REVIEW", + "description": "Submitted to government for analyst review (or returned by compliance manager)", + "email_content": "Email content for submission review", + "create_user": "system", + "update_user": "system", + }, + { + "name": "IDIR_A__IA__RETURNED_TO_ANALYST", + "description": "Director approved/returned to analyst", + "email_content": "Email content for return to analyst", "create_user": "system", "update_user": "system", }, { - "name": "TRANSFER_DIRECTOR_REVIEW", - "description": "Director review notification", - "email_content": "Email content for director review", + "name": "IDIR_A__TR__DIRECTOR_RECORDED", + "description": "Director recorded/refused", + "email_content": "Email content for director recorded", "create_user": "system", "update_user": "system", }, { - "name": "INITIATIVE_APPROVED", - "description": "Initiative approved notification", - "email_content": "Email content for initiative approval", + "name": "IDIR_A__TR__RESCINDED_ACTION", + "description": "Rescinded by either transfer partner", + "email_content": "Email content for rescinded action", "create_user": "system", "update_user": "system", }, { - "name": "INITIATIVE_DA_REQUEST", - "description": "DA request notification", - "email_content": "Email content for DA request", + "name": "IDIR_A__TR__SUBMITTED_FOR_REVIEW", + "description": "Submitted to government for analyst review", + "email_content": "Email content for submission review", "create_user": "system", "update_user": "system", }, { - "name": "SUPPLEMENTAL_REQUESTED", - "description": "Supplemental requested notification", - "email_content": "Email content for supplemental request", + "name": "IDIR_CM__CR__ANALYST_RECOMMENDATION", + "description": "Analyst recommendation (or returned by the director)", + "email_content": "Email content for analyst recommendation", "create_user": "system", "update_user": "system", }, { - "name": "DIRECTOR_ASSESSMENT", - "description": "Director assessment notification", + "name": "IDIR_CM__CR__DIRECTOR_ASSESSMENT", + "description": "Director assessment", "email_content": "Email content for director assessment", "create_user": "system", "update_user": "system", }, + { + "name": "IDIR_CM__CR__SUBMITTED_FOR_REVIEW", + "description": "Submitted to government for analyst review", + "email_content": "Email content for submission review", + "create_user": "system", + "update_user": "system", + }, + { + "name": "IDIR_D__CR__MANAGER_RECOMMENDATION", + "description": "Compliance manager recommendation", + "email_content": "Email content for manager recommendation", + "create_user": "system", + "update_user": "system", + }, + { + "name": "IDIR_D__IA__ANALYST_RECOMMENDATION", + "description": "Analyst recommendation", + "email_content": "Email content for analyst recommendation", + "create_user": "system", + "update_user": "system", + }, + { + "name": "IDIR_D__TR__ANALYST_RECOMMENDATION", + "description": "Analyst recommendation", + "email_content": "Email content for analyst recommendation", + "create_user": "system", + "update_user": "system", + }, ] try: diff --git a/backend/lcfs/db/seeders/common_seeder.py b/backend/lcfs/db/seeders/common_seeder.py index 758f6991d..55c898920 100644 --- a/backend/lcfs/db/seeders/common_seeder.py +++ b/backend/lcfs/db/seeders/common_seeder.py @@ -22,7 +22,6 @@ from lcfs.db.seeders.common.allocation_agreement_seeder import ( seed_allocation_transaction_types, ) -from lcfs.db.seeders.common.notification_type_seeder import seed_notification_types from lcfs.db.seeders.common.end_user_type_seeder import ( seed_end_user_types, ) diff --git a/backend/lcfs/tests/notification/test_notification_repo.py b/backend/lcfs/tests/notification/test_notification_repo.py index f3dbdcd12..20eb31169 100644 --- a/backend/lcfs/tests/notification/test_notification_repo.py +++ b/backend/lcfs/tests/notification/test_notification_repo.py @@ -1,11 +1,15 @@ import pytest from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import delete +from sqlalchemy import delete from lcfs.web.api.notification.repo import NotificationRepository -from lcfs.db.models.notification import NotificationMessage, NotificationChannelSubscription +from lcfs.db.models.notification import ( + NotificationMessage, + NotificationChannelSubscription, +) from lcfs.web.exception.exceptions import DataNotFoundException from unittest.mock import AsyncMock, MagicMock + @pytest.fixture def mock_db_session(): session = AsyncMock(spec=AsyncSession) @@ -37,7 +41,9 @@ def notification_repo(mock_db_session): async def test_create_notification_message(notification_repo, mock_db_session): new_notification_message = MagicMock(spec=NotificationMessage) - result = await notification_repo.create_notification_message(new_notification_message) + result = await notification_repo.create_notification_message( + new_notification_message + ) assert result == new_notification_message mock_db_session.add.assert_called_once_with(new_notification_message) @@ -66,9 +72,10 @@ async def mock_execute(*args, **kwargs): mock_result_chain.scalar_one_or_none.assert_called_once() - @pytest.mark.anyio -async def test_get_notification_message_by_id_not_found(notification_repo, mock_db_session): +async def test_get_notification_message_by_id_not_found( + notification_repo, mock_db_session +): mock_result_chain = MagicMock() mock_result_chain.scalars = MagicMock(return_value=mock_result_chain) mock_result_chain.scalar_one_or_none.side_effect = DataNotFoundException @@ -97,7 +104,10 @@ async def test_get_notification_messages_by_user(notification_repo, mock_db_sess mock_notification2.message = "Test message 2" mock_result_chain = MagicMock() - mock_result_chain.scalars.return_value.all.return_value = [mock_notification1, mock_notification2] + mock_result_chain.scalars.return_value.all.return_value = [ + mock_notification1, + mock_notification2, + ] async def mock_execute(*args, **kwargs): return mock_result_chain @@ -113,7 +123,9 @@ async def mock_execute(*args, **kwargs): @pytest.mark.anyio -async def test_get_unread_notification_message_count_by_user_id(notification_repo, mock_db_session): +async def test_get_unread_notification_message_count_by_user_id( + notification_repo, mock_db_session +): user_id = 1 expected_unread_count = 5 @@ -122,7 +134,9 @@ async def test_get_unread_notification_message_count_by_user_id(notification_rep mock_db_session.execute = AsyncMock(return_value=mock_result) - result = await notification_repo.get_unread_notification_message_count_by_user_id(user_id) + result = await notification_repo.get_unread_notification_message_count_by_user_id( + user_id + ) assert result == expected_unread_count mock_db_session.execute.assert_called_once() @@ -148,7 +162,6 @@ async def test_delete_notification_message(notification_repo, mock_db_session): mock_db_session.flush.assert_called_once() - @pytest.mark.anyio async def test_update_notification_message(notification_repo, mock_db_session): mock_notification = MagicMock(spec=NotificationMessage) @@ -197,14 +210,20 @@ async def mock_execute(*args, **kwargs): assert result.is_read is True # Verify that is_read was set to True mock_db_session.commit.assert_called_once() # Ensure commit was called - mock_db_session.refresh.assert_called_once_with(mock_notification) # Check refresh was called + mock_db_session.refresh.assert_called_once_with( + mock_notification + ) # Check refresh was called @pytest.mark.anyio -async def test_create_notification_channel_subscription(notification_repo, mock_db_session): +async def test_create_notification_channel_subscription( + notification_repo, mock_db_session +): new_subscription = MagicMock(spec=NotificationChannelSubscription) - result = await notification_repo.create_notification_channel_subscription(new_subscription) + result = await notification_repo.create_notification_channel_subscription( + new_subscription + ) assert result == new_subscription mock_db_session.add.assert_called_once_with(new_subscription) @@ -213,16 +232,18 @@ async def test_create_notification_channel_subscription(notification_repo, mock_ @pytest.mark.anyio -async def test_get_notification_channel_subscriptions_by_user(notification_repo, mock_db_session): +async def test_get_notification_channel_subscriptions_by_user( + notification_repo, mock_db_session +): mock_subscription = MagicMock(spec=NotificationChannelSubscription) mock_subscription.user_profile_id = 1 mock_subscription.notification_channel_subscription_id = 1 mock_subscription.notification_type_id = 1 mock_subscription.notification_channel_id = 1 - + mock_result_chain = MagicMock() mock_result_chain.scalars.return_value.all.return_value = [mock_subscription] - + async def mock_execute(*args, **kwargs): return mock_result_chain @@ -235,54 +256,24 @@ async def mock_execute(*args, **kwargs): @pytest.mark.anyio -async def test_get_notification_channel_subscriptions_by_id(notification_repo, mock_db_session): +async def test_get_notification_channel_subscriptions_by_id( + notification_repo, mock_db_session +): subscription_id = 1 mock_subscription = MagicMock(spec=NotificationChannelSubscription) mock_subscription.notification_channel_subscription_id = subscription_id - mock_result_chain = MagicMock() - mock_result_chain.scalar_one.return_value = mock_subscription - + mock_result_chain.scalar_one.return_value = mock_subscription + async def mock_execute(*args, **kwargs): return mock_result_chain mock_db_session.execute = mock_execute - result = await notification_repo.get_notification_channel_subscription_by_id(subscription_id) + result = await notification_repo.get_notification_channel_subscription_by_id( + subscription_id + ) assert result is not None assert result.notification_channel_subscription_id == subscription_id - - -@pytest.mark.anyio -async def test_update_notification_channel_subscription(notification_repo, mock_db_session): - mock_subscription = MagicMock(spec=NotificationChannelSubscription) - mock_subscription.notification_channel_subscription_id = 1 - mock_subscription.is_enabled = True - mock_subscription.user_profile_id = 1 - mock_subscription.notification_type_id= 1 - mock_subscription.notification_channel_id = 1 - - updated_subscription = NotificationChannelSubscription( - notification_channel_subscription_id=1, - is_enabled=False, - user_profile_id=1, - notification_type_id=2, - notification_channel_id=1 - ) - - mock_db_session.merge.return_value = updated_subscription - mock_db_session.flush = AsyncMock() - - notification_repo.db = mock_db_session - - result = await notification_repo.update_notification_channel_subscription(mock_subscription) - - mock_db_session.merge.assert_called_once_with(mock_subscription) - mock_db_session.flush.assert_called_once() - assert result == updated_subscription - assert result.is_enabled is False - assert result.notification_type_id == 2 - - diff --git a/backend/lcfs/web/api/notification/repo.py b/backend/lcfs/web/api/notification/repo.py index 84cf5c53b..9a548dece 100644 --- a/backend/lcfs/web/api/notification/repo.py +++ b/backend/lcfs/web/api/notification/repo.py @@ -4,19 +4,16 @@ NotificationChannel, NotificationType, ) -from lcfs.web.api.notification.schema import NotificationMessageSchema import structlog -from datetime import date -from typing import List, Dict, Any, Optional, Union + +from typing import List, Optional from fastapi import Depends from lcfs.db.dependencies import get_async_db_session from lcfs.web.exception.exceptions import DataNotFoundException -from sqlalchemy import and_, delete, or_, select, func, text, update, distinct +from sqlalchemy import delete, select, func from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import joinedload, contains_eager, selectinload -from sqlalchemy.exc import NoResultFound -from fastapi import HTTPException +from sqlalchemy.orm import selectinload from lcfs.web.core.decorators import repo_handler @@ -209,18 +206,6 @@ async def get_notification_channel_subscription_by_id( return subscription - @repo_handler - async def update_notification_channel_subscription( - self, notification_channel_subscription: NotificationChannelSubscription - ) -> NotificationChannelSubscription: - """ - Update a notification chanel subscription - """ - merged_subscription = await self.db.merge(notification_channel_subscription) - await self.db.flush() - - return merged_subscription - @repo_handler async def delete_notification_channel_subscription( self, notification_channel_subscription_id: int diff --git a/backend/lcfs/web/api/notification/services.py b/backend/lcfs/web/api/notification/services.py index 25ccd7f2c..9d92ecddd 100644 --- a/backend/lcfs/web/api/notification/services.py +++ b/backend/lcfs/web/api/notification/services.py @@ -9,7 +9,6 @@ ) from lcfs.web.exception.exceptions import DataNotFoundException import structlog -import math from fastapi import Depends from lcfs.web.api.notification.repo import NotificationRepository from lcfs.web.core.decorators import service_handler @@ -174,31 +173,6 @@ async def get_notification_channel_subscription_by_id( notification_channel_subscription_id ) - @service_handler - async def update_notification_channel_subscription( - self, subscription_data: SubscriptionSchema, user_profile_id: int - ): - notification_channel_subscription_id = ( - await self.get_notification_channel_id_by_key( - subscription_data.notification_channel_subscription_id - ) - ) - existing_subscription = ( - await self.repo.get_notification_channel_subscription_by_id( - notification_channel_subscription_id - ) - ) - if existing_subscription.user_profile_id != user_profile_id: - raise DataNotFoundException( - "You are not authorized to update this subscription." - ) - - subscription = NotificationChannelSubscription( - **subscription_data.model_dump(exclude={"deleted"}) - ) - subscription.user_profile_id = user_profile_id # Ensure correct user - return await self.repo.update_notification_channel_subscription(subscription) - @service_handler async def delete_notification_channel_subscription( self, subscription_id: int, user_profile_id: int diff --git a/backend/lcfs/web/api/notification/views.py b/backend/lcfs/web/api/notification/views.py index 4602df595..f4f98d0e9 100644 --- a/backend/lcfs/web/api/notification/views.py +++ b/backend/lcfs/web/api/notification/views.py @@ -5,8 +5,7 @@ from typing import Union, List from lcfs.web.exception.exceptions import DataNotFoundException import structlog -from fastapi import APIRouter, Body, Depends, HTTPException, Request, Query -from lcfs.db import dependencies +from fastapi import APIRouter, Body, Depends, HTTPException, Request from lcfs.db.models.user.Role import RoleEnum from lcfs.web.api.notification.schema import ( DeleteNotificationChannelSubscriptionResponseSchema, diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 74a575e7f..127024c7d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -9,7 +9,6 @@ import { Dashboard } from './views/Dashboard' import { FileSubmissions } from './views/FileSubmissions' import { NotificationMenu } from '@/views/Notifications/NotificationMenu' import { FuelCodes, AddEditFuelCode } from './views/FuelCodes' -import { Notifications, NotificationSettings } from './views/Notifications' import { Organizations, AddEditOrg, From 14ea090c55e511f377797e83c926d1ea30fe90e7 Mon Sep 17 00:00:00 2001 From: Hamed Valiollahi Bayeki Date: Mon, 2 Dec 2024 16:02:20 -0800 Subject: [PATCH 3/5] fix: fix failing tests. --- .../lcfs/db/models/notification/NotificationType.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/backend/lcfs/db/models/notification/NotificationType.py b/backend/lcfs/db/models/notification/NotificationType.py index f3f6f03f5..69943df2c 100644 --- a/backend/lcfs/db/models/notification/NotificationType.py +++ b/backend/lcfs/db/models/notification/NotificationType.py @@ -4,6 +4,17 @@ from sqlalchemy.orm import relationship +class NotificationTypeEnum(enum.Enum): + TRANSFER_PARTNER_UPDATE = ( + "Transfer partner proposed, declined, rescinded, or signed" + ) + TRANSFER_DIRECTOR_REVIEW = "Director recorded/refused" + INITIATIVE_APPROVED = "Director approved" + INITIATIVE_DA_REQUEST = "DA request" + SUPPLEMENTAL_REQUESTED = "Supplemental requested" + DIRECTOR_ASSESSMENT = "Director assessment" + + class NotificationType(BaseModel, Auditable): __tablename__ = "notification_type" __table_args__ = {"comment": "Represents a Notification type"} From cbf53f783aa3f735e1f94985a0adb60780fedb5e Mon Sep 17 00:00:00 2001 From: Hamed Valiollahi Bayeki Date: Mon, 2 Dec 2024 16:15:33 -0800 Subject: [PATCH 4/5] fix: fix failing tests. --- frontend/src/components/Role.jsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/components/Role.jsx b/frontend/src/components/Role.jsx index 4f2fb4f84..b62c14e1b 100644 --- a/frontend/src/components/Role.jsx +++ b/frontend/src/components/Role.jsx @@ -7,6 +7,10 @@ export const Role = ({ children, roles }) => { const isAuthorized = roles?.length > 0 ? roles.some((role) => userRoles.includes(role)) : true + if (!currentUser) { + return
Loading...
+ } + if (!isAuthorized) { return null } From de1a55d3973272111f0e47af9433fd80d31968cd Mon Sep 17 00:00:00 2001 From: Hamed Valiollahi Bayeki Date: Thu, 5 Dec 2024 14:14:14 -0800 Subject: [PATCH 5/5] refactor: applied a few improvements to notifcation settings --- ...35.py => 2024-12-05-02-19_b69a33bbd135.py} | 6 +- ...2b.py => 2024-12-05-02-20_d4104af84f2b.py} | 2 +- .../notification/NotificationChannel.py | 4 +- .../models/notification/NotificationType.py | 12 --- .../lcfs/db/models/notification/__init__.py | 2 + .../db/seeders/common/notifications_seeder.py | 102 +++++++++--------- .../test_notification_services.py | 10 +- .../notification/test_notification_views.py | 4 +- backend/lcfs/tests/user/test_user_repo.py | 10 -- backend/lcfs/web/api/email/repo.py | 7 +- backend/lcfs/web/api/email/services.py | 3 +- .../lcfs/web/api/email/template_mapping.py | 28 +++-- backend/lcfs/web/api/notification/repo.py | 17 +-- backend/lcfs/web/api/notification/schema.py | 4 +- backend/lcfs/web/api/notification/services.py | 30 +++--- backend/lcfs/web/api/user/repo.py | 4 - frontend/src/constants/notificationTypes.js | 49 ++++++--- .../components/BCeIDNotificationSettings.jsx | 9 +- .../IDIRAnalystNotificationSettings.jsx | 14 +-- ...RComplianceManagerNotificationSettings.jsx | 6 +- .../IDIRDirectorNotificationSettings.jsx | 6 +- .../components/NotificationSettingsForm.jsx | 68 +++++++----- .../NotificationSettingsForm.test.jsx | 8 +- 23 files changed, 209 insertions(+), 196 deletions(-) rename backend/lcfs/db/migrations/versions/{2024-11-29-02-19_b69a33bbd135.py => 2024-12-05-02-19_b69a33bbd135.py} (88%) rename backend/lcfs/db/migrations/versions/{2024-11-29-02-20_d4104af84f2b.py => 2024-12-05-02-20_d4104af84f2b.py} (98%) diff --git a/backend/lcfs/db/migrations/versions/2024-11-29-02-19_b69a33bbd135.py b/backend/lcfs/db/migrations/versions/2024-12-05-02-19_b69a33bbd135.py similarity index 88% rename from backend/lcfs/db/migrations/versions/2024-11-29-02-19_b69a33bbd135.py rename to backend/lcfs/db/migrations/versions/2024-12-05-02-19_b69a33bbd135.py index 5e4a9a538..3f1477892 100644 --- a/backend/lcfs/db/migrations/versions/2024-11-29-02-19_b69a33bbd135.py +++ b/backend/lcfs/db/migrations/versions/2024-12-05-02-19_b69a33bbd135.py @@ -1,8 +1,8 @@ """Add notifications email to user_profile Revision ID: b69a33bbd135 -Revises: b4da565bb711 -Create Date: 2024-11-27 20:48:24.724112 +Revises: 8491890dd688 +Create Date: 2024-12-05 20:48:24.724112 """ @@ -11,7 +11,7 @@ # revision identifiers, used by Alembic. revision = "b69a33bbd135" -down_revision = "b4da565bb711" +down_revision = "8491890dd688" branch_labels = None depends_on = None diff --git a/backend/lcfs/db/migrations/versions/2024-11-29-02-20_d4104af84f2b.py b/backend/lcfs/db/migrations/versions/2024-12-05-02-20_d4104af84f2b.py similarity index 98% rename from backend/lcfs/db/migrations/versions/2024-11-29-02-20_d4104af84f2b.py rename to backend/lcfs/db/migrations/versions/2024-12-05-02-20_d4104af84f2b.py index 83bee6bda..a77e0cb43 100644 --- a/backend/lcfs/db/migrations/versions/2024-11-29-02-20_d4104af84f2b.py +++ b/backend/lcfs/db/migrations/versions/2024-12-05-02-20_d4104af84f2b.py @@ -2,7 +2,7 @@ Revision ID: d4104af84f2b Revises: b69a33bbd135 -Create Date: 2024-11-29 02:20:33.898150 +Create Date: 2024-12-05 02:20:33.898150 """ diff --git a/backend/lcfs/db/models/notification/NotificationChannel.py b/backend/lcfs/db/models/notification/NotificationChannel.py index f27a61eb2..2d1103b51 100644 --- a/backend/lcfs/db/models/notification/NotificationChannel.py +++ b/backend/lcfs/db/models/notification/NotificationChannel.py @@ -5,8 +5,8 @@ class ChannelEnum(enum.Enum): - EMAIL = "Email" - IN_APP = "In-Application" + EMAIL = "EMAIL" + IN_APP = "IN_APP" class NotificationChannel(BaseModel, Auditable): diff --git a/backend/lcfs/db/models/notification/NotificationType.py b/backend/lcfs/db/models/notification/NotificationType.py index 69943df2c..9c8f31914 100644 --- a/backend/lcfs/db/models/notification/NotificationType.py +++ b/backend/lcfs/db/models/notification/NotificationType.py @@ -1,20 +1,8 @@ -import enum from lcfs.db.base import BaseModel, Auditable from sqlalchemy import Column, Integer, Text, String from sqlalchemy.orm import relationship -class NotificationTypeEnum(enum.Enum): - TRANSFER_PARTNER_UPDATE = ( - "Transfer partner proposed, declined, rescinded, or signed" - ) - TRANSFER_DIRECTOR_REVIEW = "Director recorded/refused" - INITIATIVE_APPROVED = "Director approved" - INITIATIVE_DA_REQUEST = "DA request" - SUPPLEMENTAL_REQUESTED = "Supplemental requested" - DIRECTOR_ASSESSMENT = "Director assessment" - - class NotificationType(BaseModel, Auditable): __tablename__ = "notification_type" __table_args__ = {"comment": "Represents a Notification type"} diff --git a/backend/lcfs/db/models/notification/__init__.py b/backend/lcfs/db/models/notification/__init__.py index ebc71c209..e16003e7e 100644 --- a/backend/lcfs/db/models/notification/__init__.py +++ b/backend/lcfs/db/models/notification/__init__.py @@ -1,10 +1,12 @@ from .NotificationChannel import NotificationChannel +from .NotificationChannel import ChannelEnum from .NotificationChannelSubscription import NotificationChannelSubscription from .NotificationMessage import NotificationMessage from .NotificationType import NotificationType __all__ = [ "NotificationChannel", + "ChannelEnum", "NotificationChannelSubscription", "NotificationMessage", "NotificationType", diff --git a/backend/lcfs/db/seeders/common/notifications_seeder.py b/backend/lcfs/db/seeders/common/notifications_seeder.py index 87fd449d0..363ae6c98 100644 --- a/backend/lcfs/db/seeders/common/notifications_seeder.py +++ b/backend/lcfs/db/seeders/common/notifications_seeder.py @@ -58,121 +58,121 @@ async def seed_notification_types(session): """ types_to_seed = [ { - "name": "BCEID__CR__DIRECTOR_ASSESSMENT", - "description": "Director assessment", - "email_content": "Email content for director assessment", + "name": "BCEID__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT", + "description": "Director assessed a compliance report or supplemental report.", + "email_content": "Email content", "create_user": "system", "update_user": "system", }, { - "name": "BCEID__IA__DIRECTOR_APPROVAL", - "description": "Director approved", - "email_content": "Email content for director approval", + "name": "BCEID__INITIATIVE_AGREEMENT__DIRECTOR_APPROVAL", + "description": "Director approved the initiative agreement or transaction", + "email_content": "Email content", "create_user": "system", "update_user": "system", }, { - "name": "BCEID__TR__DIRECTOR_DECISION", - "description": "Director recorded/refused", - "email_content": "Email content for director decision", + "name": "BCEID__TRANSFER__DIRECTOR_DECISION", + "description": "Director recorded or refused a transfer request", + "email_content": "Email content", "create_user": "system", "update_user": "system", }, { - "name": "BCEID__TR__PARTNER_ACTIONS", - "description": "Transfer partner proposed, declined, rescinded or signed & submitted", - "email_content": "Email content for partner actions", + "name": "BCEID__TRANSFER__PARTNER_ACTIONS", + "description": "A transfer partner took action (proposed, declined, rescinded, or signed & submitted) on a transfer request", + "email_content": "Email content", "create_user": "system", "update_user": "system", }, { - "name": "IDIR_A__CR__DIRECTOR_DECISION", - "description": "Director assessment", - "email_content": "Email content for director assessment", + "name": "IDIR_ANALYST__COMPLIANCE_REPORT__DIRECTOR_DECISION", + "description": "Director assessed compliance report", + "email_content": "Email content", "create_user": "system", "update_user": "system", }, { - "name": "IDIR_A__CR__MANAGER_RECOMMENDATION", - "description": "Recommended by compliance manager", - "email_content": "Email content for manager recommendation", + "name": "IDIR_ANALYST__COMPLIANCE_REPORT__MANAGER_RECOMMENDATION", + "description": "Compliance manager recommended action on the compliance report.", + "email_content": "Email content", "create_user": "system", "update_user": "system", }, { - "name": "IDIR_A__CR__SUBMITTED_FOR_REVIEW", - "description": "Submitted to government for analyst review (or returned by compliance manager)", - "email_content": "Email content for submission review", + "name": "IDIR_ANALYST__COMPLIANCE_REPORT__SUBMITTED_FOR_REVIEW", + "description": "Compliance report submitted for government analyst review or returned by compliance manager", + "email_content": "Email content", "create_user": "system", "update_user": "system", }, { - "name": "IDIR_A__IA__RETURNED_TO_ANALYST", - "description": "Director approved/returned to analyst", - "email_content": "Email content for return to analyst", + "name": "IDIR_ANALYST__INITIATIVE_AGREEMENT__RETURNED_TO_ANALYST", + "description": "Director approved/returned the initiative agreement to the analyst", + "email_content": "Email content", "create_user": "system", "update_user": "system", }, { - "name": "IDIR_A__TR__DIRECTOR_RECORDED", - "description": "Director recorded/refused", - "email_content": "Email content for director recorded", + "name": "IDIR_ANALYST__TRANSFER__DIRECTOR_RECORDED", + "description": "Director recorded or refused a transfer request", + "email_content": "Email content", "create_user": "system", "update_user": "system", }, { - "name": "IDIR_A__TR__RESCINDED_ACTION", - "description": "Rescinded by either transfer partner", - "email_content": "Email content for rescinded action", + "name": "IDIR_ANALYST__TRANSFER__RESCINDED_ACTION", + "description": "A transfer request was rescinded by a transfer partner", + "email_content": "Email content", "create_user": "system", "update_user": "system", }, { - "name": "IDIR_A__TR__SUBMITTED_FOR_REVIEW", - "description": "Submitted to government for analyst review", - "email_content": "Email content for submission review", + "name": "IDIR_ANALYST__TRANSFER__SUBMITTED_FOR_REVIEW", + "description": "Transfer request submitted for government analyst review", + "email_content": "Email content", "create_user": "system", "update_user": "system", }, { - "name": "IDIR_CM__CR__ANALYST_RECOMMENDATION", - "description": "Analyst recommendation (or returned by the director)", - "email_content": "Email content for analyst recommendation", + "name": "IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__ANALYST_RECOMMENDATION", + "description": "Analyst recommendation on the compliance report or returned by the director", + "email_content": "Email content", "create_user": "system", "update_user": "system", }, { - "name": "IDIR_CM__CR__DIRECTOR_ASSESSMENT", - "description": "Director assessment", - "email_content": "Email content for director assessment", + "name": "IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT", + "description": "Director assessed a compliance report", + "email_content": "Email content", "create_user": "system", "update_user": "system", }, { - "name": "IDIR_CM__CR__SUBMITTED_FOR_REVIEW", - "description": "Submitted to government for analyst review", - "email_content": "Email content for submission review", + "name": "IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__SUBMITTED_FOR_REVIEW", + "description": "Compliance report submitted for government analyst review", + "email_content": "Email content", "create_user": "system", "update_user": "system", }, { - "name": "IDIR_D__CR__MANAGER_RECOMMENDATION", - "description": "Compliance manager recommendation", - "email_content": "Email content for manager recommendation", + "name": "IDIR_DIRECTOR__COMPLIANCE_REPORT__MANAGER_RECOMMENDATION", + "description": "Compliance manager recommended action on the compliance report", + "email_content": "Email content", "create_user": "system", "update_user": "system", }, { - "name": "IDIR_D__IA__ANALYST_RECOMMENDATION", - "description": "Analyst recommendation", - "email_content": "Email content for analyst recommendation", + "name": "IDIR_DIRECTOR__INITIATIVE_AGREEMENT__ANALYST_RECOMMENDATION", + "description": "Analyst recommendation provided for the initiative agreement", + "email_content": "Email content", "create_user": "system", "update_user": "system", }, { - "name": "IDIR_D__TR__ANALYST_RECOMMENDATION", - "description": "Analyst recommendation", - "email_content": "Email content for analyst recommendation", + "name": "IDIR_DIRECTOR__TRANSFER__ANALYST_RECOMMENDATION", + "description": "Analyst recommendation provided for the transfer request", + "email_content": "Email content", "create_user": "system", "update_user": "system", }, diff --git a/backend/lcfs/tests/notification/test_notification_services.py b/backend/lcfs/tests/notification/test_notification_services.py index c112b6658..a23b49388 100644 --- a/backend/lcfs/tests/notification/test_notification_services.py +++ b/backend/lcfs/tests/notification/test_notification_services.py @@ -272,8 +272,8 @@ async def test_create_notification_channel_subscription(notification_service): subscription_data = SubscriptionSchema( is_enabled=True, - notification_channel_key="email", - notification_type_key="new_message", + notification_channel_name="EMAIL", + notification_type_name="BCEID__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT", ) user_profile_id = 1 @@ -307,8 +307,6 @@ async def test_create_notification_channel_subscription(notification_service): passed_subscription = called_args[0] assert passed_subscription.is_enabled == True assert passed_subscription.user_profile_id == user_profile_id - assert passed_subscription.notification_channel_id == 3 - assert passed_subscription.notification_type_id == 2 @pytest.mark.anyio @@ -340,8 +338,8 @@ async def test_get_notification_channel_subscriptions_by_user_id(notification_se assert len(result) == 1 subscription = result[0] assert subscription["notification_channel_subscription_id"] == 123 - assert subscription["notification_channel_key"] == "email" - assert subscription["notification_type_key"] == "new_message" + assert subscription["notification_channel_name"] == "email" + assert subscription["notification_type_name"] == "new_message" mock_repo.get_notification_channel_subscriptions_by_user.assert_awaited_once_with( user_id diff --git a/backend/lcfs/tests/notification/test_notification_views.py b/backend/lcfs/tests/notification/test_notification_views.py index bc028fe11..7a7e2fa93 100644 --- a/backend/lcfs/tests/notification/test_notification_views.py +++ b/backend/lcfs/tests/notification/test_notification_views.py @@ -172,8 +172,8 @@ async def test_get_notification_channel_subscription_by_id( mock_subscription = SubscriptionSchema( notification_channel_subscription_id=1, user_profile_id=1, # Match mock user - notification_channel_key="email", - notification_type_key="new_message", + notification_channel_name="EMAIL", + notification_type_name="BCEID__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT", is_enabled=True, ) mock_get_subscription.return_value = mock_subscription diff --git a/backend/lcfs/tests/user/test_user_repo.py b/backend/lcfs/tests/user/test_user_repo.py index ba5b955ac..56cb75403 100644 --- a/backend/lcfs/tests/user/test_user_repo.py +++ b/backend/lcfs/tests/user/test_user_repo.py @@ -77,13 +77,3 @@ async def test_update_notifications_email_success(dbsession, user_repo): # Assert: Check if the notifications email was updated assert updated_user.notifications_email == "new_email@domain.com" - - -@pytest.mark.anyio -async def test_update_notifications_email_user_not_found(dbsession, user_repo): - # Act and Assert: Try to update a non-existent user and expect an exception - with pytest.raises(DataNotFoundException) as exc_info: - await user_repo.update_notifications_email( - user_profile_id=9999, email="new_email@domain.com" - ) - assert "User with id '9999' not found." in str(exc_info.value) diff --git a/backend/lcfs/web/api/email/repo.py b/backend/lcfs/web/api/email/repo.py index d05e6863d..7d8c6cd6e 100644 --- a/backend/lcfs/web/api/email/repo.py +++ b/backend/lcfs/web/api/email/repo.py @@ -4,7 +4,6 @@ ChannelEnum, NotificationChannel, ) -from lcfs.db.models.notification.NotificationType import NotificationTypeEnum from lcfs.web.core.decorators import repo_handler from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, or_ @@ -32,7 +31,7 @@ async def get_subscribed_user_emails( .join(NotificationChannelSubscription.notification_channel) .filter( NotificationChannelSubscription.notification_type.has( - name=NotificationTypeEnum[notification_type.upper()] + name=notification_type ), NotificationChannelSubscription.is_enabled == True, NotificationChannel.channel_name @@ -59,9 +58,7 @@ async def get_notification_template(self, notification_type: str) -> str: NotificationChannelSubscription.notification_type_id == NotificationType.notification_type_id, ) - .filter( - NotificationType.name == NotificationTypeEnum[notification_type.upper()] - ) + .filter(NotificationType.name == notification_type) .limit(1) # Fetch only one record ) diff --git a/backend/lcfs/web/api/email/services.py b/backend/lcfs/web/api/email/services.py index f2ab678e1..856874d92 100644 --- a/backend/lcfs/web/api/email/services.py +++ b/backend/lcfs/web/api/email/services.py @@ -1,5 +1,4 @@ import os -from lcfs.db.models.notification.NotificationType import NotificationTypeEnum import requests import structlog from fastapi import Depends @@ -88,7 +87,7 @@ def _render_email_template( """ # Fetch template file path from the imported mapping template_file = TEMPLATE_MAPPING.get( - NotificationTypeEnum[template_name], TEMPLATE_MAPPING["default"] + template_name, TEMPLATE_MAPPING["default"] ) # Render the template diff --git a/backend/lcfs/web/api/email/template_mapping.py b/backend/lcfs/web/api/email/template_mapping.py index 41f041d18..d31291666 100644 --- a/backend/lcfs/web/api/email/template_mapping.py +++ b/backend/lcfs/web/api/email/template_mapping.py @@ -1,12 +1,20 @@ -from lcfs.db.models.notification.NotificationType import NotificationTypeEnum - - TEMPLATE_MAPPING = { - NotificationTypeEnum.TRANSFER_DIRECTOR_REVIEW: "transfer_director_review.html", - NotificationTypeEnum.INITIATIVE_APPROVED: "initiative_approved.html", - NotificationTypeEnum.INITIATIVE_DA_REQUEST: "initiative_da_request.html", - NotificationTypeEnum.SUPPLEMENTAL_REQUESTED: "supplemental_requested.html", - NotificationTypeEnum.DIRECTOR_ASSESSMENT: "director_assessment.html", - NotificationTypeEnum.TRANSFER_PARTNER_UPDATE: "transfer_partner_update.html", - "default": "default.html" + "BCEID__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT": "bceid__compliance_report__director_assessment.html", + "BCEID__INITIATIVE_AGREEMENT__DIRECTOR_APPROVAL": "bceid__initiative_agreement__director_approval.html", + "BCEID__TRANSFER__DIRECTOR_DECISION": "bceid__transfer__director_decision.html", + "BCEID__TRANSFER__PARTNER_ACTIONS": "bceid__transfer__partner_actions.html", + "IDIR_ANALYST__COMPLIANCE_REPORT__DIRECTOR_DECISION": "idir_analyst__compliance_report__director_decision.html", + "IDIR_ANALYST__COMPLIANCE_REPORT__MANAGER_RECOMMENDATION": "idir_analyst__compliance_report__manager_recommendation.html", + "IDIR_ANALYST__COMPLIANCE_REPORT__SUBMITTED_FOR_REVIEW": "idir_analyst__compliance_report__submitted_for_review.html", + "IDIR_ANALYST__INITIATIVE_AGREEMENT__RETURNED_TO_ANALYST": "idir_analyst__initiative_agreement__returned_to_analyst.html", + "IDIR_ANALYST__TRANSFER__DIRECTOR_RECORDED": "idir_analyst__transfer__director_recorded.html", + "IDIR_ANALYST__TRANSFER__RESCINDED_ACTION": "idir_analyst__transfer__rescinded_action.html", + "IDIR_ANALYST__TRANSFER__SUBMITTED_FOR_REVIEW": "idir_analyst__transfer__submitted_for_review.html", + "IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__ANALYST_RECOMMENDATION": "idir_compliance_manager__compliance_report__analyst_recommendation.html", + "IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT": "idir_compliance_manager__compliance_report__director_assessment.html", + "IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__SUBMITTED_FOR_REVIEW": "idir_compliance_manager__compliance_report__submitted_for_review.html", + "IDIR_DIRECTOR__COMPLIANCE_REPORT__MANAGER_RECOMMENDATION": "idir_director__compliance_report__manager_recommendation.html", + "IDIR_DIRECTOR__INITIATIVE_AGREEMENT__ANALYST_RECOMMENDATION": "idir_director__initiative_agreement__analyst_recommendation.html", + "IDIR_DIRECTOR__TRANSFER__ANALYST_RECOMMENDATION": "idir_director__transfer__analyst_recommendation.html", + "default": "default.html", } diff --git a/backend/lcfs/web/api/notification/repo.py b/backend/lcfs/web/api/notification/repo.py index 9a548dece..c12689ee4 100644 --- a/backend/lcfs/web/api/notification/repo.py +++ b/backend/lcfs/web/api/notification/repo.py @@ -3,6 +3,7 @@ NotificationMessage, NotificationChannel, NotificationType, + ChannelEnum, ) import structlog @@ -163,7 +164,7 @@ async def get_notification_channel_subscriptions_by_user( self, user_profile_id: int ) -> List[NotificationChannelSubscription]: """ - Retrieve channel subscriptions for a user, including channel key and notification type key. + Retrieve channel subscriptions for a user, including channel name and notification type name. """ query = ( select(NotificationChannelSubscription) @@ -221,24 +222,26 @@ async def delete_notification_channel_subscription( await self.db.flush() @repo_handler - async def get_notification_type_by_key(self, key: str) -> Optional[int]: + async def get_notification_type_by_name(self, name: str) -> Optional[int]: """ - Retrieve a NotificationType by its key + Retrieve a NotificationType by its name """ query = select(NotificationType.notification_type_id).where( - NotificationType.name == key + NotificationType.name == name ) result = await self.db.execute(query) x = result.scalars().first() return x @repo_handler - async def get_notification_channel_by_key(self, key: str) -> Optional[int]: + async def get_notification_channel_by_name( + self, name: ChannelEnum + ) -> Optional[int]: """ - Retrieve a NotificationChannel by its key + Retrieve a NotificationChannel by its name """ query = select(NotificationChannel.notification_channel_id).where( - NotificationChannel.channel_name == key + NotificationChannel.channel_name == name.value ) result = await self.db.execute(query) return result.scalars().first() diff --git a/backend/lcfs/web/api/notification/schema.py b/backend/lcfs/web/api/notification/schema.py index 65ebafc11..419f212c5 100644 --- a/backend/lcfs/web/api/notification/schema.py +++ b/backend/lcfs/web/api/notification/schema.py @@ -33,9 +33,9 @@ class DeleteNotificationMessageResponseSchema(BaseSchema): class SubscriptionSchema(BaseSchema): notification_channel_subscription_id: Optional[int] = None is_enabled: Optional[bool] = True - notification_channel_key: Optional[str] = None + notification_channel_name: Optional[str] = None user_profile_id: Optional[int] = None - notification_type_key: Optional[str] = None + notification_type_name: Optional[str] = None deleted: Optional[bool] = None diff --git a/backend/lcfs/web/api/notification/services.py b/backend/lcfs/web/api/notification/services.py index 9d92ecddd..f9bc27951 100644 --- a/backend/lcfs/web/api/notification/services.py +++ b/backend/lcfs/web/api/notification/services.py @@ -2,6 +2,7 @@ from lcfs.db.models.notification import ( NotificationChannelSubscription, NotificationMessage, + ChannelEnum, ) from lcfs.web.api.notification.schema import ( SubscriptionSchema, @@ -106,28 +107,29 @@ async def delete_notification_message(self, notification_id: int): raise DataNotFoundException(f"Notification with ID {notification_id}.") @service_handler - async def get_notification_type_id_by_key(self, key: str) -> int: - notification_type = await self.repo.get_notification_type_by_key(key) + async def get_notification_type_id_by_name(self, name: str) -> int: + notification_type = await self.repo.get_notification_type_by_name(name) if not notification_type: - raise ValueError(f"Invalid notification type key: {key}") + raise ValueError(f"Invalid notification type name: {name}") return notification_type @service_handler - async def get_notification_channel_id_by_key(self, key: str) -> int: - notification_channel = await self.repo.get_notification_channel_by_key(key) + async def get_notification_channel_id_by_name(self, name: ChannelEnum) -> int: + notification_channel = await self.repo.get_notification_channel_by_name(name) if not notification_channel: - raise ValueError(f"Invalid notification channel key: {key}") + raise ValueError(f"Invalid notification channel name: {name}") return notification_channel @service_handler async def create_notification_channel_subscription( self, subscription_data: SubscriptionSchema, user_profile_id: int ): - notification_channel_id = await self.get_notification_channel_id_by_key( - subscription_data.notification_channel_key + channel_enum_name = ChannelEnum(subscription_data.notification_channel_name) + notification_channel_id = await self.get_notification_channel_id_by_name( + channel_enum_name ) - notification_type_id = await self.get_notification_type_id_by_key( - subscription_data.notification_type_key + notification_type_id = await self.get_notification_type_id_by_name( + subscription_data.notification_type_name ) subscription = NotificationChannelSubscription( @@ -151,16 +153,16 @@ async def get_notification_channel_subscriptions_by_user_id(self, user_id: int): user_id ) - subscriptions_with_keys = [ + subscriptions_with_names = [ { "notification_channel_subscription_id": subscription.notification_channel_subscription_id, - "notification_channel_key": subscription.notification_channel.channel_name.name, - "notification_type_key": subscription.notification_type.name, + "notification_channel_name": subscription.notification_channel.channel_name.name, + "notification_type_name": subscription.notification_type.name, } for subscription in subscriptions ] - return subscriptions_with_keys + return subscriptions_with_names @service_handler async def get_notification_channel_subscription_by_id( diff --git a/backend/lcfs/web/api/user/repo.py b/backend/lcfs/web/api/user/repo.py index 1d79974b5..c1906c34c 100644 --- a/backend/lcfs/web/api/user/repo.py +++ b/backend/lcfs/web/api/user/repo.py @@ -679,10 +679,6 @@ async def update_notifications_email( result = await self.db.execute(query) user_profile = result.scalar_one_or_none() - if not user_profile: - logger.warning(f"User with ID {user_profile_id} not found.") - raise DataNotFoundException(f"User with id '{user_profile_id}' not found.") - # Update the notifications_email field user_profile.notifications_email = email diff --git a/frontend/src/constants/notificationTypes.js b/frontend/src/constants/notificationTypes.js index ea7cee8ac..c694e5fc9 100644 --- a/frontend/src/constants/notificationTypes.js +++ b/frontend/src/constants/notificationTypes.js @@ -1,28 +1,43 @@ export const notificationTypes = { // BCEID - BCEID__CR__DIRECTOR_ASSESSMENT: 'BCEID__CR__DIRECTOR_ASSESSMENT', - BCEID__IA__DIRECTOR_APPROVAL: 'BCEID__IA__DIRECTOR_APPROVAL', - BCEID__TR__DIRECTOR_DECISION: 'BCEID__TR__DIRECTOR_DECISION', - BCEID__TR__PARTNER_ACTIONS: 'BCEID__TR__PARTNER_ACTIONS', + BCEID__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT: + 'BCEID__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT', + BCEID__INITIATIVE_AGREEMENT__DIRECTOR_APPROVAL: + 'BCEID__INITIATIVE_AGREEMENT__DIRECTOR_APPROVAL', + BCEID__TRANSFER__DIRECTOR_DECISION: 'BCEID__TRANSFER__DIRECTOR_DECISION', + BCEID__TRANSFER__PARTNER_ACTIONS: 'BCEID__TRANSFER__PARTNER_ACTIONS', // IDIR ANALYST - IDIR_A__CR__DIRECTOR_DECISION: 'IDIR_A__CR__DIRECTOR_DECISION', - IDIR_A__CR__MANAGER_RECOMMENDATION: 'IDIR_A__CR__MANAGER_RECOMMENDATION', - IDIR_A__CR__SUBMITTED_FOR_REVIEW: 'IDIR_A__CR__SUBMITTED_FOR_REVIEW', - IDIR_A__IA__RETURNED_TO_ANALYST: 'IDIR_A__IA__RETURNED_TO_ANALYST', - IDIR_A__TR__DIRECTOR_RECORDED: 'IDIR_A__TR__DIRECTOR_RECORDED', - IDIR_A__TR__RESCINDED_ACTION: 'IDIR_A__TR__RESCINDED_ACTION', - IDIR_A__TR__SUBMITTED_FOR_REVIEW: 'IDIR_A__TR__SUBMITTED_FOR_REVIEW', + IDIR_ANALYST__COMPLIANCE_REPORT__DIRECTOR_DECISION: + 'IDIR_ANALYST__COMPLIANCE_REPORT__DIRECTOR_DECISION', + IDIR_ANALYST__COMPLIANCE_REPORT__MANAGER_RECOMMENDATION: + 'IDIR_ANALYST__COMPLIANCE_REPORT__MANAGER_RECOMMENDATION', + IDIR_ANALYST__COMPLIANCE_REPORT__SUBMITTED_FOR_REVIEW: + 'IDIR_ANALYST__COMPLIANCE_REPORT__SUBMITTED_FOR_REVIEW', + IDIR_ANALYST__INITIATIVE_AGREEMENT__RETURNED_TO_ANALYST: + 'IDIR_ANALYST__INITIATIVE_AGREEMENT__RETURNED_TO_ANALYST', + IDIR_ANALYST__TRANSFER__DIRECTOR_RECORDED: + 'IDIR_ANALYST__TRANSFER__DIRECTOR_RECORDED', + IDIR_ANALYST__TRANSFER__RESCINDED_ACTION: + 'IDIR_ANALYST__TRANSFER__RESCINDED_ACTION', + IDIR_ANALYST__TRANSFER__SUBMITTED_FOR_REVIEW: + 'IDIR_ANALYST__TRANSFER__SUBMITTED_FOR_REVIEW', // IDIR COMPLIANCE_MANAGER - IDIR_CM__CR__ANALYST_RECOMMENDATION: 'IDIR_CM__CR__ANALYST_RECOMMENDATION', - IDIR_CM__CR__DIRECTOR_ASSESSMENT: 'IDIR_CM__CR__DIRECTOR_ASSESSMENT', - IDIR_CM__CR__SUBMITTED_FOR_REVIEW: 'IDIR_CM__CR__SUBMITTED_FOR_REVIEW', + IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__ANALYST_RECOMMENDATION: + 'IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__ANALYST_RECOMMENDATION', + IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT: + 'IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT', + IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__SUBMITTED_FOR_REVIEW: + 'IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__SUBMITTED_FOR_REVIEW', // IDIR DIRECTOR - IDIR_D__CR__MANAGER_RECOMMENDATION: 'IDIR_D__CR__MANAGER_RECOMMENDATION', - IDIR_D__IA__ANALYST_RECOMMENDATION: 'IDIR_D__IA__ANALYST_RECOMMENDATION', - IDIR_D__TR__ANALYST_RECOMMENDATION: 'IDIR_D__TR__ANALYST_RECOMMENDATION' + IDIR_DIRECTOR__COMPLIANCE_REPORT__MANAGER_RECOMMENDATION: + 'IDIR_DIRECTOR__COMPLIANCE_REPORT__MANAGER_RECOMMENDATION', + IDIR_DIRECTOR__INITIATIVE_AGREEMENT__ANALYST_RECOMMENDATION: + 'IDIR_DIRECTOR__INITIATIVE_AGREEMENT__ANALYST_RECOMMENDATION', + IDIR_DIRECTOR__TRANSFER__ANALYST_RECOMMENDATION: + 'IDIR_DIRECTOR__TRANSFER__ANALYST_RECOMMENDATION' } export const notificationChannels = { diff --git a/frontend/src/views/Notifications/NotificationMenu/components/BCeIDNotificationSettings.jsx b/frontend/src/views/Notifications/NotificationMenu/components/BCeIDNotificationSettings.jsx index 35d566455..74af3935c 100644 --- a/frontend/src/views/Notifications/NotificationMenu/components/BCeIDNotificationSettings.jsx +++ b/frontend/src/views/Notifications/NotificationMenu/components/BCeIDNotificationSettings.jsx @@ -8,18 +8,19 @@ const BCeIDNotificationSettings = () => { const categories = { 'bceid.categories.transfers': { title: 'bceid.categories.transfers.title', - BCEID__TR__PARTNER_ACTIONS: 'bceid.categories.transfers.partnerActions', - BCEID__TR__DIRECTOR_DECISION: + BCEID__TRANSFER__PARTNER_ACTIONS: + 'bceid.categories.transfers.partnerActions', + BCEID__TRANSFER__DIRECTOR_DECISION: 'bceid.categories.transfers.directorDecision' }, 'bceid.categories.initiativeAgreements': { title: 'bceid.categories.initiativeAgreements.title', - BCEID__IA__DIRECTOR_APPROVAL: + BCEID__INITIATIVE_AGREEMENT__DIRECTOR_APPROVAL: 'bceid.categories.initiativeAgreements.directorApproval' }, 'bceid.categories.complianceReports': { title: 'bceid.categories.complianceReports.title', - BCEID__CR__DIRECTOR_ASSESSMENT: + BCEID__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT: 'bceid.categories.complianceReports.directorAssessment' } } diff --git a/frontend/src/views/Notifications/NotificationMenu/components/IDIRAnalystNotificationSettings.jsx b/frontend/src/views/Notifications/NotificationMenu/components/IDIRAnalystNotificationSettings.jsx index cb3403e52..731dedd68 100644 --- a/frontend/src/views/Notifications/NotificationMenu/components/IDIRAnalystNotificationSettings.jsx +++ b/frontend/src/views/Notifications/NotificationMenu/components/IDIRAnalystNotificationSettings.jsx @@ -5,25 +5,25 @@ const IDIRAnalystNotificationSettings = () => { const categories = { 'idirAnalyst.categories.transfers': { title: 'idirAnalyst.categories.transfers.title', - IDIR_A__TR__SUBMITTED_FOR_REVIEW: + IDIR_ANALYST__TRANSFER__SUBMITTED_FOR_REVIEW: 'idirAnalyst.categories.transfers.submittedForReview', - IDIR_A__TR__RESCINDED_ACTION: + IDIR_ANALYST__TRANSFER__RESCINDED_ACTION: 'idirAnalyst.categories.transfers.rescindedAction', - IDIR_A__TR__DIRECTOR_RECORDED: + IDIR_ANALYST__TRANSFER__DIRECTOR_RECORDEDIDIR_A__TR__DIRECTOR_RECORDED: 'idirAnalyst.categories.transfers.directorRecorded' }, 'idirAnalyst.categories.initiativeAgreements': { title: 'idirAnalyst.categories.initiativeAgreements.title', - IDIR_A__IA__RETURNED_TO_ANALYST: + IDIR_ANALYST__INITIATIVE_AGREEMENT__RETURNED_TO_ANALYST: 'idirAnalyst.categories.initiativeAgreements.returnedToAnalyst' }, 'idirAnalyst.categories.complianceReports': { title: 'idirAnalyst.categories.complianceReports.title', - IDIR_A__CR__SUBMITTED_FOR_REVIEW: + IDIR_ANALYST__COMPLIANCE_REPORT__SUBMITTED_FOR_REVIEW: 'idirAnalyst.categories.complianceReports.submittedForReview', - IDIR_A__CR__MANAGER_RECOMMENDATION: + IDIR_ANALYST__COMPLIANCE_REPORT__MANAGER_RECOMMENDATION: 'idirAnalyst.categories.complianceReports.managerRecommendation', - IDIR_A__CR__DIRECTOR_DECISION: + IDIR_ANALYST__COMPLIANCE_REPORT__DIRECTOR_DECISION: 'idirAnalyst.categories.complianceReports.directorDecision' } } diff --git a/frontend/src/views/Notifications/NotificationMenu/components/IDIRComplianceManagerNotificationSettings.jsx b/frontend/src/views/Notifications/NotificationMenu/components/IDIRComplianceManagerNotificationSettings.jsx index 91be06073..ee76d9958 100644 --- a/frontend/src/views/Notifications/NotificationMenu/components/IDIRComplianceManagerNotificationSettings.jsx +++ b/frontend/src/views/Notifications/NotificationMenu/components/IDIRComplianceManagerNotificationSettings.jsx @@ -5,11 +5,11 @@ const IDIRComplianceManagerNotificationSettings = () => { const categories = { 'idirComplianceManager.categories.complianceReports': { title: 'idirComplianceManager.categories.complianceReports.title', - IDIR_CM__CR__SUBMITTED_FOR_REVIEW: + IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__SUBMITTED_FOR_REVIEW: 'idirComplianceManager.categories.complianceReports.submittedForReview', - IDIR_CM__CR__ANALYST_RECOMMENDATION: + IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__ANALYST_RECOMMENDATION: 'idirComplianceManager.categories.complianceReports.analystRecommendation', - IDIR_CM__CR__DIRECTOR_ASSESSMENT: + IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT: 'idirComplianceManager.categories.complianceReports.directorAssessment' } } diff --git a/frontend/src/views/Notifications/NotificationMenu/components/IDIRDirectorNotificationSettings.jsx b/frontend/src/views/Notifications/NotificationMenu/components/IDIRDirectorNotificationSettings.jsx index 6f9c6148b..2c5e366d9 100644 --- a/frontend/src/views/Notifications/NotificationMenu/components/IDIRDirectorNotificationSettings.jsx +++ b/frontend/src/views/Notifications/NotificationMenu/components/IDIRDirectorNotificationSettings.jsx @@ -5,17 +5,17 @@ const IDIRDirectorNotificationSettings = () => { const categories = { 'idirDirector.categories.transfers': { title: 'idirDirector.categories.transfers.title', - IDIR_D__TR__ANALYST_RECOMMENDATION: + IDIR_DIRECTOR__TRANSFER__ANALYST_RECOMMENDATION: 'idirDirector.categories.transfers.analystRecommendation' }, 'idirDirector.categories.initiativeAgreements': { title: 'idirDirector.categories.initiativeAgreements.title', - IDIR_D__IA__ANALYST_RECOMMENDATION: + IDIR_DIRECTOR__INITIATIVE_AGREEMENT__ANALYST_RECOMMENDATION: 'idirDirector.categories.initiativeAgreements.analystRecommendation' }, 'idirDirector.categories.complianceReports': { title: 'idirDirector.categories.complianceReports.title', - IDIR_D__CR__MANAGER_RECOMMENDATION: + IDIR_DIRECTOR__COMPLIANCE_REPORT__MANAGER_RECOMMENDATION: 'idirDirector.categories.complianceReports.managerRecommendation' } } diff --git a/frontend/src/views/Notifications/NotificationMenu/components/NotificationSettingsForm.jsx b/frontend/src/views/Notifications/NotificationMenu/components/NotificationSettingsForm.jsx index ad29117d9..6fd482744 100644 --- a/frontend/src/views/Notifications/NotificationMenu/components/NotificationSettingsForm.jsx +++ b/frontend/src/views/Notifications/NotificationMenu/components/NotificationSettingsForm.jsx @@ -68,9 +68,9 @@ const NotificationSettingsForm = ({ useEffect(() => { if (subscriptionsData) { subscriptionsData.forEach((subscription) => { - const { notificationTypeKey, notificationChannelKey, isEnabled } = + const { notificationTypeName, notificationChannelName, isEnabled } = subscription - const fieldName = `${notificationTypeKey}_${notificationChannelKey}` + const fieldName = `${notificationTypeName}_${notificationChannelName}` setValue(fieldName, isEnabled) }) } @@ -80,8 +80,8 @@ const NotificationSettingsForm = ({ }, [subscriptionsData, showEmailField, initialEmail, setValue]) const handleCheckboxChange = async ( - notificationTypeKey, - notificationChannelKey, + notificationTypeName, + notificationChannelName, isChecked ) => { setIsFormLoading(true) @@ -90,16 +90,16 @@ const NotificationSettingsForm = ({ if (isChecked) { // Create the subscription await createSubscription.mutateAsync({ - notificationTypeKey, - notificationChannelKey, + notificationTypeName, + notificationChannelName, isEnabled: true }) } else { // Find the subscription ID const subscription = subscriptionsData.find( (sub) => - sub.notificationTypeKey === notificationTypeKey && - sub.notificationChannelKey === notificationChannelKey + sub.notificationTypeName === notificationTypeName && + sub.notificationChannelName === notificationChannelName ) if (subscription) { // Delete the subscription @@ -127,10 +127,6 @@ const NotificationSettingsForm = ({ }) setMessage(t('messages.emailSaved')) } - - if (!showEmailField) { - navigate(ROUTES.NOTIFICATIONS) - } } catch (err) { setMessage(t('errors.operationFailed')) } finally { @@ -197,7 +193,7 @@ const NotificationSettingsForm = ({ md: 'column' // Column layout on larger screens }, justifyContent: 'flex-end', - alignItems: 'flex-end', + alignItems: 'flex-start', gap: 2, mt: { xs: 0, // Margin-top on mobile devices @@ -220,7 +216,9 @@ const NotificationSettingsForm = ({ } }} > - + {t('emailNotification')} @@ -238,7 +236,9 @@ const NotificationSettingsForm = ({ } }} > - + {t('inAppNotification')} @@ -264,7 +264,13 @@ const NotificationSettingsForm = ({ paddingTop: index === 0 ? 1 : 4 }} > - + - + - - {t('saveButton')} - + {showEmailField && ( + + {t('saveButton')} + + )} { const subscriptionsData = [ { - notificationTypeKey: 'notificationType1', - notificationChannelKey: 'EMAIL', + notificationTypeName: 'notificationType1', + notificationChannelName: 'EMAIL', isEnabled: true, notificationChannelSubscriptionId: '1' }, { - notificationTypeKey: 'notificationType2', - notificationChannelKey: 'IN_APP', + notificationTypeName: 'notificationType2', + notificationChannelName: 'IN_APP', isEnabled: true, notificationChannelSubscriptionId: '2' }