From d28d157088b2f6855ac74ffc75af64a0f2b9bb8a Mon Sep 17 00:00:00 2001 From: guru_sainath Date: Tue, 19 Aug 2025 01:28:02 +0530 Subject: [PATCH] [MOB-1071] feat: Update push notification functionality and add notification count badge (#3923) * dev: updated push notification workflow * dev: Notification count query * dev: updating the device info mutation * dev: handled unread and read notifications in notifications count and user level unread notification count in push notification * dev: updated relations and comment in push notifications * dev: cleaned up prints * dev: updated notification count --- apps/api/plane/bgtasks/notification_task.py | 4 +- .../bgtasks/push_notifications/__init__.py | 1 + .../bgtasks/push_notifications/firebase.py | 125 ++++++++ .../bgtasks/push_notifications/helper.py | 76 +++++ .../bgtasks/push_notifications/issue.py | 280 ++++++++++++++++++ .../push_notifications/issue_builder.py | 279 +++++++++++++++++ apps/api/plane/graphql/mutations/device.py | 2 +- .../api/plane/graphql/queries/notification.py | 105 ++++++- apps/api/plane/graphql/schema.py | 3 +- apps/api/plane/graphql/types/notification.py | 18 ++ 10 files changed, 878 insertions(+), 15 deletions(-) create mode 100644 apps/api/plane/graphql/bgtasks/push_notifications/__init__.py create mode 100644 apps/api/plane/graphql/bgtasks/push_notifications/firebase.py create mode 100644 apps/api/plane/graphql/bgtasks/push_notifications/helper.py create mode 100644 apps/api/plane/graphql/bgtasks/push_notifications/issue.py create mode 100644 apps/api/plane/graphql/bgtasks/push_notifications/issue_builder.py diff --git a/apps/api/plane/bgtasks/notification_task.py b/apps/api/plane/bgtasks/notification_task.py index 5989b39d5d..ec6980e183 100644 --- a/apps/api/plane/bgtasks/notification_task.py +++ b/apps/api/plane/bgtasks/notification_task.py @@ -20,9 +20,9 @@ from plane.db.models import ( UserNotificationPreference, ProjectMember, ) -from django.db.models import Subquery, Q +from django.db.models import Subquery from plane.app.serializers import NotificationSerializer -from plane.graphql.bgtasks.push_notification import issue_push_notifications +from plane.graphql.bgtasks.push_notifications import issue_push_notifications # Third Party imports from celery import shared_task diff --git a/apps/api/plane/graphql/bgtasks/push_notifications/__init__.py b/apps/api/plane/graphql/bgtasks/push_notifications/__init__.py new file mode 100644 index 0000000000..ff4bfa549d --- /dev/null +++ b/apps/api/plane/graphql/bgtasks/push_notifications/__init__.py @@ -0,0 +1 @@ +from .issue import issue_push_notifications diff --git a/apps/api/plane/graphql/bgtasks/push_notifications/firebase.py b/apps/api/plane/graphql/bgtasks/push_notifications/firebase.py new file mode 100644 index 0000000000..f6e6c36479 --- /dev/null +++ b/apps/api/plane/graphql/bgtasks/push_notifications/firebase.py @@ -0,0 +1,125 @@ +"""Firebase Cloud Messaging (FCM) Implementation""" + +import base64 + +# Python imports +from typing import Optional + +# Third party imports +import firebase_admin + +# Django imports +from django.conf import settings +from firebase_admin import credentials, messaging + +# Module imports +from .helper import is_mobile_push_notification_disabled + + +class PushNotification: + """Handles push notifications using Firebase Cloud Messaging.""" + + def __init__(self): + """Initialize Firebase credentials on instantiation.""" + self.initialize_firebase() + + def decode_private_key(self, base64_string): + """Decode the base64 encoded private key.""" + try: + decoded_bytes = base64.b64decode(base64_string) + private_key = decoded_bytes.decode("utf-8") + + if "-----BEGIN PRIVATE KEY-----" not in private_key: + private_key = ( + f"-----BEGIN PRIVATE KEY-----\n" + f"{private_key}\n" + f"-----END PRIVATE KEY-----" + ) + + return private_key + except Exception as e: + print(f"Error decoding private key: {str(e)}") + return None + + def initialize_firebase(self) -> None: + """Initialize Firebase Cloud Messaging with credentials from settings.""" + try: + if is_mobile_push_notification_disabled(): + return + + # convert private key from base64 to bytes + private_key = ( + self.decode_private_key(settings.FIREBASE_PRIVATE_KEY) + if settings.FIREBASE_PRIVATE_KEY + else None + ) + + if private_key is not None: + firebase_credentials = credentials.Certificate( + { + "type": "service_account", + "project_id": settings.FIREBASE_PROJECT_ID, + "private_key_id": settings.FIREBASE_PRIVATE_KEY_ID, + "private_key": private_key, + "client_email": settings.FIREBASE_CLIENT_EMAIL, + "client_id": settings.FIREBASE_CLIENT_ID, + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "client_x509_cert_url": settings.FIREBASE_CLIENT_CERT_URL, + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "universe_domain": "googleapis.com", + } + ) + if not firebase_admin._apps: + firebase_admin.initialize_app(firebase_credentials) + return + except Exception as e: + print(f"Error initializing Firebase: {e}") + raise + + def send( + self, + title: str, + body: str, + device_token_id: str, + data: Optional[dict] = {}, + notification_count: Optional[int] = 0, + ) -> dict: + """Send push notifications to specified devices.""" + if is_mobile_push_notification_disabled(): + return "Mobile push notifications are disabled" + + try: + if not all([title, body, device_token_id]): + raise ValueError("Title, body, and device_token_id are required") + + message = messaging.Message( + notification=messaging.Notification(title=title, body=body), + data=data or {}, + token=device_token_id, + apns=messaging.APNSConfig( + payload=messaging.APNSPayload( + aps=messaging.Aps(badge=notification_count, sound="default") + ) + ), + android=messaging.AndroidConfig( + notification=messaging.AndroidNotification( + notification_count=notification_count, + sound="default", + ), + ), + ) + + response = messaging.send(message) + + return { + "notification_success_count": response.success_count + if response and hasattr(response, "success_count") + else 0, + "notification_failure_count": response.failure_count + if response and hasattr(response, "failure_count") + else 0, + } + except Exception as e: + print(f"Error sending push notification: {e}") + return {"status": "error", "error": str(e)} diff --git a/apps/api/plane/graphql/bgtasks/push_notifications/helper.py b/apps/api/plane/graphql/bgtasks/push_notifications/helper.py new file mode 100644 index 0000000000..ffe0744299 --- /dev/null +++ b/apps/api/plane/graphql/bgtasks/push_notifications/helper.py @@ -0,0 +1,76 @@ +# Python imports +from typing import Optional + +# Django imports +from django.conf import settings + +# Module imports +from plane.db.models import Device, Notification, Workspace + + +def is_mobile_push_notification_disabled() -> bool: + """Check if push notification is disabled.""" + if hasattr(settings, "IS_MOBILE_PUSH_NOTIFICATION_ENABLED"): + return False if settings.IS_MOBILE_PUSH_NOTIFICATION_ENABLED else True + return True + + +def fetch_device_tokens_by_user_id(user_id: str) -> list[str]: + """Fetch device tokens for the user.""" + device_tokens = [] + + try: + devices = Device.objects.filter( + user_id=user_id, + is_active=True, + push_token__isnull=False, + deleted_at__isnull=True, + ) + for device in devices: + device_tokens.append(device.push_token) + except Exception as e: + print(f"Error fetching device tokens: {e}") + + return device_tokens + + +def notification_count( + user_id: str, + workspace_slug: Optional[str] = None, + mentioned: bool = False, + combined: bool = False, +) -> int: + """Fetch unread notification count for the user.""" + + try: + notification_query = Notification.objects.filter( + receiver_id=user_id, + read_at__isnull=True, + snoozed_till__isnull=True, + archived_at__isnull=True, + ) + + # filter by workspace + if workspace_slug: + workspace = Workspace.objects.get(slug=workspace_slug) + notification_query = notification_query.filter(workspace=workspace) + + # filter by mentioned + if combined: + if mentioned: + notification_query = notification_query.filter( + sender__icontains="mentioned" + ) + else: + notification_query = notification_query.exclude( + sender__icontains="mentioned" + ) + + total_notification_count = notification_query.count() + + return total_notification_count + except Workspace.DoesNotExist: + return 0 + except Exception as e: + print(f"Error fetching unread notification count: {e}") + return 0 diff --git a/apps/api/plane/graphql/bgtasks/push_notifications/issue.py b/apps/api/plane/graphql/bgtasks/push_notifications/issue.py new file mode 100644 index 0000000000..82fbaa45b3 --- /dev/null +++ b/apps/api/plane/graphql/bgtasks/push_notifications/issue.py @@ -0,0 +1,280 @@ +# Python imports +from enum import Enum + +# Third party imports +from celery import shared_task + +# Module imports +from plane.db.models import User, Workspace + +# Local module imports +from .helper import ( + fetch_device_tokens_by_user_id, + is_mobile_push_notification_disabled, + notification_count, +) +from .issue_builder import Actor, IssueNotificationBuilder +from .firebase import PushNotification + + +class IssuePushNotificationTypes(Enum): + """Enumeration of supported push notification types.""" + + ISSUE_CREATED = "ISSUE_CREATED" + ISSUE_UPDATED = "ISSUE_UPDATED" + EPIC_CREATED = "EPIC_CREATED" + EPIC_UPDATED = "EPIC_UPDATED" + INTAKE_CREATED = "INTAKE_CREATED" + INTAKE_UPDATED = "INTAKE_UPDATED" + + def __str__(self) -> str: + return self.value + + +def construct_issue_notification_details(notification: dict) -> dict: + """Construct the issue push notification details.""" + try: + # notification details + notification_id = notification.get("id", "") + + # notification issue and issue activity details + notification_issue = notification.get("data", {}).get("issue", {}) + notification_issue_activity = notification.get("data", {}).get( + "issue_activity", {} + ) + + # sender actor details + actor_details = notification.get("triggered_by_details", {}) + actor_id = actor_details.get("id", "") + actor_display_name = actor_details.get("display_name", "") + actor_first_name = actor_details.get("first_name", "") + actor_last_name = actor_details.get("last_name", "") + actor_name = ( + actor_display_name + if actor_display_name + else f"{actor_first_name} {actor_last_name}" + ) + + # receiver actor details + receiver_id = notification.get("receiver", None) + receiver_name = "" + if receiver_id is not None: + receiver_details = User.objects.filter(id=receiver_id).first() + if receiver_details: + receiver_display_name = receiver_details.display_name or "" + receiver_first_name = receiver_details.first_name or "" + receiver_last_name = receiver_details.last_name or "" + receiver_name = ( + receiver_display_name + if receiver_display_name + else f"{receiver_first_name} {receiver_last_name}" + ) + + # workspace details + workspace_id = notification.get("workspace", "") + workspace_slug = "" + workspace = Workspace.objects.filter(id=workspace_id).first() + if workspace and workspace.slug: + workspace_slug = workspace.slug + + # project details + project_id = notification.get("project", "") + project_identifier = notification_issue.get("identifier", "") + + # getting the push token from the device with receiver_id + receiver_push_tokens = [] + if receiver_id is not None: + receiver_push_tokens = fetch_device_tokens_by_user_id(receiver_id) + if len(receiver_push_tokens) == 0: + print("Receiver push token is not available") + return + + # push notification details + push_notification = { + "id": notification_id, + "workspace": { + "id": workspace_id, + "slug": workspace_slug, + }, + "project": { + "id": project_id, + "identifier": project_identifier, + }, + "sender": {"id": actor_id, "name": actor_name}, + "receiver": { + "id": receiver_id, + "name": receiver_name, + "push_tokens": receiver_push_tokens, + }, + "issue": { + "id": notification_issue.get("id", ""), + "sequence_id": notification_issue.get("sequence_id", ""), + "name": notification_issue.get("name", ""), + }, + "activity": { + "id": notification_issue_activity.get("id", ""), + "verb": notification_issue_activity.get("verb", ""), + "field": notification_issue_activity.get("field", ""), + "actor": notification_issue_activity.get("actor", ""), + "new_value": notification_issue_activity.get("new_value", ""), + "old_value": notification_issue_activity.get("old_value", ""), + "old_identifier": notification_issue_activity.get("old_identifier", ""), + "issue_comment": notification_issue_activity.get("issue_comment", ""), + "new_identifier": notification_issue_activity.get("new_identifier", ""), + "old_identifier_type": notification_issue_activity.get( + "old_identifier_type", "" + ), + }, + } + + return push_notification + except Exception as e: + print(f"Error constructing issue notification details: {e}") + raise e + + +@shared_task +def issue_push_notifications(notification): + try: + print("=== sending issue properties push notification to mobile ===") + + if is_mobile_push_notification_disabled(): + return + + notification = construct_issue_notification_details(notification) + if notification is None: + print("=== Notification is None ===") + return + + notification_id = notification.get("id", None) + notification_sender = notification.get("sender", None) + notification_receiver = notification.get("receiver", None) + notification_workspace = notification.get("workspace", None) + notification_project = notification.get("project", None) + notification_issue = notification.get("issue", None) + notification_issue_activity = notification.get("activity", None) + + if not all( + [ + notification_receiver, + notification_sender, + notification_issue, + notification_issue_activity, + ] + ): + return + + # sender details + sender_id = notification_sender.get("id", "") + sender_name = notification_sender.get("name", "") + + # receiver details + receiver_id = notification_receiver.get("id", "") + receiver_name = notification_receiver.get("name", "") + + # workspace details + workspace_slug = notification_workspace.get("slug", "") + + # project details + project_id = notification_project.get("id", "") + project_identifier = notification_project.get("identifier", "") + + # issue details + issue_id = notification_issue.get("id", "") + issue_name = notification_issue.get("name", "") + issue_sequence_id = notification_issue.get("sequence_id", "") + + # issue activity details + issue_activity_id = notification_issue_activity.get("id", "") + issue_activity_field = notification_issue_activity.get("field", None) + issue_activity_new_value = notification_issue_activity.get( + "new_identifier", None + ) + issue_comment_id = ( + issue_activity_new_value + if issue_activity_field == "comment" and issue_activity_new_value + else "" + ) + + # unread notification count + unread_notification_count = notification_count( + user_id=receiver_id, workspace_slug=None, mentioned=False, combined=True + ) + + # push tokens + receiver_push_tokens = notification_receiver.get("push_tokens", []) + mention_push_tokens = [] + + title = f"{project_identifier}-{issue_sequence_id} - {issue_name}" + data = { + # deprecated fields + "workspaceSlug": workspace_slug, + "projectId": project_id, + "issueId": issue_id, + "userId": receiver_id, + "notificationId": notification_id, + # new fields + "type": IssuePushNotificationTypes.ISSUE_UPDATED.value, + "user_id": receiver_id, + "workspace_slug": workspace_slug, + "project_id": project_id, + "issue_id": issue_id, + "epic_id": "none", + "intake_id": "none", + "notification_id": notification_id, + "issue_activity_id": issue_activity_id, + "issue_comment_id": issue_comment_id, + "unread_notification_count": str(unread_notification_count), + } + + property_key = notification_issue_activity.get("field", "") + old_value = notification_issue_activity.get("old_value", "") + new_value = notification_issue_activity.get("new_value", "") + old_identifier = notification_issue_activity.get("old_identifier", "") + new_identifier = notification_issue_activity.get("new_identifier", "") + + body = None + issue_notification_description_builder = IssueNotificationBuilder( + sender=Actor(id=sender_id, name=sender_name), + receiver=Actor(id=receiver_id, name=receiver_name), + property_key=property_key, + old_value=old_value, + new_value=new_value, + old_identifier=old_identifier, + new_identifier=new_identifier, + ) + body = issue_notification_description_builder.build_notification() or "" + + if body is None: + print("=== notification body is None ===") + return + + # push notification + push_notification = PushNotification() + + for token in receiver_push_tokens: + push_notification.send( + title=title, + body=body, + device_token_id=token, + data=data, + notification_count=unread_notification_count, + ) + + for token in mention_push_tokens: + push_notification.send( + title=title, + body=body, + device_token_id=token, + data=data, + notification_count=unread_notification_count, + ) + + print( + "=== sending issue properties push notification to mobile is successful ===" + ) + + return + except Exception as e: + print(f"Error sending issue push notification: {e}") + raise e diff --git a/apps/api/plane/graphql/bgtasks/push_notifications/issue_builder.py b/apps/api/plane/graphql/bgtasks/push_notifications/issue_builder.py new file mode 100644 index 0000000000..1737fc2296 --- /dev/null +++ b/apps/api/plane/graphql/bgtasks/push_notifications/issue_builder.py @@ -0,0 +1,279 @@ +# Python imports +from typing import TypedDict +from uuid import uuid4 + +# Third party imports +from bs4 import BeautifulSoup + +# Module imports +from plane.db.models import User + + +class Actor(TypedDict): + id: uuid4 + name: str | None + + +def construct_comment_content_with_mentions(html_content: str) -> dict: + soup = BeautifulSoup(html_content, "html.parser") + user_mentions = {} + + for mention_tag in soup.find_all( + "mention-component", attrs={"entity_name": "user_mention"} + ): + entity_identifier = mention_tag.get("entity_identifier", "") + user = User.objects.filter(id=entity_identifier).first() + user_mentions[entity_identifier] = user if user else None + mention_tag.replace_with( + user.display_name + if user.display_name + else user.first_name + if user.first_name + else user.email + if user + else "" + ) + + for tag in soup.find_all(True): + tag.replace_with(tag.text) + + plain_text = soup.get_text() + return { + "mention_objects": user_mentions, + "content": plain_text, + } + + +class IssueNotificationBuilder: + """Builds notification strings for issue property changes.""" + + def __init__( + self, + sender: Actor | None, + receiver: Actor | None, + property_key: str, + old_value: str | None, + new_value: str | None, + old_identifier: str | None, + new_identifier: str | None, + ): + self.sender = sender + self.receiver = receiver + self.property_key = property_key + self.old_value = old_value + self.new_value = new_value + self.old_identifier = old_identifier + self.new_identifier = new_identifier + + def _handle_name_change(self) -> str: + return f"set the name to '{self.new_value}'" + + def _handle_state_change(self) -> str: + return f"set the state to '{self.new_value}'" + + def _handle_priority_change(self) -> str: + return f"set the priority to '{self.new_value}'" + + def _handle_assignees_change(self) -> str: + final_string = "" + if self.new_identifier: + if self.new_identifier == self.receiver["id"]: + final_string += "added you" + else: + final_string += f"added a new assignee '{self.new_value}'" + else: + if self.old_identifier == self.receiver["id"]: + final_string += "removed you" + else: + final_string += f"removed the assignee '{self.old_value}'" + + return final_string + + def _handle_labels_change(self) -> str: + final_string = "" + if self.new_value: + final_string += f"added a new label '{self.new_value}'" + else: + final_string += f"removed the label '{self.old_value}'" + + return final_string + + def _handle_start_date_change(self) -> str: + final_string = "" + if self.new_value: + final_string += f"set the start date to '{self.new_value}'" + else: + final_string += "removed the start date" + + return final_string + + def _handle_target_date_change(self) -> str: + final_string = "" + if self.new_value: + final_string += f"set the due date to '{self.new_value}'" + else: + final_string += "removed the due date" + + return final_string + + def _handle_parent_change(self) -> str: + final_string = "" + if self.new_value: + final_string += f"set the parent to '{self.new_value}'" + else: + final_string += "removed the parent" + + return final_string + + def _handle_link_change(self) -> str: + final_string = "" + if self.new_value: + final_string += f"added a new link '{self.new_value}'" + else: + final_string += f"removed the link '{self.old_value}'" + + return final_string + + def _handle_attachment_change(self) -> str: + final_string = "" + if self.new_value: + final_string += "updated a new attachment" + else: + final_string += "removed an attachment" + + return final_string + + # Relationship handlers + def _handle_relates_to_change(self) -> str: + action = ( + "marked that this work item relates to" + if self.new_value + else "removed the relation from" + ) + return f"{action} {f'{self.new_value}' if self.new_value else self.old_value}" + + def _handle_duplicate_change(self) -> str: + action = ( + "marked that this work item as duplicate of" + if self.new_value + else "removed this work item as a duplicate of" + ) + return f"{action} {f'{self.new_value}' if self.new_value else self.old_value}" + + def _handle_blocked_by_change(self) -> str: + action = ( + "marked this work item is being blocked by" + if self.new_value + else "removed this work item being blocked by" + ) + return f"{action} {f'{self.new_value}' if self.new_value else self.old_value}" + + def _handle_blocking_change(self) -> str: + action = ( + "marked this work item is blocking work item" + if self.new_value + else "removed the blocking work item" + ) + return f"{action} {f'{self.new_value}' if self.new_value else self.old_value}" + + def _handle_start_before_change(self) -> str: + action = ( + "marked this work item to start before" + if self.new_value + else "removed the start before relation from work item" + ) + return f"{action} {f'{self.new_value}' if self.new_value else self.old_value}" + + def _handle_start_after_change(self) -> str: + action = ( + "marked this work item to start after" + if self.new_value + else "removed the start after relation from work item" + ) + return f"{action} {f'{self.new_value}' if self.new_value else self.old_value}" + + def _handle_finish_before_change(self) -> str: + action = ( + "marked this work item to finish before" + if self.new_value + else "removed the finish before relation from work item" + ) + return f"{action} {f'{self.new_value}' if self.new_value else self.old_value}" + + def _handle_finish_after_change(self) -> str: + action = ( + "marked this work item to finish after" + if self.new_value + else "removed the finish after relation from work item" + ) + return f"{action} {f'{self.new_value}' if self.new_value else self.old_value}" + + def _handle_comment_change(self) -> str: + comment_content = ( + None + if self.new_value == "None" and self.old_value == "None" + else self.new_value + ) + constructed_comment = ( + construct_comment_content_with_mentions(comment_content) + if comment_content + else None + ) + + is_receiver_mentioned = False + if constructed_comment and constructed_comment["mention_objects"] is not None: + is_receiver_mentioned = ( + self.receiver["id"] in constructed_comment["mention_objects"] + ) + + action = "" + if self.new_value == "None" and self.old_value == "None": + action = "removed the comment" + elif self.old_value != "None": + if is_receiver_mentioned: + action = "updated the comment and mentioned you" + else: + action = "updated the comment" + else: + if is_receiver_mentioned: + action = "mentioned you in a comment" + else: + action = "commented" + + return f"{action} '{constructed_comment['content']}'" + + def build_notification(self) -> str: + """Build and return the notification string for the property change.""" + + property_handlers = { + # Properties + "name": self._handle_name_change, + "state": self._handle_state_change, + "priority": self._handle_priority_change, + "assignees": self._handle_assignees_change, + "labels": self._handle_labels_change, + "start_date": self._handle_start_date_change, + "target_date": self._handle_target_date_change, + "parent": self._handle_parent_change, + # Links + "link": self._handle_link_change, + # Attachments + "attachment": self._handle_attachment_change, + # Relationships + "relates_to": self._handle_relates_to_change, + "duplicate": self._handle_duplicate_change, + "blocked_by": self._handle_blocked_by_change, + "blocking": self._handle_blocking_change, + "start_before": self._handle_start_before_change, + "start_after": self._handle_start_after_change, + "finish_before": self._handle_finish_before_change, + "finish_after": self._handle_finish_after_change, + # Comments + "comment": self._handle_comment_change, + } + + handler = property_handlers.get(self.property_key) + if handler: + return f"{self.sender['name']} {handler()}" + else: + return f"{self.actor['name']} updated {self.property_key}" diff --git a/apps/api/plane/graphql/mutations/device.py b/apps/api/plane/graphql/mutations/device.py index 124c7fac0c..a40bd57ed9 100644 --- a/apps/api/plane/graphql/mutations/device.py +++ b/apps/api/plane/graphql/mutations/device.py @@ -86,7 +86,7 @@ class DeviceInformationMutation: user_device_info.push_token = push_token await sync_to_async(user_device_info.save)() else: - user_device_info.is_active = False + user_device_info.is_active = True user_device_info.push_token = push_token await sync_to_async(user_device_info.save)() diff --git a/apps/api/plane/graphql/queries/notification.py b/apps/api/plane/graphql/queries/notification.py index 1bb9f46121..a4a41f3414 100644 --- a/apps/api/plane/graphql/queries/notification.py +++ b/apps/api/plane/graphql/queries/notification.py @@ -20,15 +20,96 @@ from plane.db.models import ( IssueAssignee, IssueSubscriber, Notification, + Workspace, WorkspaceMember, ) +from plane.graphql.bgtasks.push_notifications.helper import notification_count from plane.graphql.helpers.teamspace import project_member_filter_via_teamspaces_async -from plane.graphql.permissions.workspace import WorkspaceBasePermission -from plane.graphql.types.notification import NotificationType +from plane.graphql.permissions.workspace import IsAuthenticated, WorkspaceBasePermission +from plane.graphql.types.notification import ( + NotificationCountBaseType, + NotificationCountType, + NotificationCountWorkspaceType, + NotificationType, +) from plane.graphql.types.paginator import PaginatorResponse from plane.graphql.utils.paginator import paginate +@sync_to_async +def get_notification_count(user_id: str) -> NotificationCountBaseType: + unread_notification_count = notification_count( + user_id=user_id, workspace_slug=None, mentioned=False, combined=False + ) + mentioned_notification_count = notification_count( + user_id=user_id, workspace_slug=None, mentioned=True, combined=False + ) + + return NotificationCountBaseType( + unread=unread_notification_count, mentioned=mentioned_notification_count + ) + + +@sync_to_async +def get_notification_count_by_workspaces( + user_id: str, +) -> NotificationCountWorkspaceType: + user_workspaces = Workspace.objects.filter( + workspace_member__member=user_id, workspace_member__is_active=True + ) + + workspaces_notification_counts = [] + + if user_workspaces.exists(): + for workspace in user_workspaces: + workspace_id = str(workspace.id) + workspace_slug = workspace.slug + workspace_name = workspace.name + + unread_notification_count = notification_count( + user_id=user_id, + workspace_slug=workspace_slug, + mentioned=False, + combined=False, + ) + mentioned_notification_count = notification_count( + user_id=user_id, + workspace_slug=workspace_slug, + mentioned=True, + combined=False, + ) + + workspace_notification_count = NotificationCountWorkspaceType( + id=workspace_id, + slug=workspace_slug, + name=workspace_name, + unread=unread_notification_count, + mentioned=mentioned_notification_count, + ) + workspaces_notification_counts.append(workspace_notification_count) + + return workspaces_notification_counts + + +@strawberry.type +class NotificationCountQuery: + @strawberry.field(extensions=[PermissionExtension(permissions=[IsAuthenticated()])]) + async def notification_count(self, info: Info) -> NotificationCountType: + user = info.context.user + user_id = str(user.id) + + global_notification_count = await get_notification_count(user_id=user_id) + workspaces_notification_counts = await get_notification_count_by_workspaces( + user_id=user_id + ) + + return NotificationCountType( + unread=global_notification_count.unread, + mentioned=global_notification_count.mentioned, + workspaces=workspaces_notification_counts, + ) + + @strawberry.type class NotificationQuery: @strawberry.field( @@ -41,7 +122,7 @@ class NotificationQuery: type: Optional[JSON] = "all", snoozed: Optional[bool] = None, archived: Optional[bool] = None, - mentioned: Optional[bool] = False, + mentioned: Optional[bool] = None, read: Optional[str] = None, cursor: Optional[str] = None, ) -> PaginatorResponse[NotificationType]: @@ -101,17 +182,19 @@ class NotificationQuery: ) # Apply mentioned filter - if mentioned: - notification_queryset = notification_queryset.filter( - sender__icontains="mentioned" - ) - else: - notification_queryset = notification_queryset.exclude( - sender__icontains="mentioned" - ) + if mentioned is not None: + if mentioned: + notification_queryset = notification_queryset.filter( + sender__icontains="mentioned" + ) + else: + notification_queryset = notification_queryset.exclude( + sender__icontains="mentioned" + ) # Subscribed issues type_list = type.split(",") + # Subscribed issues if "subscribed" in type_list: issue_ids = await sync_to_async(list)( IssueSubscriber.objects.filter( diff --git a/apps/api/plane/graphql/schema.py b/apps/api/plane/graphql/schema.py index 0e9a03ef59..06481f05c8 100644 --- a/apps/api/plane/graphql/schema.py +++ b/apps/api/plane/graphql/schema.py @@ -138,7 +138,7 @@ from .queries.module import ( ModuleIssueUserPropertyQuery, ModuleQuery, ) -from .queries.notification import NotificationQuery +from .queries.notification import NotificationQuery, NotificationCountQuery from .queries.page import ( NestedChildPagesQuery, NestedParentPagesQuery, @@ -205,6 +205,7 @@ class Query( UserProjectRolesQuery, # notification NotificationQuery, + NotificationCountQuery, # search GlobalSearchQuery, # workspace diff --git a/apps/api/plane/graphql/types/notification.py b/apps/api/plane/graphql/types/notification.py index 11070b8c5b..226721c161 100644 --- a/apps/api/plane/graphql/types/notification.py +++ b/apps/api/plane/graphql/types/notification.py @@ -20,6 +20,24 @@ from plane.graphql.utils.issue_activity import issue_activity_comment_string from plane.graphql.utils.timezone import user_timezone_converter +@strawberry.type +class NotificationCountBaseType: + unread: Optional[int] + mentioned: Optional[int] + + +@strawberry.type +class NotificationCountWorkspaceType(NotificationCountBaseType): + id: str + slug: str + name: str + + +@strawberry.type +class NotificationCountType(NotificationCountBaseType): + workspaces: Optional[list[NotificationCountWorkspaceType]] + + @strawberry_django.type(Notification) class NotificationType: id: strawberry.ID