From f27efb80e1066ee387f4689562dda01ba06578fc Mon Sep 17 00:00:00 2001 From: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Date: Tue, 23 Jan 2024 17:49:22 +0530 Subject: [PATCH] dev: email notifications (#3421) * dev: create email notification preference model * dev: intiate models * dev: user notification preferences * dev: create notification logs for the user. * dev: email notification stacking and sending logic * feat: email notification preference settings page. * dev: delete subscribers * dev: issue update ui implementation in email notification * chore: integrate email notification endpoint. * chore: remove toggle switch. * chore: added labels part * fix: refactored base design with tables * dev: email notification templates * dev: template updates * dev: update models * dev: update template for labels and new migrations * fix: profile settings preference sidebar. * dev: update preference endpoints * dev: update the schedule to 5 minutes * dev: update template with priority data * dev: update templates * chore: enable `issue subscribe` button for all users. * chore: notification handling for external api * dev: update origin request --------- Co-authored-by: Prateek Shourya Co-authored-by: LAKHAN BAHETI Co-authored-by: Ramesh Kumar Chandra Co-authored-by: NarayanBavisetti --- apiserver/plane/app/serializers/__init__.py | 2 +- .../plane/app/serializers/notification.py | 9 +- apiserver/plane/app/urls/notification.py | 6 + apiserver/plane/app/views/__init__.py | 1 + apiserver/plane/app/views/cycle.py | 6 + apiserver/plane/app/views/inbox.py | 4 + apiserver/plane/app/views/issue.py | 46 +- apiserver/plane/app/views/module.py | 6 + apiserver/plane/app/views/notification.py | 59 +- .../plane/bgtasks/email_notification_task.py | 243 +++++ apiserver/plane/bgtasks/importer_task.py | 17 +- .../plane/bgtasks/issue_activites_task.py | 63 +- .../plane/bgtasks/issue_automation_task.py | 2 + apiserver/plane/bgtasks/notification_task.py | 729 +++++++++----- apiserver/plane/celery.py | 5 + ...ficationpreference_emailnotificationlog.py | 184 ++++ .../db/migrations/0057_auto_20240122_0901.py | 28 + apiserver/plane/db/models/__init__.py | 2 +- apiserver/plane/db/models/notification.py | 85 +- apiserver/plane/db/models/user.py | 39 + apiserver/plane/settings/common.py | 1 + .../emails/notifications/issue-updates.html | 903 ++++++++++++++++++ packages/types/src/users.d.ts | 8 + .../issues/issue-detail/sidebar.tsx | 7 +- .../issues/issue-detail/subscription.tsx | 7 +- web/components/issues/peek-overview/view.tsx | 7 +- .../preferences/email-notification-form.tsx | 188 ++++ web/components/profile/preferences/index.ts | 1 + web/constants/profile.ts | 2 +- .../profile/preferences/index.ts | 2 + .../profile/preferences/layout.tsx | 25 + .../profile/preferences/sidebar.tsx | 43 + web/pages/profile/preferences/email.tsx | 36 + .../theme.tsx} | 12 +- web/services/user.service.ts | 18 +- 35 files changed, 2482 insertions(+), 314 deletions(-) create mode 100644 apiserver/plane/bgtasks/email_notification_task.py create mode 100644 apiserver/plane/db/migrations/0056_usernotificationpreference_emailnotificationlog.py create mode 100644 apiserver/plane/db/migrations/0057_auto_20240122_0901.py create mode 100644 apiserver/templates/emails/notifications/issue-updates.html create mode 100644 web/components/profile/preferences/email-notification-form.tsx create mode 100644 web/components/profile/preferences/index.ts create mode 100644 web/layouts/settings-layout/profile/preferences/index.ts create mode 100644 web/layouts/settings-layout/profile/preferences/layout.tsx create mode 100644 web/layouts/settings-layout/profile/preferences/sidebar.tsx create mode 100644 web/pages/profile/preferences/email.tsx rename web/pages/profile/{preferences.tsx => preferences/theme.tsx} (83%) diff --git a/apiserver/plane/app/serializers/__init__.py b/apiserver/plane/app/serializers/__init__.py index 60c292336..c35737cb5 100644 --- a/apiserver/plane/app/serializers/__init__.py +++ b/apiserver/plane/app/serializers/__init__.py @@ -115,7 +115,7 @@ from .inbox import ( from .analytic import AnalyticViewSerializer -from .notification import NotificationSerializer +from .notification import NotificationSerializer, UserNotificationPreferenceSerializer from .exporter import ExporterHistorySerializer diff --git a/apiserver/plane/app/serializers/notification.py b/apiserver/plane/app/serializers/notification.py index 70d876241..2152fcf0f 100644 --- a/apiserver/plane/app/serializers/notification.py +++ b/apiserver/plane/app/serializers/notification.py @@ -1,7 +1,7 @@ # Module imports from .base import BaseSerializer from .user import UserLiteSerializer -from plane.db.models import Notification +from plane.db.models import Notification, UserNotificationPreference class NotificationSerializer(BaseSerializer): @@ -12,3 +12,10 @@ class NotificationSerializer(BaseSerializer): class Meta: model = Notification fields = "__all__" + + +class UserNotificationPreferenceSerializer(BaseSerializer): + + class Meta: + model = UserNotificationPreference + fields = "__all__" diff --git a/apiserver/plane/app/urls/notification.py b/apiserver/plane/app/urls/notification.py index 0c96e5f15..0bbf4f3c7 100644 --- a/apiserver/plane/app/urls/notification.py +++ b/apiserver/plane/app/urls/notification.py @@ -5,6 +5,7 @@ from plane.app.views import ( NotificationViewSet, UnreadNotificationEndpoint, MarkAllReadNotificationViewSet, + UserNotificationPreferenceEndpoint, ) @@ -63,4 +64,9 @@ urlpatterns = [ ), name="mark-all-read-notifications", ), + path( + "users/me/notification-preferences/", + UserNotificationPreferenceEndpoint.as_view(), + name="user-notification-preferences", + ), ] diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index 9c71e29ab..0a959a667 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -167,6 +167,7 @@ from .notification import ( NotificationViewSet, UnreadNotificationEndpoint, MarkAllReadNotificationViewSet, + UserNotificationPreferenceEndpoint, ) from .exporter import ExportIssuesEndpoint diff --git a/apiserver/plane/app/views/cycle.py b/apiserver/plane/app/views/cycle.py index 2d459d15b..3c54f7f95 100644 --- a/apiserver/plane/app/views/cycle.py +++ b/apiserver/plane/app/views/cycle.py @@ -530,6 +530,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet): project_id=str(project_id), current_instance=None, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) # Delete the cycle cycle.delete() @@ -721,6 +723,8 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): } ), epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) # Return all Cycle Issues @@ -753,6 +757,8 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): project_id=str(self.kwargs.get("project_id", None)), current_instance=None, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) cycle_issue.delete() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/inbox.py b/apiserver/plane/app/views/inbox.py index ff88bfdab..0f8e68656 100644 --- a/apiserver/plane/app/views/inbox.py +++ b/apiserver/plane/app/views/inbox.py @@ -200,6 +200,8 @@ class InboxIssueViewSet(BaseViewSet): project_id=str(project_id), current_instance=None, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) # create an inbox issue InboxIssue.objects.create( @@ -277,6 +279,8 @@ class InboxIssueViewSet(BaseViewSet): cls=DjangoJSONEncoder, ), epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) issue_serializer.save() else: diff --git a/apiserver/plane/app/views/issue.py b/apiserver/plane/app/views/issue.py index 2a0df13e5..5ea02e40e 100644 --- a/apiserver/plane/app/views/issue.py +++ b/apiserver/plane/app/views/issue.py @@ -259,6 +259,8 @@ class IssueViewSet(WebhookMixin, BaseViewSet): project_id=str(project_id), current_instance=None, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) issue = ( self.get_queryset().filter(pk=serializer.data["id"]).first() @@ -297,6 +299,8 @@ class IssueViewSet(WebhookMixin, BaseViewSet): project_id=str(project_id), current_instance=current_instance, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) issue = self.get_queryset().filter(pk=pk).first() return Response( @@ -320,6 +324,8 @@ class IssueViewSet(WebhookMixin, BaseViewSet): project_id=str(project_id), current_instance=current_instance, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) return Response(status=status.HTTP_204_NO_CONTENT) @@ -528,7 +534,7 @@ class IssueActivityEndpoint(BaseAPIView): if request.GET.get("activity_type", None) == "issue-property": return Response(issue_activities, status=status.HTTP_200_OK) - + if request.GET.get("activity_type", None) == "issue-comment": return Response(issue_comments, status=status.HTTP_200_OK) @@ -595,6 +601,8 @@ class IssueCommentViewSet(WebhookMixin, BaseViewSet): project_id=str(self.kwargs.get("project_id")), current_instance=None, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -624,6 +632,8 @@ class IssueCommentViewSet(WebhookMixin, BaseViewSet): project_id=str(project_id), current_instance=current_instance, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -648,6 +658,8 @@ class IssueCommentViewSet(WebhookMixin, BaseViewSet): project_id=str(project_id), current_instance=current_instance, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) return Response(status=status.HTTP_204_NO_CONTENT) @@ -850,6 +862,8 @@ class SubIssuesEndpoint(BaseAPIView): project_id=str(project_id), current_instance=json.dumps({"parent": str(sub_issue_id)}), epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) for sub_issue_id in sub_issue_ids ] @@ -909,6 +923,8 @@ class IssueLinkViewSet(BaseViewSet): project_id=str(self.kwargs.get("project_id")), current_instance=None, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -938,6 +954,8 @@ class IssueLinkViewSet(BaseViewSet): project_id=str(project_id), current_instance=current_instance, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -961,6 +979,8 @@ class IssueLinkViewSet(BaseViewSet): project_id=str(project_id), current_instance=current_instance, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) issue_link.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -1017,6 +1037,8 @@ class IssueAttachmentEndpoint(BaseAPIView): cls=DjangoJSONEncoder, ), epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -1033,6 +1055,8 @@ class IssueAttachmentEndpoint(BaseAPIView): project_id=str(self.kwargs.get("project_id", None)), current_instance=None, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) return Response(status=status.HTTP_204_NO_CONTENT) @@ -1211,6 +1235,8 @@ class IssueArchiveViewSet(BaseViewSet): IssueSerializer(issue).data, cls=DjangoJSONEncoder ), epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) issue.archived_at = None issue.save() @@ -1356,6 +1382,8 @@ class IssueReactionViewSet(BaseViewSet): project_id=str(project_id), current_instance=None, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -1381,6 +1409,8 @@ class IssueReactionViewSet(BaseViewSet): } ), epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) issue_reaction.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -1421,6 +1451,8 @@ class CommentReactionViewSet(BaseViewSet): project_id=str(project_id), current_instance=None, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -1447,6 +1479,8 @@ class CommentReactionViewSet(BaseViewSet): } ), epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) comment_reaction.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -1575,6 +1609,8 @@ class IssueRelationViewSet(BaseViewSet): project_id=str(project_id), current_instance=None, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) if relation_type == "blocking": @@ -1619,6 +1655,8 @@ class IssueRelationViewSet(BaseViewSet): project_id=str(project_id), current_instance=current_instance, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) return Response(status=status.HTTP_204_NO_CONTENT) @@ -1790,6 +1828,8 @@ class IssueDraftViewSet(BaseViewSet): project_id=str(project_id), current_instance=None, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -1820,6 +1860,8 @@ class IssueDraftViewSet(BaseViewSet): cls=DjangoJSONEncoder, ), epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -1846,5 +1888,7 @@ class IssueDraftViewSet(BaseViewSet): project_id=str(project_id), current_instance=current_instance, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/module.py b/apiserver/plane/app/views/module.py index 09d763ab7..969adc2a5 100644 --- a/apiserver/plane/app/views/module.py +++ b/apiserver/plane/app/views/module.py @@ -310,6 +310,8 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): project_id=str(project_id), current_instance=None, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) module.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -488,6 +490,8 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet): } ), epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) issues = self.get_queryset().values_list("issue_id", flat=True) @@ -519,6 +523,8 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet): project_id=str(project_id), current_instance=None, epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), ) module_issue.delete() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/notification.py b/apiserver/plane/app/views/notification.py index 15eef9cf0..ebe8e5082 100644 --- a/apiserver/plane/app/views/notification.py +++ b/apiserver/plane/app/views/notification.py @@ -1,5 +1,5 @@ # Django imports -from django.db.models import Q +from django.db.models import Q, OuterRef, Exists from django.utils import timezone # Third party imports @@ -15,8 +15,9 @@ from plane.db.models import ( IssueSubscriber, Issue, WorkspaceMember, + UserNotificationPreference, ) -from plane.app.serializers import NotificationSerializer +from plane.app.serializers import NotificationSerializer, UserNotificationPreferenceSerializer class NotificationViewSet(BaseViewSet, BasePaginator): @@ -71,11 +72,29 @@ class NotificationViewSet(BaseViewSet, BasePaginator): # Subscribed issues if type == "watching": - issue_ids = IssueSubscriber.objects.filter( - workspace__slug=slug, subscriber_id=request.user.id - ).values_list("issue_id", flat=True) + issue_ids = ( + IssueSubscriber.objects.filter( + workspace__slug=slug, subscriber_id=request.user.id + ) + .annotate( + created=Exists( + Issue.objects.filter( + created_by=request.user, pk=OuterRef("issue_id") + ) + ) + ) + .annotate( + assigned=Exists( + IssueAssignee.objects.filter( + pk=OuterRef("issue_id"), assignee=request.user + ) + ) + ) + .filter(created=False, assigned=False) + .values_list("issue_id", flat=True) + ) notifications = notifications.filter( - entity_identifier__in=issue_ids + entity_identifier__in=issue_ids, ) # Assigned Issues @@ -295,3 +314,31 @@ class MarkAllReadNotificationViewSet(BaseViewSet): updated_notifications, ["read_at"], batch_size=100 ) return Response({"message": "Successful"}, status=status.HTTP_200_OK) + + +class UserNotificationPreferenceEndpoint(BaseAPIView): + model = UserNotificationPreference + serializer_class = UserNotificationPreferenceSerializer + + # request the object + def get(self, request): + user_notification_preference = UserNotificationPreference.objects.get( + user=request.user + ) + serializer = UserNotificationPreferenceSerializer( + user_notification_preference + ) + return Response(serializer.data, status=status.HTTP_200_OK) + + # update the object + def patch(self, request): + user_notification_preference = UserNotificationPreference.objects.get( + user=request.user + ) + serializer = UserNotificationPreferenceSerializer( + user_notification_preference, data=request.data, partial=True + ) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/apiserver/plane/bgtasks/email_notification_task.py b/apiserver/plane/bgtasks/email_notification_task.py new file mode 100644 index 000000000..86b6d938e --- /dev/null +++ b/apiserver/plane/bgtasks/email_notification_task.py @@ -0,0 +1,243 @@ +import json +from datetime import datetime + +# Third party imports +from celery import shared_task + +# Django imports +from django.utils import timezone +from django.core.mail import EmailMultiAlternatives, get_connection +from django.template.loader import render_to_string +from django.utils.html import strip_tags +from django.conf import settings + +# Module imports +from plane.db.models import EmailNotificationLog, User, Issue +from plane.license.utils.instance_value import get_email_configuration +from plane.settings.redis import redis_instance + +@shared_task +def stack_email_notification(): + # get all email notifications + email_notifications = ( + EmailNotificationLog.objects.filter(processed_at__isnull=True) + .order_by("receiver") + .values() + ) + + # Create the below format for each of the issues + # {"issue_id" : { "actor_id1": [ { data }, { data } ], "actor_id2": [ { data }, { data } ] }} + + # Convert to unique receivers list + receivers = list( + set( + [ + str(notification.get("receiver_id")) + for notification in email_notifications + ] + ) + ) + processed_notifications = [] + # Loop through all the issues to create the emails + for receiver_id in receivers: + # Notifcation triggered for the receiver + receiver_notifications = [ + notification + for notification in email_notifications + if str(notification.get("receiver_id")) == receiver_id + ] + # create payload for all issues + payload = {} + email_notification_ids = [] + for receiver_notification in receiver_notifications: + payload.setdefault( + receiver_notification.get("entity_identifier"), {} + ).setdefault( + str(receiver_notification.get("triggered_by_id")), [] + ).append( + receiver_notification.get("data") + ) + # append processed notifications + processed_notifications.append(receiver_notification.get("id")) + email_notification_ids.append(receiver_notification.get("id")) + + # Create emails for all the issues + for issue_id, notification_data in payload.items(): + send_email_notification.delay( + issue_id=issue_id, + notification_data=notification_data, + receiver_id=receiver_id, + email_notification_ids=email_notification_ids, + ) + + # Update the email notification log + EmailNotificationLog.objects.filter(pk__in=processed_notifications).update( + processed_at=timezone.now() + ) + + +def create_payload(notification_data): + # return format {"actor_id": { "key": { "old_value": [], "new_value": [] } }} + data = {} + for actor_id, changes in notification_data.items(): + for change in changes: + issue_activity = change.get("issue_activity") + if issue_activity: # Ensure issue_activity is not None + field = issue_activity.get("field") + old_value = str(issue_activity.get("old_value")) + new_value = str(issue_activity.get("new_value")) + + # Append old_value if it's not empty and not already in the list + if old_value: + data.setdefault(actor_id, {}).setdefault( + field, {} + ).setdefault("old_value", []).append( + old_value + ) if old_value not in data.setdefault( + actor_id, {} + ).setdefault( + field, {} + ).get( + "old_value", [] + ) else None + + # Append new_value if it's not empty and not already in the list + if new_value: + data.setdefault(actor_id, {}).setdefault( + field, {} + ).setdefault("new_value", []).append( + new_value + ) if new_value not in data.setdefault( + actor_id, {} + ).setdefault( + field, {} + ).get( + "new_value", [] + ) else None + + if not data.get("actor_id", {}).get("activity_time", False): + data[actor_id]["activity_time"] = str( + datetime.fromisoformat( + issue_activity.get("activity_time").rstrip("Z") + ).strftime("%Y-%m-%d %H:%M:%S") + ) + + return data + + +@shared_task +def send_email_notification( + issue_id, notification_data, receiver_id, email_notification_ids +): + ri = redis_instance() + base_api = (ri.get(str(issue_id)).decode()) + data = create_payload(notification_data=notification_data) + + # Get email configurations + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_FROM, + ) = get_email_configuration() + + receiver = User.objects.get(pk=receiver_id) + issue = Issue.objects.get(pk=issue_id) + template_data = [] + total_changes = 0 + comments = [] + for actor_id, changes in data.items(): + actor = User.objects.get(pk=actor_id) + total_changes = total_changes + len(changes) + comment = changes.pop("comment", False) + if comment: + comments.append( + { + "actor_comments": comment, + "actor_detail": { + "avatar_url": actor.avatar, + "first_name": actor.first_name, + "last_name": actor.last_name, + }, + } + ) + activity_time = changes.pop("activity_time") + template_data.append( + { + "actor_detail": { + "avatar_url": actor.avatar, + "first_name": actor.first_name, + "last_name": actor.last_name, + }, + "changes": changes, + "issue_details": { + "name": issue.name, + "identifier": f"{issue.project.identifier}-{issue.sequence_id}", + }, + "activity_time": str(activity_time), + } + ) + + span = f"""""" + + summary = "updates were made to the issue by" + + # Send the mail + subject = f"{issue.project.identifier}-{issue.sequence_id} {issue.name}" + context = { + "data": template_data, + "summary": summary, + "issue": { + "issue_identifier": f"{str(issue.project.identifier)}-{str(issue.sequence_id)}", + "name": issue.name, + "issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}", + }, + "receiver": { + "email": receiver.email, + }, + "issue_unsubscribe": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}", + "user_preference": f"{base_api}/profile/preferences/email", + "comments": comments, + } + print(json.dumps(context)) + html_content = render_to_string( + "emails/notifications/issue-updates.html", context + ) + text_content = strip_tags(html_content) + + try: + connection = get_connection( + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=EMAIL_USE_TLS == "1", + ) + + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=EMAIL_FROM, + to=[receiver.email], + connection=connection, + ) + msg.attach_alternative(html_content, "text/html") + msg.send() + + EmailNotificationLog.objects.filter( + pk__in=email_notification_ids + ).update(sent_at=timezone.now()) + print("Email Sent") + return + except Exception as e: + print(e) + return diff --git a/apiserver/plane/bgtasks/importer_task.py b/apiserver/plane/bgtasks/importer_task.py index f58085249..421521363 100644 --- a/apiserver/plane/bgtasks/importer_task.py +++ b/apiserver/plane/bgtasks/importer_task.py @@ -24,6 +24,7 @@ from plane.db.models import ( Label, User, IssueProperty, + UserNotificationPreference, ) @@ -50,10 +51,24 @@ def service_importer(service, importer_id): for user in users if user.get("import", False) == "invite" ], - batch_size=10, + batch_size=100, ignore_conflicts=True, ) + _ = UserNotificationPreference.objects.bulk_create( + [UserNotificationPreference(user=user) for user in new_users], + batch_size=100, + ) + + _ = [ + send_welcome_slack.delay( + str(user.id), + True, + f"{user.email} was imported to Plane from {service}", + ) + for user in new_users + ] + workspace_users = User.objects.filter( email__in=[ user.get("email").strip().lower() diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 06d3295dc..4a036ec31 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -24,10 +24,11 @@ from plane.db.models import ( IssueReaction, CommentReaction, IssueComment, + IssueSubscriber, ) from plane.app.serializers import IssueActivitySerializer from plane.bgtasks.notification_task import notifications - +from plane.settings.redis import redis_instance # Track Changes in name def track_name( @@ -190,7 +191,9 @@ def track_state( ): if current_instance.get("state_id") != requested_data.get("state_id"): new_state = State.objects.get(pk=requested_data.get("state_id", None)) - old_state = State.objects.get(pk=current_instance.get("state_id", None)) + old_state = State.objects.get( + pk=current_instance.get("state_id", None) + ) issue_activities.append( IssueActivity( @@ -359,6 +362,7 @@ def track_assignees( added_assignees = requested_assignees - current_assignees dropped_assginees = current_assignees - requested_assignees + bulk_subscribers = [] for added_asignee in added_assignees: assignee = User.objects.get(pk=added_asignee) issue_activities.append( @@ -376,6 +380,21 @@ def track_assignees( epoch=epoch, ) ) + bulk_subscribers.append( + IssueSubscriber( + subscriber_id=assignee.id, + issue_id=issue_id, + workspace_id=workspace_id, + project_id=project_id, + created_by_id=assignee.id, + updated_by_id=assignee.id, + ) + ) + + # Create assignees subscribers to the issue and ignore if already + IssueSubscriber.objects.bulk_create( + bulk_subscribers, batch_size=10, ignore_conflicts=True + ) for dropped_assignee in dropped_assginees: assignee = User.objects.get(pk=dropped_assignee) @@ -1543,6 +1562,8 @@ def issue_activity( project_id, epoch, subscriber=True, + notification=False, + origin=None, ): try: issue_activities = [] @@ -1551,6 +1572,10 @@ def issue_activity( workspace_id = project.workspace_id if issue_id is not None: + if origin: + ri = redis_instance() + # set the request origin in redis + ri.set(str(issue_id), origin, ex=600) issue = Issue.objects.filter(pk=issue_id).first() if issue: try: @@ -1623,22 +1648,24 @@ def issue_activity( ) except Exception as e: capture_exception(e) + - notifications.delay( - type=type, - issue_id=issue_id, - actor_id=actor_id, - project_id=project_id, - subscriber=subscriber, - issue_activities_created=json.dumps( - IssueActivitySerializer( - issue_activities_created, many=True - ).data, - cls=DjangoJSONEncoder, - ), - requested_data=requested_data, - current_instance=current_instance, - ) + if notification: + notifications.delay( + type=type, + issue_id=issue_id, + actor_id=actor_id, + project_id=project_id, + subscriber=subscriber, + issue_activities_created=json.dumps( + IssueActivitySerializer( + issue_activities_created, many=True + ).data, + cls=DjangoJSONEncoder, + ), + requested_data=requested_data, + current_instance=current_instance, + ) return except Exception as e: @@ -1646,4 +1673,4 @@ def issue_activity( if settings.DEBUG: print(e) capture_exception(e) - return \ No newline at end of file + return diff --git a/apiserver/plane/bgtasks/issue_automation_task.py b/apiserver/plane/bgtasks/issue_automation_task.py index 718b60355..974a545fc 100644 --- a/apiserver/plane/bgtasks/issue_automation_task.py +++ b/apiserver/plane/bgtasks/issue_automation_task.py @@ -87,6 +87,7 @@ def archive_old_issues(): current_instance=json.dumps({"archived_at": None}), subscriber=False, epoch=int(timezone.now().timestamp()), + notification=True, ) for issue in issues_to_update ] @@ -169,6 +170,7 @@ def close_old_issues(): current_instance=None, subscriber=False, epoch=int(timezone.now().timestamp()), + notification=True, ) for issue in issues_to_update ] diff --git a/apiserver/plane/bgtasks/notification_task.py b/apiserver/plane/bgtasks/notification_task.py index 5649ad6b7..bcd0f8543 100644 --- a/apiserver/plane/bgtasks/notification_task.py +++ b/apiserver/plane/bgtasks/notification_task.py @@ -10,9 +10,12 @@ from plane.db.models import ( User, IssueAssignee, Issue, + State, + EmailNotificationLog, Notification, IssueComment, IssueActivity, + UserNotificationPreference, ) # Third Party imports @@ -20,7 +23,7 @@ from celery import shared_task from bs4 import BeautifulSoup -# =========== Issue Description Html Parsing and Notification Functions ====================== +# =========== Issue Description Html Parsing and notification Functions ====================== def update_mentions_for_issue(issue, project, new_mentions, removed_mention): @@ -37,9 +40,7 @@ def update_mentions_for_issue(issue, project, new_mentions, removed_mention): ) IssueMention.objects.bulk_create(aggregated_issue_mentions, batch_size=100) - IssueMention.objects.filter( - issue=issue, mention__in=removed_mention - ).delete() + IssueMention.objects.filter(issue=issue, mention__in=removed_mention).delete() def get_new_mentions(requested_instance, current_instance): @@ -60,8 +61,6 @@ def get_new_mentions(requested_instance, current_instance): # Get Removed Mention - - def get_removed_mentions(requested_instance, current_instance): # requested_data is the newer instance of the current issue # current_instance is the older instance of the current issue, saved in the database @@ -79,8 +78,6 @@ def get_removed_mentions(requested_instance, current_instance): # Adds mentions as subscribers - - def extract_mentions_as_subscribers(project_id, issue_id, mentions): # mentions is an array of User IDs representing the FILTERED set of mentioned users @@ -95,9 +92,7 @@ def extract_mentions_as_subscribers(project_id, issue_id, mentions): project_id=project_id, ).exists() and not IssueAssignee.objects.filter( - project_id=project_id, - issue_id=issue_id, - assignee_id=mention_id, + project_id=project_id, issue_id=issue_id, assignee_id=mention_id ).exists() and not Issue.objects.filter( project_id=project_id, pk=issue_id, created_by_id=mention_id @@ -125,9 +120,7 @@ def extract_mentions(issue_instance): data = json.loads(issue_instance) html = data.get("description_html") soup = BeautifulSoup(html, "html.parser") - mention_tags = soup.find_all( - "mention-component", attrs={"target": "users"} - ) + mention_tags = soup.find_all("mention-component", attrs={"target": "users"}) mentions = [mention_tag["id"] for mention_tag in mention_tags] @@ -136,14 +129,12 @@ def extract_mentions(issue_instance): return [] -# =========== Comment Parsing and Notification Functions ====================== +# =========== Comment Parsing and notification Functions ====================== def extract_comment_mentions(comment_value): try: mentions = [] soup = BeautifulSoup(comment_value, "html.parser") - mentions_tags = soup.find_all( - "mention-component", attrs={"target": "users"} - ) + mentions_tags = soup.find_all("mention-component", attrs={"target": "users"}) for mention_tag in mentions_tags: mentions.append(mention_tag["id"]) return list(set(mentions)) @@ -165,14 +156,8 @@ def get_new_comment_mentions(new_value, old_value): return new_mentions -def createMentionNotification( - project, - notification_comment, - issue, - actor_id, - mention_id, - issue_id, - activity, +def create_mention_notification( + project, notification_comment, issue, actor_id, mention_id, issue_id, activity ): return Notification( workspace=project.workspace, @@ -215,244 +200,195 @@ def notifications( requested_data, current_instance, ): - issue_activities_created = ( - json.loads(issue_activities_created) - if issue_activities_created is not None - else None - ) - if type not in [ - "issue.activity.deleted", - "cycle.activity.created", - "cycle.activity.deleted", - "module.activity.created", - "module.activity.deleted", - "issue_reaction.activity.created", - "issue_reaction.activity.deleted", - "comment_reaction.activity.created", - "comment_reaction.activity.deleted", - "issue_vote.activity.created", - "issue_vote.activity.deleted", - "issue_draft.activity.created", - "issue_draft.activity.updated", - "issue_draft.activity.deleted", - ]: - # Create Notifications - bulk_notifications = [] - - """ - Mention Tasks - 1. Perform Diffing and Extract the mentions, that mention notification needs to be sent - 2. From the latest set of mentions, extract the users which are not a subscribers & make them subscribers - """ - - # Get new mentions from the newer instance - new_mentions = get_new_mentions( - requested_instance=requested_data, - current_instance=current_instance, - ) - removed_mention = get_removed_mentions( - requested_instance=requested_data, - current_instance=current_instance, + try: + issue_activities_created = ( + json.loads(issue_activities_created) + if issue_activities_created is not None + else None ) + if type not in [ + "issue.activity.deleted", + "cycle.activity.created", + "cycle.activity.deleted", + "module.activity.created", + "module.activity.deleted", + "issue_reaction.activity.created", + "issue_reaction.activity.deleted", + "comment_reaction.activity.created", + "comment_reaction.activity.deleted", + "issue_vote.activity.created", + "issue_vote.activity.deleted", + "issue_draft.activity.created", + "issue_draft.activity.updated", + "issue_draft.activity.deleted", + ]: + # Create Notifications + bulk_notifications = [] + bulk_email_logs = [] - comment_mentions = [] - all_comment_mentions = [] + """ + Mention Tasks + 1. Perform Diffing and Extract the mentions, that mention notification needs to be sent + 2. From the latest set of mentions, extract the users which are not a subscribers & make them subscribers + """ - # Get New Subscribers from the mentions of the newer instance - requested_mentions = extract_mentions(issue_instance=requested_data) - mention_subscribers = extract_mentions_as_subscribers( - project_id=project_id, - issue_id=issue_id, - mentions=requested_mentions, - ) - - for issue_activity in issue_activities_created: - issue_comment = issue_activity.get("issue_comment") - issue_comment_new_value = issue_activity.get("new_value") - issue_comment_old_value = issue_activity.get("old_value") - if issue_comment is not None: - # TODO: Maybe save the comment mentions, so that in future, we can filter out the issues based on comment mentions as well. - - all_comment_mentions = ( - all_comment_mentions - + extract_comment_mentions(issue_comment_new_value) - ) - - new_comment_mentions = get_new_comment_mentions( - old_value=issue_comment_old_value, - new_value=issue_comment_new_value, - ) - comment_mentions = comment_mentions + new_comment_mentions - - comment_mention_subscribers = extract_mentions_as_subscribers( - project_id=project_id, - issue_id=issue_id, - mentions=all_comment_mentions, - ) - """ - We will not send subscription activity notification to the below mentioned user sets - - Those who have been newly mentioned in the issue description, we will send mention notification to them. - - When the activity is a comment_created and there exist a mention in the comment, then we have to send the "mention_in_comment" notification - - When the activity is a comment_updated and there exist a mention change, then also we have to send the "mention_in_comment" notification - """ - - issue_assignees = list( - IssueAssignee.objects.filter( - project_id=project_id, issue_id=issue_id + # Get new mentions from the newer instance + new_mentions = get_new_mentions( + requested_instance=requested_data, + current_instance=current_instance, ) - .exclude(assignee_id__in=list(new_mentions + comment_mentions)) - .values_list("assignee", flat=True) - ) - - issue_subscribers = list( - IssueSubscriber.objects.filter( - project_id=project_id, issue_id=issue_id + removed_mention = get_removed_mentions( + requested_instance=requested_data, + current_instance=current_instance, ) - .exclude( - subscriber_id__in=list( - new_mentions + comment_mentions + [actor_id] - ) + + comment_mentions = [] + all_comment_mentions = [] + + # Get New Subscribers from the mentions of the newer instance + requested_mentions = extract_mentions( + issue_instance=requested_data + ) + mention_subscribers = extract_mentions_as_subscribers( + project_id=project_id, + issue_id=issue_id, + mentions=requested_mentions, ) - .values_list("subscriber", flat=True) - ) - - issue = Issue.objects.filter(pk=issue_id).first() - - if issue.created_by_id is not None and str(issue.created_by_id) != str( - actor_id - ): - issue_subscribers = issue_subscribers + [issue.created_by_id] - - if subscriber: - # add the user to issue subscriber - try: - if ( - str(issue.created_by_id) != str(actor_id) - and uuid.UUID(actor_id) not in issue_assignees - ): - _ = IssueSubscriber.objects.get_or_create( - project_id=project_id, - issue_id=issue_id, - subscriber_id=actor_id, - ) - except Exception as e: - pass - - project = Project.objects.get(pk=project_id) - - issue_subscribers = list( - set(issue_subscribers + issue_assignees) - {uuid.UUID(actor_id)} - ) - - for subscriber in issue_subscribers: - if subscriber in issue_subscribers: - sender = "in_app:issue_activities:subscribed" - if ( - issue.created_by_id is not None - and subscriber == issue.created_by_id - ): - sender = "in_app:issue_activities:created" - if subscriber in issue_assignees: - sender = "in_app:issue_activities:assigned" for issue_activity in issue_activities_created: - # Do not send notification for description update - if issue_activity.get("field") == "description": - continue issue_comment = issue_activity.get("issue_comment") + issue_comment_new_value = issue_activity.get("new_value") + issue_comment_old_value = issue_activity.get("old_value") if issue_comment is not None: - issue_comment = IssueComment.objects.get( - id=issue_comment, - issue_id=issue_id, - project_id=project_id, - workspace_id=project.workspace_id, + # TODO: Maybe save the comment mentions, so that in future, we can filter out the issues based on comment mentions as well. + + all_comment_mentions = ( + all_comment_mentions + + extract_comment_mentions(issue_comment_new_value) ) - bulk_notifications.append( - Notification( - workspace=project.workspace, - sender=sender, - triggered_by_id=actor_id, - receiver_id=subscriber, - entity_identifier=issue_id, - entity_name="issue", - project=project, - title=issue_activity.get("comment"), - data={ - "issue": { - "id": str(issue_id), - "name": str(issue.name), - "identifier": str(issue.project.identifier), - "sequence_id": issue.sequence_id, - "state_name": issue.state.name, - "state_group": issue.state.group, - }, - "issue_activity": { - "id": str(issue_activity.get("id")), - "verb": str(issue_activity.get("verb")), - "field": str(issue_activity.get("field")), - "actor": str(issue_activity.get("actor_id")), - "new_value": str( - issue_activity.get("new_value") - ), - "old_value": str( - issue_activity.get("old_value") - ), - "issue_comment": str( - issue_comment.comment_stripped - if issue_activity.get("issue_comment") - is not None - else "" - ), - }, - }, + new_comment_mentions = get_new_comment_mentions( + old_value=issue_comment_old_value, + new_value=issue_comment_new_value, + ) + comment_mentions = comment_mentions + new_comment_mentions + + comment_mention_subscribers = extract_mentions_as_subscribers( + project_id=project_id, + issue_id=issue_id, + mentions=all_comment_mentions, + ) + """ + We will not send subscription activity notification to the below mentioned user sets + - Those who have been newly mentioned in the issue description, we will send mention notification to them. + - When the activity is a comment_created and there exist a mention in the comment, then we have to send the "mention_in_comment" notification + - When the activity is a comment_updated and there exist a mention change, then also we have to send the "mention_in_comment" notification + """ + + # ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- # + issue_subscribers = list( + IssueSubscriber.objects.filter( + project_id=project_id, issue_id=issue_id + ) + .exclude( + subscriber_id__in=list( + new_mentions + comment_mentions + [actor_id] ) ) + .values_list("subscriber", flat=True) + ) - # Add Mentioned as Issue Subscribers - IssueSubscriber.objects.bulk_create( - mention_subscribers + comment_mention_subscribers, batch_size=100 - ) + issue = Issue.objects.filter(pk=issue_id).first() - last_activity = ( - IssueActivity.objects.filter(issue_id=issue_id) - .order_by("-created_at") - .first() - ) - - actor = User.objects.get(pk=actor_id) - - for mention_id in comment_mentions: - if mention_id != actor_id: - for issue_activity in issue_activities_created: - notification = createMentionNotification( - project=project, - issue=issue, - notification_comment=f"{actor.display_name} has mentioned you in a comment in issue {issue.name}", - actor_id=actor_id, - mention_id=mention_id, - issue_id=issue_id, - activity=issue_activity, + if subscriber: + # add the user to issue subscriber + try: + _ = IssueSubscriber.objects.get_or_create( + project_id=project_id, issue_id=issue_id, subscriber_id=actor_id ) - bulk_notifications.append(notification) + except Exception as e: + pass - for mention_id in new_mentions: - if mention_id != actor_id: - if ( - last_activity is not None - and last_activity.field == "description" - and actor_id == str(last_activity.actor_id) + project = Project.objects.get(pk=project_id) + + issue_assignees = IssueAssignee.objects.filter( + issue_id=issue_id, project_id=project_id + ).values_list("assignee", flat=True) + + issue_subscribers = list( + set(issue_subscribers) - {uuid.UUID(actor_id)} + ) + + for subscriber in issue_subscribers: + if issue.created_by_id and issue.created_by_id == subscriber: + sender = "in_app:issue_activities:created" + elif ( + subscriber in issue_assignees + and issue.created_by_id not in issue_assignees ): + sender = "in_app:issue_activities:assigned" + else: + sender = "in_app:issue_activities:subscribed" + + preference = UserNotificationPreference.objects.get( + user_id=subscriber + ) + + for issue_activity in issue_activities_created: + # Do not send notification for description update + if issue_activity.get("field") == "description": + continue + + # Check if the value should be sent or not + send_email = False + if ( + issue_activity.get("field") == "state" + and preference.state_change + ): + send_email = True + elif ( + issue_activity.get("field") == "state" + and preference.issue_completed + and State.objects.filter( + project_id=project_id, + pk=issue_activity.get("new_identifier"), + group="completed", + ).exists() + ): + send_email = True + elif ( + issue_activity.get("field") == "comment" + and preference.comment + ): + send_email = True + elif preference.property_change: + send_email = True + else: + send_email = False + + # If activity is of issue comment fetch the comment + issue_comment = ( + IssueComment.objects.filter( + id=issue_activity.get("issue_comment"), + issue_id=issue_id, + project_id=project_id, + workspace_id=project.workspace_id, + ).first() + if issue_activity.get("issue_comment") + else None + ) + + # Create in app notification bulk_notifications.append( Notification( workspace=project.workspace, - sender="in_app:issue_activities:mentioned", + sender=sender, triggered_by_id=actor_id, - receiver_id=mention_id, + receiver_id=subscriber, entity_identifier=issue_id, entity_name="issue", project=project, - message=f"You have been mentioned in the issue {issue.name}", + title=issue_activity.get("comment"), data={ "issue": { "id": str(issue_id), @@ -465,36 +401,317 @@ def notifications( "state_group": issue.state.group, }, "issue_activity": { - "id": str(last_activity.id), - "verb": str(last_activity.verb), - "field": str(last_activity.field), - "actor": str(last_activity.actor_id), - "new_value": str(last_activity.new_value), - "old_value": str(last_activity.old_value), + "id": str(issue_activity.get("id")), + "verb": str(issue_activity.get("verb")), + "field": str(issue_activity.get("field")), + "actor": str( + issue_activity.get("actor_id") + ), + "new_value": str( + issue_activity.get("new_value") + ), + "old_value": str( + issue_activity.get("old_value") + ), + "issue_comment": str( + issue_comment.comment_stripped + if issue_comment is not None + else "" + ), }, }, ) ) - else: + # Create email notification + if send_email: + bulk_email_logs.append( + EmailNotificationLog( + triggered_by_id=actor_id, + receiver_id=subscriber, + entity_identifier=issue_id, + entity_name="issue", + data={ + "issue": { + "id": str(issue_id), + "name": str(issue.name), + "identifier": str( + issue.project.identifier + ), + "project_id": str(issue.project.id), + "workspace_slug": str( + issue.project.workspace.slug + ), + "sequence_id": issue.sequence_id, + "state_name": issue.state.name, + "state_group": issue.state.group, + }, + "issue_activity": { + "id": str(issue_activity.get("id")), + "verb": str( + issue_activity.get("verb") + ), + "field": str( + issue_activity.get("field") + ), + "actor": str( + issue_activity.get("actor_id") + ), + "new_value": str( + issue_activity.get("new_value") + ), + "old_value": str( + issue_activity.get("old_value") + ), + "issue_comment": str( + issue_comment.comment_stripped + if issue_comment is not None + else "" + ), + "activity_time": issue_activity.get("created_at"), + }, + }, + ) + ) + + # ----------------------------------------------------------------------------------------------------------------- # + + # Add Mentioned as Issue Subscribers + IssueSubscriber.objects.bulk_create( + mention_subscribers + comment_mention_subscribers, + batch_size=100, + ignore_conflicts=True, + ) + + last_activity = ( + IssueActivity.objects.filter(issue_id=issue_id) + .order_by("-created_at") + .first() + ) + + actor = User.objects.get(pk=actor_id) + + for mention_id in comment_mentions: + if mention_id != actor_id: + preference = UserNotificationPreference.objects.get( + user_id=mention_id + ) for issue_activity in issue_activities_created: - notification = createMentionNotification( + notification = create_mention_notification( project=project, issue=issue, - notification_comment=f"You have been mentioned in the issue {issue.name}", + notification_comment=f"{actor.display_name} has mentioned you in a comment in issue {issue.name}", actor_id=actor_id, mention_id=mention_id, issue_id=issue_id, activity=issue_activity, ) + + # check for email notifications + if preference.mention: + bulk_email_logs.append( + EmailNotificationLog( + triggered_by_id=actor_id, + receiver_id=subscriber, + entity_identifier=issue_id, + entity_name="issue", + data={ + "issue": { + "id": str(issue_id), + "name": str(issue.name), + "identifier": str( + issue.project.identifier + ), + "sequence_id": issue.sequence_id, + "state_name": issue.state.name, + "state_group": issue.state.group, + "project_id": str( + issue.project.id + ), + "workspace_slug": str( + issue.project.workspace.slug + ), + }, + "issue_activity": { + "id": str( + issue_activity.get("id") + ), + "verb": str( + issue_activity.get("verb") + ), + "field": str("mention"), + "actor": str( + issue_activity.get("actor_id") + ), + "new_value": str( + issue_activity.get("new_value") + ), + "old_value": str( + issue_activity.get("old_value") + ), + }, + }, + ) + ) bulk_notifications.append(notification) - # save new mentions for the particular issue and remove the mentions that has been deleted from the description - update_mentions_for_issue( - issue=issue, - project=project, - new_mentions=new_mentions, - removed_mention=removed_mention, - ) + for mention_id in new_mentions: + if mention_id != actor_id: + preference = UserNotificationPreference.objects.get( + user_id=mention_id + ) + if ( + last_activity is not None + and last_activity.field == "description" + and actor_id == str(last_activity.actor_id) + ): + bulk_notifications.append( + Notification( + workspace=project.workspace, + sender="in_app:issue_activities:mentioned", + triggered_by_id=actor_id, + receiver_id=mention_id, + entity_identifier=issue_id, + entity_name="issue", + project=project, + message=f"You have been mentioned in the issue {issue.name}", + data={ + "issue": { + "id": str(issue_id), + "name": str(issue.name), + "identifier": str( + issue.project.identifier + ), + "sequence_id": issue.sequence_id, + "state_name": issue.state.name, + "state_group": issue.state.group, + "project_id": str(issue.project.id), + "workspace_slug": str( + issue.project.workspace.slug + ), + }, + "issue_activity": { + "id": str(last_activity.id), + "verb": str(last_activity.verb), + "field": str(last_activity.field), + "actor": str(last_activity.actor_id), + "new_value": str( + last_activity.new_value + ), + "old_value": str( + last_activity.old_value + ), + }, + }, + ) + ) + if preference.mention: + bulk_email_logs.append( + EmailNotificationLog( + triggered_by_id=actor_id, + receiver_id=subscriber, + entity_identifier=issue_id, + entity_name="issue", + data={ + "issue": { + "id": str(issue_id), + "name": str(issue.name), + "identifier": str( + issue.project.identifier + ), + "sequence_id": issue.sequence_id, + "state_name": issue.state.name, + "state_group": issue.state.group, + }, + "issue_activity": { + "id": str(last_activity.id), + "verb": str(last_activity.verb), + "field": "mention", + "actor": str( + last_activity.actor_id + ), + "new_value": str( + last_activity.new_value + ), + "old_value": str( + last_activity.old_value + ), + }, + }, + ) + ) + else: + for issue_activity in issue_activities_created: + notification = create_mention_notification( + project=project, + issue=issue, + notification_comment=f"You have been mentioned in the issue {issue.name}", + actor_id=actor_id, + mention_id=mention_id, + issue_id=issue_id, + activity=issue_activity, + ) + if preference.mention: + bulk_email_logs.append( + EmailNotificationLog( + triggered_by_id=actor_id, + receiver_id=subscriber, + entity_identifier=issue_id, + entity_name="issue", + data={ + "issue": { + "id": str(issue_id), + "name": str(issue.name), + "identifier": str( + issue.project.identifier + ), + "sequence_id": issue.sequence_id, + "state_name": issue.state.name, + "state_group": issue.state.group, + }, + "issue_activity": { + "id": str( + issue_activity.get("id") + ), + "verb": str( + issue_activity.get("verb") + ), + "field": str("mention"), + "actor": str( + issue_activity.get( + "actor_id" + ) + ), + "new_value": str( + issue_activity.get( + "new_value" + ) + ), + "old_value": str( + issue_activity.get( + "old_value" + ) + ), + }, + }, + ) + ) + bulk_notifications.append(notification) - # Bulk create notifications - Notification.objects.bulk_create(bulk_notifications, batch_size=100) + # save new mentions for the particular issue and remove the mentions that has been deleted from the description + update_mentions_for_issue( + issue=issue, + project=project, + new_mentions=new_mentions, + removed_mention=removed_mention, + ) + # Bulk create notifications + Notification.objects.bulk_create( + bulk_notifications, batch_size=100 + ) + EmailNotificationLog.objects.bulk_create( + bulk_email_logs, batch_size=100, ignore_conflicts=True + ) + return + except Exception as e: + print(e) + return diff --git a/apiserver/plane/celery.py b/apiserver/plane/celery.py index 442e72836..11a88de57 100644 --- a/apiserver/plane/celery.py +++ b/apiserver/plane/celery.py @@ -2,6 +2,7 @@ import os from celery import Celery from plane.settings.redis import redis_instance from celery.schedules import crontab +from django.utils.timezone import timedelta # Set the default Django settings module for the 'celery' program. os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production") @@ -28,6 +29,10 @@ app.conf.beat_schedule = { "task": "plane.bgtasks.file_asset_task.delete_file_asset", "schedule": crontab(hour=0, minute=0), }, + "check-every-five-minutes-to-send-email-notifications": { + "task": "plane.bgtasks.email_notification_task.stack_email_notification", + "schedule": crontab(minute='*/1') + }, } # Load task modules from all registered Django app configs. diff --git a/apiserver/plane/db/migrations/0056_usernotificationpreference_emailnotificationlog.py b/apiserver/plane/db/migrations/0056_usernotificationpreference_emailnotificationlog.py new file mode 100644 index 000000000..2e6645945 --- /dev/null +++ b/apiserver/plane/db/migrations/0056_usernotificationpreference_emailnotificationlog.py @@ -0,0 +1,184 @@ +# Generated by Django 4.2.7 on 2024-01-22 08:55 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0055_auto_20240108_0648"), + ] + + operations = [ + migrations.CreateModel( + name="UserNotificationPreference", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("property_change", models.BooleanField(default=True)), + ("state_change", models.BooleanField(default=True)), + ("comment", models.BooleanField(default=True)), + ("mention", models.BooleanField(default=True)), + ("issue_completed", models.BooleanField(default=True)), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "project", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="project_notification_preferences", + to="db.project", + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="notification_preferences", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "workspace", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="workspace_notification_preferences", + to="db.workspace", + ), + ), + ], + options={ + "verbose_name": "UserNotificationPreference", + "verbose_name_plural": "UserNotificationPreferences", + "db_table": "user_notification_preferences", + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="EmailNotificationLog", + fields=[ + ( + "created_at", + models.DateTimeField( + auto_now_add=True, verbose_name="Created At" + ), + ), + ( + "updated_at", + models.DateTimeField( + auto_now=True, verbose_name="Last Modified At" + ), + ), + ( + "id", + models.UUIDField( + db_index=True, + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("entity_identifier", models.UUIDField(null=True)), + ("entity_name", models.CharField(max_length=255)), + ("data", models.JSONField(null=True)), + ("processed_at", models.DateTimeField(null=True)), + ("sent_at", models.DateTimeField(null=True)), + ("entity", models.CharField(max_length=200)), + ( + "old_value", + models.CharField(blank=True, max_length=300, null=True), + ), + ( + "new_value", + models.CharField(blank=True, max_length=300, null=True), + ), + ( + "created_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Created By", + ), + ), + ( + "receiver", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="email_notifications", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "triggered_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="triggered_emails", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + verbose_name="Last Modified By", + ), + ), + ], + options={ + "verbose_name": "Email Notification Log", + "verbose_name_plural": "Email Notification Logs", + "db_table": "email_notification_logs", + "ordering": ("-created_at",), + }, + ), + ] diff --git a/apiserver/plane/db/migrations/0057_auto_20240122_0901.py b/apiserver/plane/db/migrations/0057_auto_20240122_0901.py new file mode 100644 index 000000000..9204d43b3 --- /dev/null +++ b/apiserver/plane/db/migrations/0057_auto_20240122_0901.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.7 on 2024-01-22 09:01 + +from django.db import migrations + +def create_notification_preferences(apps, schema_editor): + UserNotificationPreference = apps.get_model("db", "UserNotificationPreference") + User = apps.get_model("db", "User") + + bulk_notification_preferences = [] + for user_id in User.objects.filter(is_bot=False).values_list("id", flat=True): + bulk_notification_preferences.append( + UserNotificationPreference( + user_id=user_id, + created_by_id=user_id, + ) + ) + UserNotificationPreference.objects.bulk_create( + bulk_notification_preferences, batch_size=1000, ignore_conflicts=True + ) + +class Migration(migrations.Migration): + dependencies = [ + ("db", "0056_usernotificationpreference_emailnotificationlog"), + ] + + operations = [ + migrations.RunPython(create_notification_preferences) + ] diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index 3a07a33f3..d9096bd01 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -85,7 +85,7 @@ from .inbox import Inbox, InboxIssue from .analytic import AnalyticView -from .notification import Notification +from .notification import Notification, UserNotificationPreference, EmailNotificationLog from .exporter import ExporterHistory diff --git a/apiserver/plane/db/models/notification.py b/apiserver/plane/db/models/notification.py index 8e6a48e14..b42ae54a9 100644 --- a/apiserver/plane/db/models/notification.py +++ b/apiserver/plane/db/models/notification.py @@ -1,9 +1,9 @@ # Django imports from django.db import models +from django.conf import settings -# Third party imports -from .base import BaseModel - +# Module imports +from . import BaseModel class Notification(BaseModel): workspace = models.ForeignKey( @@ -47,3 +47,82 @@ class Notification(BaseModel): def __str__(self): """Return name of the notifications""" return f"{self.receiver.email} <{self.workspace.name}>" + + +def get_default_preference(): + return { + "property_change": { + "email": True, + }, + "state": { + "email": True, + }, + "comment": { + "email": True, + }, + "mentions": { + "email": True, + }, + } + + +class UserNotificationPreference(BaseModel): + # user it is related to + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="notification_preferences", + ) + # workspace if it is applicable + workspace = models.ForeignKey( + "db.Workspace", + on_delete=models.CASCADE, + related_name="workspace_notification_preferences", + null=True, + ) + # project + project = models.ForeignKey( + "db.Project", + on_delete=models.CASCADE, + related_name="project_notification_preferences", + null=True, + ) + + # preference fields + property_change = models.BooleanField(default=True) + state_change = models.BooleanField(default=True) + comment = models.BooleanField(default=True) + mention = models.BooleanField(default=True) + issue_completed = models.BooleanField(default=True) + + class Meta: + verbose_name = "UserNotificationPreference" + verbose_name_plural = "UserNotificationPreferences" + db_table = "user_notification_preferences" + ordering = ("-created_at",) + + def __str__(self): + """Return the user""" + return f"<{self.user}>" + +class EmailNotificationLog(BaseModel): + # receiver + receiver = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="email_notifications") + triggered_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="triggered_emails") + # entity - can be issues, pages, etc. + entity_identifier = models.UUIDField(null=True) + entity_name = models.CharField(max_length=255) + # data + data = models.JSONField(null=True) + # sent at + processed_at = models.DateTimeField(null=True) + sent_at = models.DateTimeField(null=True) + entity = models.CharField(max_length=200) + old_value = models.CharField(max_length=300, blank=True, null=True) + new_value = models.CharField(max_length=300, blank=True, null=True) + + class Meta: + verbose_name = "Email Notification Log" + verbose_name_plural = "Email Notification Logs" + db_table = "email_notification_logs" + ordering = ("-created_at",) diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py index 087162ca5..6f8a82e56 100644 --- a/apiserver/plane/db/models/user.py +++ b/apiserver/plane/db/models/user.py @@ -11,8 +11,16 @@ from django.contrib.auth.models import ( UserManager, PermissionsMixin, ) +from django.db.models.signals import post_save +from django.conf import settings +from django.dispatch import receiver from django.utils import timezone +# Third party imports +from sentry_sdk import capture_exception +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError + def get_default_onboarding(): return { @@ -134,3 +142,34 @@ class User(AbstractBaseUser, PermissionsMixin): self.is_staff = True super(User, self).save(*args, **kwargs) + + +@receiver(post_save, sender=User) +def send_welcome_slack(sender, instance, created, **kwargs): + try: + if created and not instance.is_bot: + # Send message on slack as well + if settings.SLACK_BOT_TOKEN: + client = WebClient(token=settings.SLACK_BOT_TOKEN) + try: + _ = client.chat_postMessage( + channel="#trackers", + text=f"New user {instance.email} has signed up and begun the onboarding journey.", + ) + except SlackApiError as e: + print(f"Got an error: {e.response['error']}") + return + except Exception as e: + capture_exception(e) + return + + +@receiver(post_save, sender=User) +def create_user_notification(sender, instance, created, **kwargs): + # create preferences + if created and not instance.is_bot: + # Module imports + from plane.db.models import UserNotificationPreference + UserNotificationPreference.objects.create( + user=instance, + ) diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 623583840..444248382 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -291,6 +291,7 @@ CELERY_IMPORTS = ( "plane.bgtasks.issue_automation_task", "plane.bgtasks.exporter_expired_task", "plane.bgtasks.file_asset_task", + "plane.bgtasks.email_notification_task", ) # Sentry Settings diff --git a/apiserver/templates/emails/notifications/issue-updates.html b/apiserver/templates/emails/notifications/issue-updates.html new file mode 100644 index 000000000..2bf5b69ec --- /dev/null +++ b/apiserver/templates/emails/notifications/issue-updates.html @@ -0,0 +1,903 @@ + + + + + + + + Updates on Issue + + + + + + + + + + + + + + + + +
+ + + + +
+
+ + + + Plane + +
+
+
+ + + + +
+
+ + + + +
+

+ {{ issue.identifier }} updates +

+

+ {{ issue.name }} +

+
+
+

+ {{ summary }} + + {{ data.0.actor_detail.first_name}} {{data.0.actor_detail.last_name }} + +

+ + + + + + + + {% for update in data %} + + + + {% endfor %} +
+

+ Updates +

+
+ + + + + + + {% if update.changes.assignees %} + + + + {% endif %} + + {% if update.changes.target_date %} + + + + {% endif %} --> + + {% if update.changes.duplicate %} + + + + {% endif %} + + {% if update.changes.labels %} + + + + {% endif %} + + {% if update.changes.state %} + + + + + + + {% endif %} + + {% if update.changes.link %} + + + + {% endif %} + + {% if update.changes.priority %} + + + + {% endif %} + + {% if update.changes.blocking %} + + + + {% endif %} +
+ + + + + + +
+ + +

+ {{ update.actor_detail.first_name }} {{ update.actor_detail.last_name }} +

+
+

+ {{ update.activity_time }} +

+
+
+ + + + + {% for assignee in update.changes.assignees.old_value %} + + {% endfor %} + {% if update.changes.assignees.old_value and update.changes.assignee.new_value %} + + {% endif %} + {% for assignee in update.changes.assignees.new_value %} + + {% endfor %} + +
+

+ Assignee: +

+
+

+ {{ assignee }} +

+
+ + +

+ {{ assginee }} +

+
+
+ + + + + + +
+ + +

+ Due Date: +

+
+

+ {{ update.changes.target_date.new_value.0 }} +

+
+
+ + + + + {% for dup in update.changes.duplicate.new_value %} + + {% endfor %} + +
+ + +

+ Duplicate: +

+
+

+ {{ dup }} +

+
+
+ + + + + + + +
+ + +

+ Labels: +

+
+ + + {% for label in update.changes.labels.new_value %} + + {% endfor %} + +
+

+ {{ label }} +

+
+
+
+
+ + + + + + + + + +
+ + +

+ State: +

+
+

+ {{ update.changes.state.old_value.0 }} +

+
-> +

+ {{ update.changes.state.new_value.0 }} +

+
+
+ + + + + + +
+ + +

+ Link: +

+
+ + {{ update.changes.link.new_value.0 }} + +
+
+ + + + + + + + +
+ + +

+ Priority: +

+
+

+ {{ update.changes.priority.old_value.0 }} +

+
+ -> + +

+ {{ update.changes.priority.new_value.0 }} +

+
+
+ + + + + {% for bl in update.changes.blocking.new_value %} + + {% endfor %} + +
+ + +

+ Blocking: +

+
+ + {{ bl }} + +
+
+
+ + {% if comments %} + + + + + + + + + + + + + +
+

+ Comments +

+
+
+
+ + + + {% for comment in comments %} + + {% endfor %} + +
+
+ S +
+ +
+ + + + + {% for actor_comment in comment.actor_comments %} + + + + {% endfor %} +
+

+ {{ comment.actor_detail.first_name }} {{ comment.actor_detail.last_name }} +

+
+
+ {{ actor_comment.new_value.0 }} +
+
+
+
+ {% endif %} +
+ +
+
+ + + + +
+
+ This email was sent to + {{ receiver.email }}. + If you'd rather not receive this kind of email, + you can unsubscribe to the issue + or + manage your email preferences. + + +
+
+
+ + + + + diff --git a/packages/types/src/users.d.ts b/packages/types/src/users.d.ts index bbca953f6..81c8abcd5 100644 --- a/packages/types/src/users.d.ts +++ b/packages/types/src/users.d.ts @@ -166,6 +166,14 @@ export interface IUserProjectsRole { [projectId: string]: EUserProjectRoles; } +export interface IUserEmailNotificationSettings { + property_change: boolean; + state_change: boolean; + comment: boolean; + mention: boolean; + issue_completed: boolean; +} + // export interface ICurrentUser { // id: readonly string; // avatar: string; diff --git a/web/components/issues/issue-detail/sidebar.tsx b/web/components/issues/issue-detail/sidebar.tsx index 6b249f4bd..b4f8ac997 100644 --- a/web/components/issues/issue-detail/sidebar.tsx +++ b/web/components/issues/issue-detail/sidebar.tsx @@ -154,12 +154,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => {
{currentUser && !is_archived && (fieldsToShow.includes("all") || fieldsToShow.includes("subscribe")) && ( - + )} {(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && ( diff --git a/web/components/issues/issue-detail/subscription.tsx b/web/components/issues/issue-detail/subscription.tsx index d20bc53cf..b57e75bed 100644 --- a/web/components/issues/issue-detail/subscription.tsx +++ b/web/components/issues/issue-detail/subscription.tsx @@ -11,14 +11,12 @@ export type TIssueSubscription = { workspaceSlug: string; projectId: string; issueId: string; - currentUserId: string; }; export const IssueSubscription: FC = observer((props) => { - const { workspaceSlug, projectId, issueId, currentUserId } = props; + const { workspaceSlug, projectId, issueId } = props; // hooks const { - issue: { getIssueById }, subscription: { getSubscriptionByIssueId }, createSubscription, removeSubscription, @@ -27,7 +25,6 @@ export const IssueSubscription: FC = observer((props) => { // state const [loading, setLoading] = useState(false); - const issue = getIssueById(issueId); const subscription = getSubscriptionByIssueId(issueId); const handleSubscription = async () => { @@ -51,8 +48,6 @@ export const IssueSubscription: FC = observer((props) => { } }; - if (issue?.created_by === currentUserId || issue?.assignee_ids?.includes(currentUserId)) return <>; - return (
+
+ + ); +}; diff --git a/web/components/profile/preferences/index.ts b/web/components/profile/preferences/index.ts new file mode 100644 index 000000000..ddda5712c --- /dev/null +++ b/web/components/profile/preferences/index.ts @@ -0,0 +1 @@ +export * from "./email-notification-form"; \ No newline at end of file diff --git a/web/constants/profile.ts b/web/constants/profile.ts index 3f17bb329..063bb7e44 100644 --- a/web/constants/profile.ts +++ b/web/constants/profile.ts @@ -33,7 +33,7 @@ export const PROFILE_ACTION_LINKS: { { key: "preferences", label: "Preferences", - href: `/profile/preferences`, + href: `/profile/preferences/theme`, highlight: (pathname: string) => pathname.includes("/profile/preferences"), Icon: Settings2, }, diff --git a/web/layouts/settings-layout/profile/preferences/index.ts b/web/layouts/settings-layout/profile/preferences/index.ts new file mode 100644 index 000000000..34e230258 --- /dev/null +++ b/web/layouts/settings-layout/profile/preferences/index.ts @@ -0,0 +1,2 @@ +export * from "./layout"; +export * from "./sidebar"; \ No newline at end of file diff --git a/web/layouts/settings-layout/profile/preferences/layout.tsx b/web/layouts/settings-layout/profile/preferences/layout.tsx new file mode 100644 index 000000000..9d17350a9 --- /dev/null +++ b/web/layouts/settings-layout/profile/preferences/layout.tsx @@ -0,0 +1,25 @@ +import { FC, ReactNode } from "react"; +// layout +import { ProfileSettingsLayout } from "layouts/settings-layout"; +import { ProfilePreferenceSettingsSidebar } from "./sidebar"; + +interface IProfilePreferenceSettingsLayout { + children: ReactNode; + header?: ReactNode; +} + +export const ProfilePreferenceSettingsLayout: FC = (props) => { + const { children, header } = props; + + return ( + +
+ +
+ {header} +
{children}
+
+
+
+ ); +}; diff --git a/web/layouts/settings-layout/profile/preferences/sidebar.tsx b/web/layouts/settings-layout/profile/preferences/sidebar.tsx new file mode 100644 index 000000000..d1eec1233 --- /dev/null +++ b/web/layouts/settings-layout/profile/preferences/sidebar.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { useRouter } from "next/router"; +import Link from "next/link"; + +export const ProfilePreferenceSettingsSidebar = () => { + const router = useRouter(); + + const profilePreferenceLinks: Array<{ + label: string; + href: string; + }> = [ + { + label: "Theme", + href: `/profile/preferences/theme`, + }, + { + label: "Email", + href: `/profile/preferences/email`, + }, + ]; + return ( +
+
+ Preference +
+ {profilePreferenceLinks.map((link) => ( + +
+ {link.label} +
+ + ))} +
+
+
+ ); +}; diff --git a/web/pages/profile/preferences/email.tsx b/web/pages/profile/preferences/email.tsx new file mode 100644 index 000000000..714d8b555 --- /dev/null +++ b/web/pages/profile/preferences/email.tsx @@ -0,0 +1,36 @@ +import { ReactElement } from "react"; +import useSWR from "swr"; +// layouts +import { ProfilePreferenceSettingsLayout } from "layouts/settings-layout/profile/preferences"; +// components +import { EmailNotificationForm } from "components/profile/preferences"; +// services +import { UserService } from "services/user.service"; +// type +import { NextPageWithLayout } from "lib/types"; + +// services +const userService = new UserService(); + +const ProfilePreferencesThemePage: NextPageWithLayout = () => { + // fetching user email notification settings + const { data } = useSWR("CURRENT_USER_EMAIL_NOTIFICATION_SETTINGS", () => + userService.currentUserEmailNotificationSettings() + ); + + if (!data) { + return null; + } + + return ( +
+ +
+ ); +}; + +ProfilePreferencesThemePage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default ProfilePreferencesThemePage; diff --git a/web/pages/profile/preferences.tsx b/web/pages/profile/preferences/theme.tsx similarity index 83% rename from web/pages/profile/preferences.tsx rename to web/pages/profile/preferences/theme.tsx index 23c85134e..51386bc29 100644 --- a/web/pages/profile/preferences.tsx +++ b/web/pages/profile/preferences/theme.tsx @@ -5,7 +5,7 @@ import { useTheme } from "next-themes"; import { useUser } from "hooks/store"; import useToast from "hooks/use-toast"; // layouts -import { ProfileSettingsLayout } from "layouts/settings-layout"; +import { ProfilePreferenceSettingsLayout } from "layouts/settings-layout/profile/preferences"; // components import { CustomThemeSelector, ThemeSwitch } from "components/core"; // ui @@ -15,7 +15,7 @@ import { I_THEME_OPTION, THEME_OPTIONS } from "constants/themes"; // type import { NextPageWithLayout } from "lib/types"; -const ProfilePreferencesPage: NextPageWithLayout = observer(() => { +const ProfilePreferencesThemePage: NextPageWithLayout = observer(() => { // states const [currentTheme, setCurrentTheme] = useState(null); // store hooks @@ -48,7 +48,7 @@ const ProfilePreferencesPage: NextPageWithLayout = observer(() => { return ( <> {currentUser ? ( -
+

Preferences

@@ -72,8 +72,8 @@ const ProfilePreferencesPage: NextPageWithLayout = observer(() => { ); }); -ProfilePreferencesPage.getLayout = function getLayout(page: ReactElement) { - return {page}; +ProfilePreferencesThemePage.getLayout = function getLayout(page: ReactElement) { + return {page}; }; -export default ProfilePreferencesPage; +export default ProfilePreferencesThemePage; diff --git a/web/services/user.service.ts b/web/services/user.service.ts index 2608b6d17..13ffa9c51 100644 --- a/web/services/user.service.ts +++ b/web/services/user.service.ts @@ -10,7 +10,7 @@ import type { IUserProfileProjectSegregation, IUserSettings, IUserWorkspaceDashboard, - TIssueMap, + IUserEmailNotificationSettings, } from "@plane/types"; // helpers import { API_BASE_URL } from "helpers/common.helper"; @@ -69,6 +69,14 @@ export class UserService extends APIService { }); } + async currentUserEmailNotificationSettings(): Promise { + return this.get("/api/users/me/notification-preferences/") + .then((response) => response?.data) + .catch((error) => { + throw error?.response; + }); + } + async updateUser(data: Partial): Promise { return this.patch("/api/users/me/", data) .then((response) => response?.data) @@ -97,6 +105,14 @@ export class UserService extends APIService { }); } + async updateCurrentUserEmailNotificationSettings(data: Partial): Promise { + return this.patch("/api/users/me/notification-preferences/", data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + async getUserActivity(): Promise { return this.get(`/api/users/me/activities/`) .then((response) => response?.data)