diff --git a/backend/lcfs/db/migrations/versions/2024-12-05-02-19_b69a33bbd135.py b/backend/lcfs/db/migrations/versions/2024-12-05-02-19_b69a33bbd135.py new file mode 100644 index 000000000..3f1477892 --- /dev/null +++ b/backend/lcfs/db/migrations/versions/2024-12-05-02-19_b69a33bbd135.py @@ -0,0 +1,34 @@ +"""Add notifications email to user_profile + +Revision ID: b69a33bbd135 +Revises: 8491890dd688 +Create Date: 2024-12-05 20:48:24.724112 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "b69a33bbd135" +down_revision = "8491890dd688" +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-12-05-02-20_d4104af84f2b.py b/backend/lcfs/db/migrations/versions/2024-12-05-02-20_d4104af84f2b.py new file mode 100644 index 000000000..a77e0cb43 --- /dev/null +++ b/backend/lcfs/db/migrations/versions/2024-12-05-02-20_d4104af84f2b.py @@ -0,0 +1,87 @@ +"""Update Notification Types and remove data + +Revision ID: d4104af84f2b +Revises: b69a33bbd135 +Create Date: 2024-12-05 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/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 2311d834d..9c8f31914 100644 --- a/backend/lcfs/db/models/notification/NotificationType.py +++ b/backend/lcfs/db/models/notification/NotificationType.py @@ -1,28 +1,17 @@ -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/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/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/notifications_seeder.py b/backend/lcfs/db/seeders/common/notifications_seeder.py index e1bfe6fc8..363ae6c98 100644 --- a/backend/lcfs/db/seeders/common/notifications_seeder.py +++ b/backend/lcfs/db/seeders/common/notifications_seeder.py @@ -58,44 +58,121 @@ 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__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT", + "description": "Director assessed a compliance report or supplemental report.", + "email_content": "Email content", "create_user": "system", "update_user": "system", }, { - "name": "TRANSFER_DIRECTOR_REVIEW", - "description": "Director review notification", - "email_content": "Email content for director review", + "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": "INITIATIVE_APPROVED", - "description": "Initiative approved notification", - "email_content": "Email content for initiative approval", + "name": "BCEID__TRANSFER__DIRECTOR_DECISION", + "description": "Director recorded or refused a transfer request", + "email_content": "Email content", "create_user": "system", "update_user": "system", }, { - "name": "INITIATIVE_DA_REQUEST", - "description": "DA request notification", - "email_content": "Email content for DA request", + "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": "SUPPLEMENTAL_REQUESTED", - "description": "Supplemental requested notification", - "email_content": "Email content for supplemental request", + "name": "IDIR_ANALYST__COMPLIANCE_REPORT__DIRECTOR_DECISION", + "description": "Director assessed compliance report", + "email_content": "Email content", "create_user": "system", "update_user": "system", }, { - "name": "DIRECTOR_ASSESSMENT", - "description": "Director assessment notification", - "email_content": "Email content for director assessment", + "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_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_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_ANALYST__TRANSFER__DIRECTOR_RECORDED", + "description": "Director recorded or refused a transfer request", + "email_content": "Email content", + "create_user": "system", + "update_user": "system", + }, + { + "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_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_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_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT", + "description": "Director assessed a compliance report", + "email_content": "Email content", + "create_user": "system", + "update_user": "system", + }, + { + "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_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_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_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_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/tests/notification/test_notification_services.py b/backend/lcfs/tests/notification/test_notification_services.py index 2d2cc6ef7..a23b49388 100644 --- a/backend/lcfs/tests/notification/test_notification_services.py +++ b/backend/lcfs/tests/notification/test_notification_services.py @@ -272,49 +272,41 @@ 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_name="EMAIL", + notification_type_name="BCEID__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT", ) + 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 @pytest.mark.anyio @@ -322,23 +314,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_name"] == "email" + assert subscription["notification_type_name"] == "new_message" + mock_repo.get_notification_channel_subscriptions_by_user.assert_awaited_once_with( user_id ) @@ -369,63 +371,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 +388,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..7a7e2fa93 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_name="EMAIL", + notification_type_name="BCEID__COMPLIANCE_REPORT__DIRECTOR_ASSESSMENT", + 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..56cb75403 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,33 @@ 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" 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/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 12788666e..c12689ee4 100644 --- a/backend/lcfs/web/api/notification/repo.py +++ b/backend/lcfs/web/api/notification/repo.py @@ -1,20 +1,20 @@ from lcfs.db.models.notification import ( NotificationChannelSubscription, NotificationMessage, + NotificationChannel, + NotificationType, + ChannelEnum, ) -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 @@ -162,12 +162,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 name and notification type name. """ - 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() @@ -202,18 +207,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 @@ -227,3 +220,28 @@ async def delete_notification_channel_subscription( ) await self.db.execute(query) await self.db.flush() + + @repo_handler + async def get_notification_type_by_name(self, name: str) -> Optional[int]: + """ + Retrieve a NotificationType by its name + """ + query = select(NotificationType.notification_type_id).where( + NotificationType.name == name + ) + result = await self.db.execute(query) + x = result.scalars().first() + return x + + @repo_handler + async def get_notification_channel_by_name( + self, name: ChannelEnum + ) -> Optional[int]: + """ + Retrieve a NotificationChannel by its name + """ + query = select(NotificationChannel.notification_channel_id).where( + 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 e6a17504c..419f212c5 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_name: Optional[str] = None + user_profile_id: Optional[int] = 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 5340df504..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, @@ -9,7 +10,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 @@ -106,21 +106,42 @@ 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_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 name: {name}") + return notification_type + + @service_handler + 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 name: {name}") + 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. - """ + 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_name( + subscription_data.notification_type_name + ) + 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 +149,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_names = [ + { + "notification_channel_subscription_id": subscription.notification_channel_subscription_id, + "notification_channel_name": subscription.notification_channel.channel_name.name, + "notification_type_name": subscription.notification_type.name, + } + for subscription in subscriptions + ] + + return subscriptions_with_names @service_handler async def get_notification_channel_subscription_by_id( @@ -141,36 +175,17 @@ async def get_notification_channel_subscription_by_id( notification_channel_subscription_id ) - @service_handler - async def update_notification_channel_subscription( - self, subscription_data: SubscriptionSchema - ): - """ - Update an existing notification channel subscription. - """ - subscription = NotificationChannelSubscription( - **subscription_data.model_dump(exclude={"deleted"}) - ) - 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..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, @@ -49,7 +48,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 +67,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 +116,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..c1906c34c 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,23 @@ 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() + + # 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 f9ec1948b..127024c7d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -7,8 +7,8 @@ import { ViewUser } from '@/views/Admin/AdminMenu/components/ViewUser' import { ComplianceReports } from './views/ComplianceReports' 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, @@ -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/constants/notificationTypes.js b/frontend/src/constants/notificationTypes.js new file mode 100644 index 000000000..c694e5fc9 --- /dev/null +++ b/frontend/src/constants/notificationTypes.js @@ -0,0 +1,46 @@ +export const notificationTypes = { + // BCEID + 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_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_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_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 = { + EMAIL: 'EMAIL', + IN_APP: 'IN_APP' +} diff --git a/frontend/src/constants/routes/apiRoutes.js b/frontend/src/constants/routes/apiRoutes.js index 4dbef0d6d..f9c089581 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 0a6c6d3d2..78c3978a8 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 87c2bf676..000000000 --- a/frontend/src/layouts/MainLayout/components/Logout.jsx +++ /dev/null @@ -1,55 +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 a4ee6122b..705466fe4 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 = () => { @@ -34,7 +34,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 @@ -53,7 +56,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..f5f7dbc68 --- /dev/null +++ b/frontend/src/layouts/MainLayout/components/UserProfileActions.jsx @@ -0,0 +1,134 @@ +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..74af3935c --- /dev/null +++ b/frontend/src/views/Notifications/NotificationMenu/components/BCeIDNotificationSettings.jsx @@ -0,0 +1,37 @@ +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__TRANSFER__PARTNER_ACTIONS: + 'bceid.categories.transfers.partnerActions', + BCEID__TRANSFER__DIRECTOR_DECISION: + 'bceid.categories.transfers.directorDecision' + }, + 'bceid.categories.initiativeAgreements': { + title: 'bceid.categories.initiativeAgreements.title', + BCEID__INITIATIVE_AGREEMENT__DIRECTOR_APPROVAL: + 'bceid.categories.initiativeAgreements.directorApproval' + }, + 'bceid.categories.complianceReports': { + title: 'bceid.categories.complianceReports.title', + BCEID__COMPLIANCE_REPORT__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..731dedd68 --- /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_ANALYST__TRANSFER__SUBMITTED_FOR_REVIEW: + 'idirAnalyst.categories.transfers.submittedForReview', + IDIR_ANALYST__TRANSFER__RESCINDED_ACTION: + 'idirAnalyst.categories.transfers.rescindedAction', + IDIR_ANALYST__TRANSFER__DIRECTOR_RECORDEDIDIR_A__TR__DIRECTOR_RECORDED: + 'idirAnalyst.categories.transfers.directorRecorded' + }, + 'idirAnalyst.categories.initiativeAgreements': { + title: 'idirAnalyst.categories.initiativeAgreements.title', + IDIR_ANALYST__INITIATIVE_AGREEMENT__RETURNED_TO_ANALYST: + 'idirAnalyst.categories.initiativeAgreements.returnedToAnalyst' + }, + 'idirAnalyst.categories.complianceReports': { + title: 'idirAnalyst.categories.complianceReports.title', + IDIR_ANALYST__COMPLIANCE_REPORT__SUBMITTED_FOR_REVIEW: + 'idirAnalyst.categories.complianceReports.submittedForReview', + IDIR_ANALYST__COMPLIANCE_REPORT__MANAGER_RECOMMENDATION: + 'idirAnalyst.categories.complianceReports.managerRecommendation', + IDIR_ANALYST__COMPLIANCE_REPORT__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..ee76d9958 --- /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_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__SUBMITTED_FOR_REVIEW: + 'idirComplianceManager.categories.complianceReports.submittedForReview', + IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__ANALYST_RECOMMENDATION: + 'idirComplianceManager.categories.complianceReports.analystRecommendation', + IDIR_COMPLIANCE_MANAGER__COMPLIANCE_REPORT__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..2c5e366d9 --- /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_DIRECTOR__TRANSFER__ANALYST_RECOMMENDATION: + 'idirDirector.categories.transfers.analystRecommendation' + }, + 'idirDirector.categories.initiativeAgreements': { + title: 'idirDirector.categories.initiativeAgreements.title', + IDIR_DIRECTOR__INITIATIVE_AGREEMENT__ANALYST_RECOMMENDATION: + 'idirDirector.categories.initiativeAgreements.analystRecommendation' + }, + 'idirDirector.categories.complianceReports': { + title: 'idirDirector.categories.complianceReports.title', + IDIR_DIRECTOR__COMPLIANCE_REPORT__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..6fd482744 --- /dev/null +++ b/frontend/src/views/Notifications/NotificationMenu/components/NotificationSettingsForm.jsx @@ -0,0 +1,479 @@ +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 { notificationTypeName, notificationChannelName, isEnabled } = + subscription + const fieldName = `${notificationTypeName}_${notificationChannelName}` + setValue(fieldName, isEnabled) + }) + } + if (showEmailField && initialEmail) { + setValue('notificationEmail', initialEmail) + } + }, [subscriptionsData, showEmailField, initialEmail, setValue]) + + const handleCheckboxChange = async ( + notificationTypeName, + notificationChannelName, + isChecked + ) => { + setIsFormLoading(true) + setMessage('') // Clear any existing messages + try { + if (isChecked) { + // Create the subscription + await createSubscription.mutateAsync({ + notificationTypeName, + notificationChannelName, + isEnabled: true + }) + } else { + // Find the subscription ID + const subscription = subscriptionsData.find( + (sub) => + sub.notificationTypeName === notificationTypeName && + sub.notificationChannelName === notificationChannelName + ) + 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')) + } + } 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 */} + + + {showEmailField && ( + + {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..b2190e1a1 --- /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 = [ + { + notificationTypeName: 'notificationType1', + notificationChannelName: 'EMAIL', + isEnabled: true, + notificationChannelSubscriptionId: '1' + }, + { + notificationTypeName: 'notificationType2', + notificationChannelName: '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/__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'