diff --git a/backend/community/_event_comments.py b/backend/community/_event_comments.py index cbc5360b..00a24b65 100644 --- a/backend/community/_event_comments.py +++ b/backend/community/_event_comments.py @@ -10,6 +10,7 @@ from ninja.responses import Status from notifications.service import ( broadcast_event_comment_update, + notify_comment_reaction, notify_comment_reply, notify_event_comment, ) @@ -340,6 +341,8 @@ def toggle_reaction(request, event_id: UUID, comment_id: UUID, payload: Reaction existing.delete() else: EventCommentReaction.objects.create(comment=comment, user=user, emoji=payload.emoji) + comment.event = event + notify_comment_reaction(comment, user) # The toggle endpoint returns the parent top-level comment so the FE can # update either a top-level row (when comment is top-level) or the reply's # row (when comment is a reply). When it's a reply, we return the parent. diff --git a/backend/notifications/migrations/0016_alter_notification_notification_type.py b/backend/notifications/migrations/0016_alter_notification_notification_type.py new file mode 100644 index 00000000..cb969417 --- /dev/null +++ b/backend/notifications/migrations/0016_alter_notification_notification_type.py @@ -0,0 +1,38 @@ +# Generated by Django 6.0.3 on 2026-07-24 16:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("notifications", "0015_alter_notification_notification_type"), + ] + + operations = [ + migrations.AlterField( + model_name="notification", + name="notification_type", + field=models.CharField( + choices=[ + ("event_invite", "Event Invite"), + ("event_cancelled", "Event Cancelled"), + ("join_request", "Join Request"), + ("cohost_added", "Co-host Added"), + ("cohost_invite", "Co-host Invite"), + ("cohost_invite_accepted", "Co-host Invite Accepted"), + ("cohost_invite_declined", "Co-host Invite Declined"), + ("cohost_removed", "Co-host Removed"), + ("magic_link_request", "Magic Link Request"), + ("waitlist_promoted", "Waitlist Promoted"), + ("event_flagged", "Event Flagged"), + ("comment_reply", "Comment Reply"), + ("event_comment", "Event Comment"), + ("comment_reaction", "Comment Reaction"), + ("rsvp_declined_note", "RSVP Declined Note"), + ("checkin_nudge", "Check-in Nudge"), + ], + default="event_invite", + max_length=32, + ), + ), + ] diff --git a/backend/notifications/models.py b/backend/notifications/models.py index 37b1c4e6..f2b1c651 100644 --- a/backend/notifications/models.py +++ b/backend/notifications/models.py @@ -24,6 +24,7 @@ class NotificationType(models.TextChoices): EVENT_FLAGGED = "event_flagged", "Event Flagged" COMMENT_REPLY = "comment_reply", "Comment Reply" EVENT_COMMENT = "event_comment", "Event Comment" + COMMENT_REACTION = "comment_reaction", "Comment Reaction" RSVP_DECLINED_NOTE = "rsvp_declined_note", "RSVP Declined Note" CHECKIN_NUDGE = "checkin_nudge", "Check-in Nudge" diff --git a/backend/notifications/service.py b/backend/notifications/service.py index c4ab6933..0a30da6f 100644 --- a/backend/notifications/service.py +++ b/backend/notifications/service.py @@ -343,6 +343,22 @@ def notify_comment_reply(reply) -> None: _notify_users([str(parent_author_id)]) +def notify_comment_reaction(comment, reactor: User) -> None: + """Notify a comment's author that someone reacted to it. No-op for self-reactions.""" + if str(comment.author_id) == str(reactor.pk): + return + reactor_name = visible_display_name(reactor, None) + event_title = comment.event.title + Notification.objects.create( + recipient_id=comment.author_id, + notification_type=NotificationType.COMMENT_REACTION, + event=comment.event, + related_user=reactor, + message=f"{reactor_name} reacted to your comment on {event_title}", + ) + _notify_users([str(comment.author_id)]) + + def _event_recipient_ids(event, *, exclude: str) -> list[str]: """Host + co-hosts, excluding `exclude` (usually the actor triggering the notification).""" recipient_ids: set[str] = set() diff --git a/backend/tests/test_event_comment_reactions.py b/backend/tests/test_event_comment_reactions.py new file mode 100644 index 00000000..00a8a6e4 --- /dev/null +++ b/backend/tests/test_event_comment_reactions.py @@ -0,0 +1,209 @@ +import json + +import pytest +from community.models import Event, EventComment, EventCommentReaction, EventRSVP, RSVPStatus +from django.core.cache import cache +from ninja_jwt.tokens import RefreshToken +from notifications.models import Notification, NotificationType +from users.models import User + +from tests.conftest import future_iso + + +@pytest.fixture +def event(db, test_user): + return Event.objects.create( + title="Test Event", + start_datetime=future_iso(days=30), + created_by=test_user, + ) + + +@pytest.fixture +def rsvp_user(db): + return User.objects.create_user( + phone_number="+12025550303", + password="rsvppass123", + first_name="RSVP", + last_name="Member", + ) + + +@pytest.fixture +def rsvp_headers(rsvp_user): + refresh = RefreshToken.for_user(rsvp_user) + return {"HTTP_AUTHORIZATION": f"Bearer {refresh.access_token}"} # type: ignore + + +@pytest.fixture +def event_with_rsvp(db, event, rsvp_user): + EventRSVP.objects.create(event=event, user=rsvp_user, status=RSVPStatus.ATTENDING) + return event + + +@pytest.mark.django_db +class TestReactionToggle: + def test_first_toggle_creates(self, api_client, rsvp_headers, event_with_rsvp, rsvp_user): + comment = EventComment.objects.create(event=event_with_rsvp, author=rsvp_user, body="hi") + response = api_client.post( + f"/api/community/events/{event_with_rsvp.id}/comments/{comment.id}/reactions/", + data=json.dumps({"emoji": "❤️"}), + content_type="application/json", + **rsvp_headers, + ) + assert response.status_code == 200, response.content + body = response.json() + hearts = [r for r in body["reactions"] if r["emoji"] == "❤️"] + assert len(hearts) == 1 + assert hearts[0]["count"] == 1 + assert hearts[0]["reacted_by_me"] is True + + def test_second_toggle_removes(self, api_client, rsvp_headers, event_with_rsvp, rsvp_user): + comment = EventComment.objects.create(event=event_with_rsvp, author=rsvp_user, body="hi") + EventCommentReaction.objects.create(comment=comment, user=rsvp_user, emoji="❤️") + response = api_client.post( + f"/api/community/events/{event_with_rsvp.id}/comments/{comment.id}/reactions/", + data=json.dumps({"emoji": "❤️"}), + content_type="application/json", + **rsvp_headers, + ) + assert response.status_code == 200 + assert response.json()["reactions"] == [] + + def test_stacking_different_emojis(self, api_client, rsvp_headers, event_with_rsvp, rsvp_user): + comment = EventComment.objects.create(event=event_with_rsvp, author=rsvp_user, body="hi") + api_client.post( + f"/api/community/events/{event_with_rsvp.id}/comments/{comment.id}/reactions/", + data=json.dumps({"emoji": "❤️"}), + content_type="application/json", + **rsvp_headers, + ) + response = api_client.post( + f"/api/community/events/{event_with_rsvp.id}/comments/{comment.id}/reactions/", + data=json.dumps({"emoji": "🔥"}), + content_type="application/json", + **rsvp_headers, + ) + emojis = {r["emoji"] for r in response.json()["reactions"]} + assert emojis == {"❤️", "🔥"} + + def test_invalid_emoji(self, api_client, rsvp_headers, event_with_rsvp, rsvp_user): + comment = EventComment.objects.create(event=event_with_rsvp, author=rsvp_user, body="hi") + response = api_client.post( + f"/api/community/events/{event_with_rsvp.id}/comments/{comment.id}/reactions/", + data=json.dumps({"emoji": "🦊"}), + content_type="application/json", + **rsvp_headers, + ) + assert response.status_code == 422 + assert response.json()["detail"][0]["code"] == "comment.invalid_emoji" + + def test_reaction_requires_rsvp(self, api_client, event): + # A bystander (not creator, not co-host, not admin, no RSVP). + bystander = User.objects.create_user( + phone_number="+12025551010", + password="bystanderpass", + first_name="Bystander", + ) + refresh = RefreshToken.for_user(bystander) + headers = {"HTTP_AUTHORIZATION": f"Bearer {refresh.access_token}"} # type: ignore + comment = EventComment.objects.create(event=event, author=event.created_by, body="hi") + response = api_client.post( + f"/api/community/events/{event.id}/comments/{comment.id}/reactions/", + data=json.dumps({"emoji": "❤️"}), + content_type="application/json", + **headers, + ) + assert response.status_code == 403 + + def test_reaction_notifies_comment_author( + self, api_client, rsvp_headers, event_with_rsvp, rsvp_user + ): + other_user = User.objects.create_user( + phone_number="+12025552020", + password="otherpass123", + first_name="Other", + last_name="Member", + ) + EventRSVP.objects.create( + event=event_with_rsvp, user=other_user, status=RSVPStatus.ATTENDING + ) + other_refresh = RefreshToken.for_user(other_user) + other_headers = {"HTTP_AUTHORIZATION": f"Bearer {other_refresh.access_token}"} # type: ignore + comment = EventComment.objects.create(event=event_with_rsvp, author=rsvp_user, body="hi") + response = api_client.post( + f"/api/community/events/{event_with_rsvp.id}/comments/{comment.id}/reactions/", + data=json.dumps({"emoji": "❤️"}), + content_type="application/json", + **other_headers, + ) + assert response.status_code == 200, response.content + notification = Notification.objects.get(recipient=rsvp_user) + assert notification.notification_type == NotificationType.COMMENT_REACTION + assert notification.related_user_id == other_user.id # ty: ignore[unresolved-attribute] + + def test_self_reaction_does_not_notify( + self, api_client, rsvp_headers, event_with_rsvp, rsvp_user + ): + comment = EventComment.objects.create(event=event_with_rsvp, author=rsvp_user, body="hi") + response = api_client.post( + f"/api/community/events/{event_with_rsvp.id}/comments/{comment.id}/reactions/", + data=json.dumps({"emoji": "❤️"}), + content_type="application/json", + **rsvp_headers, + ) + assert response.status_code == 200, response.content + assert not Notification.objects.filter( + recipient=rsvp_user, notification_type=NotificationType.COMMENT_REACTION + ).exists() + + def test_removing_reaction_does_not_notify( + self, api_client, rsvp_headers, event_with_rsvp, rsvp_user + ): + other_user = User.objects.create_user( + phone_number="+12025552021", + password="otherpass123", + first_name="Other", + last_name="Member", + ) + EventRSVP.objects.create( + event=event_with_rsvp, user=other_user, status=RSVPStatus.ATTENDING + ) + other_refresh = RefreshToken.for_user(other_user) + other_headers = {"HTTP_AUTHORIZATION": f"Bearer {other_refresh.access_token}"} # type: ignore + comment = EventComment.objects.create(event=event_with_rsvp, author=rsvp_user, body="hi") + EventCommentReaction.objects.create(comment=comment, user=other_user, emoji="❤️") + response = api_client.post( + f"/api/community/events/{event_with_rsvp.id}/comments/{comment.id}/reactions/", + data=json.dumps({"emoji": "❤️"}), + content_type="application/json", + **other_headers, + ) + assert response.status_code == 200, response.content + assert not Notification.objects.filter( + recipient=rsvp_user, notification_type=NotificationType.COMMENT_REACTION + ).exists() + + def test_rate_limit_kicks_in(self, api_client, rsvp_headers, event_with_rsvp, rsvp_user): + """11th write in 60s should 429. Toggles back-and-forth to avoid the + unique-constraint blocking the second create — toggle on, off, on, off...""" + cache.clear() + comment = EventComment.objects.create(event=event_with_rsvp, author=rsvp_user, body="hi") + url = f"/api/community/events/{event_with_rsvp.id}/comments/{comment.id}/reactions/" + for _ in range(10): + r = api_client.post( + url, + data=json.dumps({"emoji": "❤️"}), + content_type="application/json", + **rsvp_headers, + ) + assert r.status_code == 200, r.content + # 11th request hits the limit + r = api_client.post( + url, + data=json.dumps({"emoji": "❤️"}), + content_type="application/json", + **rsvp_headers, + ) + assert r.status_code == 429 + assert r.json()["detail"][0]["code"] == "rate.limited" diff --git a/backend/tests/test_event_comments.py b/backend/tests/test_event_comments.py index d340061c..6a2c010a 100644 --- a/backend/tests/test_event_comments.py +++ b/backend/tests/test_event_comments.py @@ -7,12 +7,10 @@ from community.models import ( Event, EventComment, - EventCommentReaction, EventRSVP, PageVisibility, RSVPStatus, ) -from django.core.cache import cache from django.utils import timezone from ninja_jwt.tokens import RefreshToken from users.models import User @@ -323,106 +321,6 @@ def test_delete_broadcasts_event_update( assert mock_broadcast.call_args.args[0].id == event_with_rsvp.id -@pytest.mark.django_db -class TestReactionToggle: - def test_first_toggle_creates(self, api_client, rsvp_headers, event_with_rsvp, rsvp_user): - comment = EventComment.objects.create(event=event_with_rsvp, author=rsvp_user, body="hi") - response = api_client.post( - f"/api/community/events/{event_with_rsvp.id}/comments/{comment.id}/reactions/", - data=json.dumps({"emoji": "❤️"}), - content_type="application/json", - **rsvp_headers, - ) - assert response.status_code == 200, response.content - body = response.json() - hearts = [r for r in body["reactions"] if r["emoji"] == "❤️"] - assert len(hearts) == 1 - assert hearts[0]["count"] == 1 - assert hearts[0]["reacted_by_me"] is True - - def test_second_toggle_removes(self, api_client, rsvp_headers, event_with_rsvp, rsvp_user): - comment = EventComment.objects.create(event=event_with_rsvp, author=rsvp_user, body="hi") - EventCommentReaction.objects.create(comment=comment, user=rsvp_user, emoji="❤️") - response = api_client.post( - f"/api/community/events/{event_with_rsvp.id}/comments/{comment.id}/reactions/", - data=json.dumps({"emoji": "❤️"}), - content_type="application/json", - **rsvp_headers, - ) - assert response.status_code == 200 - assert response.json()["reactions"] == [] - - def test_stacking_different_emojis(self, api_client, rsvp_headers, event_with_rsvp, rsvp_user): - comment = EventComment.objects.create(event=event_with_rsvp, author=rsvp_user, body="hi") - api_client.post( - f"/api/community/events/{event_with_rsvp.id}/comments/{comment.id}/reactions/", - data=json.dumps({"emoji": "❤️"}), - content_type="application/json", - **rsvp_headers, - ) - response = api_client.post( - f"/api/community/events/{event_with_rsvp.id}/comments/{comment.id}/reactions/", - data=json.dumps({"emoji": "🔥"}), - content_type="application/json", - **rsvp_headers, - ) - emojis = {r["emoji"] for r in response.json()["reactions"]} - assert emojis == {"❤️", "🔥"} - - def test_invalid_emoji(self, api_client, rsvp_headers, event_with_rsvp, rsvp_user): - comment = EventComment.objects.create(event=event_with_rsvp, author=rsvp_user, body="hi") - response = api_client.post( - f"/api/community/events/{event_with_rsvp.id}/comments/{comment.id}/reactions/", - data=json.dumps({"emoji": "🦊"}), - content_type="application/json", - **rsvp_headers, - ) - assert response.status_code == 422 - assert response.json()["detail"][0]["code"] == "comment.invalid_emoji" - - def test_reaction_requires_rsvp(self, api_client, event): - # A bystander (not creator, not co-host, not admin, no RSVP). - bystander = User.objects.create_user( - phone_number="+12025551010", - password="bystanderpass", - first_name="Bystander", - ) - refresh = RefreshToken.for_user(bystander) - headers = {"HTTP_AUTHORIZATION": f"Bearer {refresh.access_token}"} # type: ignore - comment = EventComment.objects.create(event=event, author=event.created_by, body="hi") - response = api_client.post( - f"/api/community/events/{event.id}/comments/{comment.id}/reactions/", - data=json.dumps({"emoji": "❤️"}), - content_type="application/json", - **headers, - ) - assert response.status_code == 403 - - def test_rate_limit_kicks_in(self, api_client, rsvp_headers, event_with_rsvp, rsvp_user): - """11th write in 60s should 429. Toggles back-and-forth to avoid the - unique-constraint blocking the second create — toggle on, off, on, off...""" - cache.clear() - comment = EventComment.objects.create(event=event_with_rsvp, author=rsvp_user, body="hi") - url = f"/api/community/events/{event_with_rsvp.id}/comments/{comment.id}/reactions/" - for _ in range(10): - r = api_client.post( - url, - data=json.dumps({"emoji": "❤️"}), - content_type="application/json", - **rsvp_headers, - ) - assert r.status_code == 200, r.content - # 11th request hits the limit - r = api_client.post( - url, - data=json.dumps({"emoji": "❤️"}), - content_type="application/json", - **rsvp_headers, - ) - assert r.status_code == 429 - assert r.json()["detail"][0]["code"] == "rate.limited" - - @pytest.mark.django_db class TestCommentVisibility: def test_invite_only_non_invitee_cannot_list(self, api_client, db, rsvp_user, rsvp_headers): diff --git a/frontend/src/layout/notificationTarget.ts b/frontend/src/layout/notificationTarget.ts index e4c120c0..4b5c429a 100644 --- a/frontend/src/layout/notificationTarget.ts +++ b/frontend/src/layout/notificationTarget.ts @@ -12,6 +12,7 @@ export function notificationTarget(n: AppNotification): string | null { case NotificationType.EventCancelled: case NotificationType.CommentReply: case NotificationType.EventComment: + case NotificationType.CommentReaction: case NotificationType.RsvpDeclinedNote: return n.eventId ? `/events/${n.eventId}` : null; case NotificationType.CheckinNudge: diff --git a/frontend/src/models/notification.ts b/frontend/src/models/notification.ts index 43b5f064..ea4ae2b2 100644 --- a/frontend/src/models/notification.ts +++ b/frontend/src/models/notification.ts @@ -12,6 +12,7 @@ export const NotificationType = { EventCancelled: 'event_cancelled', CommentReply: 'comment_reply', EventComment: 'event_comment', + CommentReaction: 'comment_reaction', RsvpDeclinedNote: 'rsvp_declined_note', CheckinNudge: 'checkin_nudge', } as const;