[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
This commit is contained in:
guru_sainath
2025-08-19 01:28:02 +05:30
committed by GitHub
parent 83c09c1250
commit d28d157088
10 changed files with 878 additions and 15 deletions

View File

@@ -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

View File

@@ -0,0 +1 @@
from .issue import issue_push_notifications

View File

@@ -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)}

View File

@@ -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

View File

@@ -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

View File

@@ -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}"

View File

@@ -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)()

View File

@@ -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(

View File

@@ -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

View File

@@ -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