From 89eb05b2583ae80818eacc003e07d8f2ec1065a1 Mon Sep 17 00:00:00 2001 From: Typogalaxy <136544101+Typogalaxy@users.noreply.github.com> Date: Fri, 22 May 2026 22:03:11 +1000 Subject: [PATCH 1/6] feat(backend): add notifications app --- .github/workflows/ci.yml | 1 + apps/backend/AGENTS.md | 2 +- apps/backend/README.md | 3 +- apps/backend/articles/services/articles.py | 6 + apps/backend/backend/settings/base.py | 1 + apps/backend/backend/settings/test.py | 1 + apps/backend/backend/urls.py | 1 + apps/backend/comments/services.py | 18 +- apps/backend/notifications/__init__.py | 1 + apps/backend/notifications/admin.py | 20 +++ apps/backend/notifications/apps.py | 7 + .../notifications/migrations/0001_initial.py | 69 ++++++++ .../notifications/migrations/__init__.py | 1 + apps/backend/notifications/models.py | 85 ++++++++++ apps/backend/notifications/serializers.py | 32 ++++ apps/backend/notifications/services.py | 155 ++++++++++++++++++ apps/backend/notifications/tasks.py | 15 ++ apps/backend/notifications/tests/__init__.py | 1 + .../notifications/tests/test_services.py | 77 +++++++++ .../backend/notifications/tests/test_views.py | 55 +++++++ apps/backend/notifications/urls.py | 11 ++ apps/backend/notifications/views.py | 53 ++++++ apps/backend/posts/services.py | 21 ++- apps/backend/tasks/periodic_tasks_registry.py | 9 + apps/backend/users/views/subscriptions.py | 2 + 25 files changed, 643 insertions(+), 4 deletions(-) create mode 100644 apps/backend/notifications/__init__.py create mode 100644 apps/backend/notifications/admin.py create mode 100644 apps/backend/notifications/apps.py create mode 100644 apps/backend/notifications/migrations/0001_initial.py create mode 100644 apps/backend/notifications/migrations/__init__.py create mode 100644 apps/backend/notifications/models.py create mode 100644 apps/backend/notifications/serializers.py create mode 100644 apps/backend/notifications/services.py create mode 100644 apps/backend/notifications/tasks.py create mode 100644 apps/backend/notifications/tests/__init__.py create mode 100644 apps/backend/notifications/tests/test_services.py create mode 100644 apps/backend/notifications/tests/test_views.py create mode 100644 apps/backend/notifications/urls.py create mode 100644 apps/backend/notifications/views.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca8f2a7..04d8bf7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,6 +95,7 @@ jobs: backend core logs + notifications posts reactions reports diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index 868b6c4..0214817 100644 --- a/apps/backend/AGENTS.md +++ b/apps/backend/AGENTS.md @@ -34,7 +34,7 @@ This directory contains the Django backend for AlienCommons. Keep backend change - For lint-sensitive changes, prefer: ```bash - uv run ruff check articles bookmarks comments posts reactions reports core users logs tasks backend manage.py + uv run ruff check articles bookmarks comments notifications posts reactions reports core users logs tasks backend manage.py ``` - If local dependencies or services make a check impossible, say exactly what was not run and why. diff --git a/apps/backend/README.md b/apps/backend/README.md index 5ac9d0e..f2a35df 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -19,6 +19,7 @@ The Django API service for AlienCommons. - `articles`: source articles, published articles, collections, and article workflows - `bookmarks`: bookmark folders and article bookmarks - `comments`: article comments and replies +- `notifications`: user notification events, fan-out deliveries, and inbox APIs - `posts`: community posts and related content - `reactions`: like/dislike targets and user reactions - `reports`: content and user moderation reports @@ -34,7 +35,7 @@ uv run python manage.py check uv run python manage.py makemigrations uv run python manage.py migrate uv run python manage.py test --settings=backend.settings.test -uv run ruff check articles bookmarks comments posts reactions reports core users logs tasks backend manage.py +uv run ruff check articles bookmarks comments notifications posts reactions reports core users logs tasks backend manage.py ``` Or use the root Make targets: diff --git a/apps/backend/articles/services/articles.py b/apps/backend/articles/services/articles.py index 811e188..f817efc 100644 --- a/apps/backend/articles/services/articles.py +++ b/apps/backend/articles/services/articles.py @@ -9,6 +9,7 @@ from core.exceptions import ServiceError from core.utils.alienmark import render_md_to_html from logs.logging import get_logger +from notifications.services import notify_subscribed_author_posted from .markdown import extract_title_from_markdown, validate_article_markdown logger = get_logger(__name__) @@ -159,6 +160,11 @@ def _create_or_update_published_article(self) -> PublishedArticle: html=html, publication_at=timezone.now(), ) + notify_subscribed_author_posted( + actor=self.source_article.author, + target=published_article.content_target, + content_kind="published_article", + ) return published_article diff --git a/apps/backend/backend/settings/base.py b/apps/backend/backend/settings/base.py index e760898..c136aee 100644 --- a/apps/backend/backend/settings/base.py +++ b/apps/backend/backend/settings/base.py @@ -164,6 +164,7 @@ "reactions.apps.ReactionsConfig", "reports.apps.ReportsConfig", "posts.apps.PostsConfig", + "notifications.apps.NotificationsConfig", "tasks.apps.TasksConfig", "corsheaders", "rest_framework", diff --git a/apps/backend/backend/settings/test.py b/apps/backend/backend/settings/test.py index 0021f53..7e63a30 100644 --- a/apps/backend/backend/settings/test.py +++ b/apps/backend/backend/settings/test.py @@ -30,6 +30,7 @@ "reactions.apps.ReactionsConfig", "reports.apps.ReportsConfig", "posts.apps.PostsConfig", + "notifications.apps.NotificationsConfig", "tasks.apps.TasksConfig", "corsheaders", "rest_framework", diff --git a/apps/backend/backend/urls.py b/apps/backend/backend/urls.py index 2779bf0..e032a26 100644 --- a/apps/backend/backend/urls.py +++ b/apps/backend/backend/urls.py @@ -15,6 +15,7 @@ path("v1/", include("reactions.urls")), path("v1/", include("reports.urls")), path("v1/", include("posts.urls")), + path("v1/", include("notifications.urls")), ] urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/apps/backend/comments/services.py b/apps/backend/comments/services.py index d4c55ca..de7344e 100644 --- a/apps/backend/comments/services.py +++ b/apps/backend/comments/services.py @@ -8,6 +8,7 @@ get_or_create_community_post_target, get_or_create_published_article_target, ) +from notifications.services import notify_comment_reply, notify_mentions from posts.models import CommunityPost from .models import Comment @@ -50,14 +51,29 @@ def create_comment( body=body, mentions=mentions, ) - get_or_create_comment_target(comment) + comment_target = get_or_create_comment_target(comment) + notify_mentions( + actor=author, + target=comment_target, + mention_user_ids=mentions, + dedupe_prefix=f"comment:{comment.id}:created", + ) + if target.comment_id is not None: + notify_comment_reply(comment=comment) return comment def update_comment(*, comment: Comment, body: str, mentions: list): + previous_mentions = set(comment.mentions) comment.body = body comment.mentions = mentions comment.save(update_fields=["body", "mentions", "updated_at"]) + notify_mentions( + actor=comment.author, + target=comment.content_target, + mention_user_ids=set(mentions) - previous_mentions, + dedupe_prefix=f"comment:{comment.id}:updated", + ) return comment diff --git a/apps/backend/notifications/__init__.py b/apps/backend/notifications/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/backend/notifications/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/backend/notifications/admin.py b/apps/backend/notifications/admin.py new file mode 100644 index 0000000..bb80d36 --- /dev/null +++ b/apps/backend/notifications/admin.py @@ -0,0 +1,20 @@ +from django.contrib import admin + +from .models import NotificationDelivery, NotificationEvent + + +@admin.register(NotificationEvent) +class NotificationEventAdmin(admin.ModelAdmin): + list_display = ("id", "event_type", "actor", "target", "delivery_status", "created_at") + list_filter = ("event_type", "delivery_status", "created_at") + search_fields = ("dedupe_key", "actor__username") + readonly_fields = ("id", "created_at", "updated_at", "delivered_at") + + +@admin.register(NotificationDelivery) +class NotificationDeliveryAdmin(admin.ModelAdmin): + list_display = ("id", "recipient", "event", "read_at", "created_at") + list_filter = ("read_at", "created_at") + search_fields = ("recipient__username", "event__dedupe_key") + readonly_fields = ("id", "created_at", "updated_at") + diff --git a/apps/backend/notifications/apps.py b/apps/backend/notifications/apps.py new file mode 100644 index 0000000..002bc30 --- /dev/null +++ b/apps/backend/notifications/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "notifications" + diff --git a/apps/backend/notifications/migrations/0001_initial.py b/apps/backend/notifications/migrations/0001_initial.py new file mode 100644 index 0000000..ed62455 --- /dev/null +++ b/apps/backend/notifications/migrations/0001_initial.py @@ -0,0 +1,69 @@ +# Generated by Django 6.0.5 on 2026-05-22 11:30 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('core', '0002_remove_contenttarget_content_target_requires_exactly_one_object_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='NotificationEvent', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='The created DateTime of the object', verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, db_index=True, help_text='The updated DateTime of the object', verbose_name='updated at')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='The UUID of the object', primary_key=True, serialize=False, verbose_name='ID')), + ('event_type', models.IntegerField(choices=[(1, 'mention'), (2, 'comment reply'), (3, 'new subscriber'), (4, 'subscribed author posted')], db_index=True)), + ('data', models.JSONField(blank=True, default=dict)), + ('dedupe_key', models.CharField(max_length=255, unique=True)), + ('delivery_status', models.IntegerField(choices=[(1, 'pending'), (2, 'processing'), (3, 'delivered'), (4, 'failed')], db_index=True, default=1)), + ('delivery_attempts', models.PositiveIntegerField(default=0)), + ('delivered_at', models.DateTimeField(blank=True, null=True)), + ('last_error', models.TextField(blank=True, default='')), + ('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='notification_events', to=settings.AUTH_USER_MODEL)), + ('target', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='notification_events', to='core.contenttarget')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='NotificationDelivery', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='The created DateTime of the object', verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, db_index=True, help_text='The updated DateTime of the object', verbose_name='updated at')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='The UUID of the object', primary_key=True, serialize=False, verbose_name='ID')), + ('read_at', models.DateTimeField(blank=True, db_index=True, null=True)), + ('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notification_deliveries', to=settings.AUTH_USER_MODEL)), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='deliveries', to='notifications.notificationevent')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.AddIndex( + model_name='notificationevent', + index=models.Index(fields=['event_type', 'created_at'], name='notificatio_event_t_58f16e_idx'), + ), + migrations.AddIndex( + model_name='notificationevent', + index=models.Index(fields=['delivery_status', 'created_at'], name='notificatio_deliver_f76ded_idx'), + ), + migrations.AddIndex( + model_name='notificationdelivery', + index=models.Index(fields=['recipient', 'read_at', 'created_at'], name='notificatio_recipie_c4a9d7_idx'), + ), + migrations.AddConstraint( + model_name='notificationdelivery', + constraint=models.UniqueConstraint(fields=('event', 'recipient'), name='unique_notification_event_delivery_recipient'), + ), + ] diff --git a/apps/backend/notifications/migrations/__init__.py b/apps/backend/notifications/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/backend/notifications/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/backend/notifications/models.py b/apps/backend/notifications/models.py new file mode 100644 index 0000000..070d485 --- /dev/null +++ b/apps/backend/notifications/models.py @@ -0,0 +1,85 @@ +from django.conf import settings +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from core.model_mixins import TimeStampedMixin, UUIDPrimaryKeyMixin + + +class NotificationEvent(UUIDPrimaryKeyMixin, TimeStampedMixin, models.Model): + class EventType(models.IntegerChoices): + MENTION = 1, _("mention") + COMMENT_REPLY = 2, _("comment reply") + NEW_SUBSCRIBER = 3, _("new subscriber") + SUBSCRIBED_AUTHOR_POSTED = 4, _("subscribed author posted") + + class DeliveryStatus(models.IntegerChoices): + PENDING = 1, _("pending") + PROCESSING = 2, _("processing") + DELIVERED = 3, _("delivered") + FAILED = 4, _("failed") + + event_type = models.IntegerField(choices=EventType.choices, db_index=True) + actor = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="notification_events", + ) + target = models.ForeignKey( + "core.ContentTarget", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="notification_events", + ) + data = models.JSONField(default=dict, blank=True) + dedupe_key = models.CharField(max_length=255, unique=True) + delivery_status = models.IntegerField( + choices=DeliveryStatus.choices, + default=DeliveryStatus.PENDING, + db_index=True, + ) + delivery_attempts = models.PositiveIntegerField(default=0) + delivered_at = models.DateTimeField(null=True, blank=True) + last_error = models.TextField(blank=True, default="") + + class Meta: + ordering = ["-created_at"] + indexes = [ + models.Index(fields=["event_type", "created_at"]), + models.Index(fields=["delivery_status", "created_at"]), + ] + + def __str__(self): + return f"{self.get_event_type_display()} event {self.id}" + + +class NotificationDelivery(UUIDPrimaryKeyMixin, TimeStampedMixin, models.Model): + event = models.ForeignKey( + NotificationEvent, + on_delete=models.CASCADE, + related_name="deliveries", + ) + recipient = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="notification_deliveries", + ) + read_at = models.DateTimeField(null=True, blank=True, db_index=True) + + class Meta: + ordering = ["-created_at"] + constraints = [ + models.UniqueConstraint( + fields=["event", "recipient"], + name="unique_notification_event_delivery_recipient", + ), + ] + indexes = [ + models.Index(fields=["recipient", "read_at", "created_at"]), + ] + + def __str__(self): + return f"Notification {self.event_id} for {self.recipient_id}" + diff --git a/apps/backend/notifications/serializers.py b/apps/backend/notifications/serializers.py new file mode 100644 index 0000000..2be50c7 --- /dev/null +++ b/apps/backend/notifications/serializers.py @@ -0,0 +1,32 @@ +from rest_framework import serializers + +from users.serializers import UserListSerializer + +from .models import NotificationDelivery + + +class NotificationDeliverySerializer(serializers.ModelSerializer): + event_type = serializers.IntegerField(source="event.event_type", read_only=True) + actor = UserListSerializer(source="event.actor", read_only=True) + target = serializers.SerializerMethodField() + data = serializers.JSONField(source="event.data", read_only=True) + + def get_target(self, obj): + if obj.event.target_id is None: + return None + return str(obj.event.target_id) + + class Meta: + model = NotificationDelivery + fields = ( + "id", + "event", + "event_type", + "actor", + "target", + "data", + "read_at", + "created_at", + ) + read_only_fields = fields + diff --git a/apps/backend/notifications/services.py b/apps/backend/notifications/services.py new file mode 100644 index 0000000..4975d00 --- /dev/null +++ b/apps/backend/notifications/services.py @@ -0,0 +1,155 @@ +from django.db import transaction +from django.utils import timezone + +from users.models import UserSubscription + +from .models import NotificationDelivery, NotificationEvent +from .tasks import fan_out_notification_event_task + + +def _queue_fan_out(event): + transaction.on_commit( + lambda: fan_out_notification_event_task.enqueue(event_id=str(event.id)) + ) + + +def create_event(*, event_type, actor, target, data, dedupe_key): + event, created = NotificationEvent.objects.get_or_create( + dedupe_key=dedupe_key, + defaults={ + "event_type": event_type, + "actor": actor, + "target": target, + "data": data, + }, + ) + if created: + _queue_fan_out(event) + return event + + +def notify_mentions(*, actor, target, mention_user_ids, dedupe_prefix): + recipient_ids = sorted({str(user_id) for user_id in mention_user_ids if str(user_id) != str(actor.id)}) + if not recipient_ids: + return None + return create_event( + event_type=NotificationEvent.EventType.MENTION, + actor=actor, + target=target, + data={"recipient_ids": recipient_ids}, + dedupe_key=f"mention:{dedupe_prefix}:{','.join(recipient_ids)}", + ) + + +def notify_comment_reply(*, comment): + replied_to = comment.target.comment + if replied_to.author_id is None or replied_to.author_id == comment.author_id: + return None + return create_event( + event_type=NotificationEvent.EventType.COMMENT_REPLY, + actor=comment.author, + target=comment.content_target, + data={"recipient_ids": [str(replied_to.author_id)]}, + dedupe_key=f"comment-reply:{comment.id}:{replied_to.id}", + ) + + +def notify_new_subscriber(*, subscription): + return create_event( + event_type=NotificationEvent.EventType.NEW_SUBSCRIBER, + actor=subscription.subscriber, + target=None, + data={"recipient_ids": [str(subscription.subscribed_to_id)]}, + dedupe_key=f"new-subscriber:{subscription.id}", + ) + + +def notify_subscribed_author_posted(*, actor, target, content_kind): + return create_event( + event_type=NotificationEvent.EventType.SUBSCRIBED_AUTHOR_POSTED, + actor=actor, + target=target, + data={"content_kind": content_kind}, + dedupe_key=f"subscribed-author-posted:{content_kind}:{target.id}", + ) + + +def _recipient_ids(event): + explicit_ids = event.data.get("recipient_ids") + if explicit_ids is not None: + return explicit_ids + if event.event_type == NotificationEvent.EventType.SUBSCRIBED_AUTHOR_POSTED: + return UserSubscription.objects.filter( + subscribed_to_id=event.actor_id, + ).values_list("subscriber_id", flat=True) + return [] + + +def fan_out_event(*, event_id): + with transaction.atomic(): + event = NotificationEvent.objects.select_for_update().get(id=event_id) + if event.delivery_status == NotificationEvent.DeliveryStatus.DELIVERED: + return {"created": 0, "status": "already_delivered"} + event.delivery_status = NotificationEvent.DeliveryStatus.PROCESSING + event.delivery_attempts += 1 + event.last_error = "" + event.save(update_fields=["delivery_status", "delivery_attempts", "last_error", "updated_at"]) + + try: + recipient_ids = { + str(recipient_id) + for recipient_id in _recipient_ids(event) + if str(recipient_id) != str(event.actor_id) + } + deliveries = [ + NotificationDelivery(event=event, recipient_id=recipient_id) + for recipient_id in recipient_ids + ] + created = NotificationDelivery.objects.bulk_create(deliveries, ignore_conflicts=True) + except Exception as exc: + event.delivery_status = NotificationEvent.DeliveryStatus.FAILED + event.last_error = str(exc) + event.save(update_fields=["delivery_status", "last_error", "updated_at"]) + raise + + event.delivery_status = NotificationEvent.DeliveryStatus.DELIVERED + event.delivered_at = timezone.now() + event.save(update_fields=["delivery_status", "delivered_at", "updated_at"]) + return {"created": len(created), "status": "delivered"} + + +def fan_out_pending_events(*, batch_size=100): + event_ids = list( + NotificationEvent.objects.filter( + delivery_status__in=[ + NotificationEvent.DeliveryStatus.PENDING, + NotificationEvent.DeliveryStatus.PROCESSING, + NotificationEvent.DeliveryStatus.FAILED, + ], + ) + .order_by("created_at") + .values_list("id", flat=True)[:batch_size] + ) + delivered = 0 + failed = 0 + for event_id in event_ids: + try: + fan_out_event(event_id=event_id) + delivered += 1 + except Exception: + failed += 1 + return {"scanned": len(event_ids), "delivered": delivered, "failed": failed} + + +def mark_delivery_read(delivery): + if delivery.read_at is None: + delivery.read_at = timezone.now() + delivery.save(update_fields=["read_at", "updated_at"]) + return delivery + + +def mark_all_deliveries_read(*, recipient): + return NotificationDelivery.objects.filter( + recipient=recipient, + read_at__isnull=True, + ).update(read_at=timezone.now()) diff --git a/apps/backend/notifications/tasks.py b/apps/backend/notifications/tasks.py new file mode 100644 index 0000000..6c58151 --- /dev/null +++ b/apps/backend/notifications/tasks.py @@ -0,0 +1,15 @@ +from django.tasks import task + + +@task +def fan_out_notification_event_task(*, event_id): + from .services import fan_out_event + + return fan_out_event(event_id=event_id) + + +@task +def fan_out_pending_notification_events_task(*, batch_size=100): + from .services import fan_out_pending_events + + return fan_out_pending_events(batch_size=batch_size) diff --git a/apps/backend/notifications/tests/__init__.py b/apps/backend/notifications/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/backend/notifications/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/backend/notifications/tests/test_services.py b/apps/backend/notifications/tests/test_services.py new file mode 100644 index 0000000..ce63e1f --- /dev/null +++ b/apps/backend/notifications/tests/test_services.py @@ -0,0 +1,77 @@ +from comments.services import create_comment +from core.tests.factories import ( + create_comment as create_comment_record, + create_community_post, + create_published_article, + create_source_article, + create_user, +) +from core.tests.testcases import BaseTestCase +from notifications.models import NotificationEvent +from notifications.services import ( + fan_out_event, + fan_out_pending_events, + notify_subscribed_author_posted, +) +from users.models import UserSubscription + + +class NotificationServiceTests(BaseTestCase): + def setUp(self): + self.author = create_user(username="author") + self.reader = create_user(username="reader") + self.article = create_source_article(author=self.author, title="Guide") + self.published = create_published_article(self.article, title=self.article.title) + + def test_comment_mentions_and_reply_create_outbox_events(self): + parent = create_comment_record(self.reader, self.published, body="Top") + + comment = create_comment( + author=self.author, + body="Hi {{mention:0}}", + mentions=[str(self.reader.id)], + target=parent.content_target, + ) + + self.assertEqual(comment.parent, parent) + self.assertEqual( + set(NotificationEvent.objects.values_list("event_type", flat=True)), + { + NotificationEvent.EventType.MENTION, + NotificationEvent.EventType.COMMENT_REPLY, + }, + ) + + def test_subscribed_author_event_fans_out_to_subscribers(self): + UserSubscription.objects.create(subscriber=self.reader, subscribed_to=self.author) + own_subscription = create_user(username="own") + UserSubscription.objects.create(subscriber=own_subscription, subscribed_to=self.author) + post = create_community_post(author=self.author, body="Post") + event = notify_subscribed_author_posted( + actor=self.author, + target=post.content_target, + content_kind="community_post-extra", + ) + + result = fan_out_event(event_id=event.id) + + self.assertEqual(result["status"], "delivered") + self.assertEqual( + set(event.deliveries.values_list("recipient_id", flat=True)), + {self.reader.id, own_subscription.id}, + ) + + def test_outbox_drain_retries_pending_events(self): + UserSubscription.objects.create(subscriber=self.reader, subscribed_to=self.author) + event = notify_subscribed_author_posted( + actor=self.author, + target=self.published.content_target, + content_kind="published-article-extra", + ) + + result = fan_out_pending_events() + + event.refresh_from_db() + self.assertEqual(result, {"scanned": 1, "delivered": 1, "failed": 0}) + self.assertEqual(event.delivery_status, NotificationEvent.DeliveryStatus.DELIVERED) + self.assertTrue(event.deliveries.filter(recipient=self.reader).exists()) diff --git a/apps/backend/notifications/tests/test_views.py b/apps/backend/notifications/tests/test_views.py new file mode 100644 index 0000000..ff86703 --- /dev/null +++ b/apps/backend/notifications/tests/test_views.py @@ -0,0 +1,55 @@ +from django.urls import reverse +from rest_framework import status + +from core.tests.factories import create_user +from core.tests.testcases import BaseAPITestCase +from notifications.models import NotificationDelivery, NotificationEvent + + +class NotificationViewTests(BaseAPITestCase): + def setUp(self): + self.recipient = create_user(username="recipient") + self.other_user = create_user(username="other") + self.event = NotificationEvent.objects.create( + event_type=NotificationEvent.EventType.NEW_SUBSCRIBER, + actor=self.other_user, + data={"recipient_ids": [str(self.recipient.id)]}, + dedupe_key="view-event", + ) + self.delivery = NotificationDelivery.objects.create( + event=self.event, + recipient=self.recipient, + ) + + def test_user_lists_only_own_notifications(self): + NotificationDelivery.objects.create(event=self.event, recipient=self.other_user) + self.authenticate(self.recipient) + + response = self.get_json(reverse("notification-list")) + + self.assert_success_response(response, status_code=status.HTTP_200_OK, code="listed") + self.assertEqual(len(response.data["data"]["results"]), 1) + self.assertEqual(response.data["data"]["results"][0]["event_type"], self.event.event_type) + + def test_user_can_mark_one_and_all_notifications_read(self): + second_event = NotificationEvent.objects.create( + event_type=NotificationEvent.EventType.MENTION, + actor=self.other_user, + data={"recipient_ids": [str(self.recipient.id)]}, + dedupe_key="second-view-event", + ) + second_delivery = NotificationDelivery.objects.create( + event=second_event, + recipient=self.recipient, + ) + self.authenticate(self.recipient) + + unread = self.get_json(reverse("notification-unread-count")) + marked = self.post_json(reverse("notification-mark-read", args=[self.delivery.id])) + marked_all = self.post_json(reverse("notification-mark-all-read")) + + second_delivery.refresh_from_db() + self.assertEqual(unread.data["data"]["count"], 2) + self.assert_success_response(marked, status_code=status.HTTP_200_OK, code="marked_read") + self.assert_success_response(marked_all, status_code=status.HTTP_200_OK, code="marked_all_read") + self.assertIsNotNone(second_delivery.read_at) diff --git a/apps/backend/notifications/urls.py b/apps/backend/notifications/urls.py new file mode 100644 index 0000000..0771c2a --- /dev/null +++ b/apps/backend/notifications/urls.py @@ -0,0 +1,11 @@ +from rest_framework import routers + +from .views import NotificationDeliveryViewSet + + +router = routers.SimpleRouter() +router.register(r"notifications", NotificationDeliveryViewSet, basename="notification") + +urlpatterns = [] +urlpatterns += router.urls + diff --git a/apps/backend/notifications/views.py b/apps/backend/notifications/views.py new file mode 100644 index 0000000..093a976 --- /dev/null +++ b/apps/backend/notifications/views.py @@ -0,0 +1,53 @@ +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated + +from core.views.viewsets import MyReadOnlyModelViewSet + +from .models import NotificationDelivery +from .serializers import NotificationDeliverySerializer +from .services import mark_all_deliveries_read, mark_delivery_read + + +class NotificationDeliveryViewSet(MyReadOnlyModelViewSet): + serializer_class = NotificationDeliverySerializer + permission_classes = [IsAuthenticated] + queryset = NotificationDelivery.objects.select_related( + "recipient", + "event", + "event__actor", + "event__target", + ) + + def get_queryset(self): + return super().get_queryset().filter(recipient=self.request.user) + + @action(detail=False, methods=["get"], url_path="unread_count") + def unread_count(self, request): + count = self.get_queryset().filter(read_at__isnull=True).count() + return self.format_success_response( + message="retrieved", + code="unread_count_retrieved", + data={"count": count}, + ) + + @action(detail=True, methods=["post"], url_path="mark_read") + def mark_read(self, request, pk=None): + delivery = mark_delivery_read(self.get_object()) + serializer = self.get_serializer(delivery) + return self.format_success_response( + message="updated", + code="marked_read", + data=serializer.data, + ) + + @action(detail=False, methods=["post"], url_path="mark_all_read") + def mark_all_read(self, request): + updated = mark_all_deliveries_read(recipient=request.user) + return self.format_success_response( + message="updated", + code="marked_all_read", + data={"updated": updated}, + status_code=status.HTTP_200_OK, + ) + diff --git a/apps/backend/posts/services.py b/apps/backend/posts/services.py index 916430b..474423a 100644 --- a/apps/backend/posts/services.py +++ b/apps/backend/posts/services.py @@ -1,19 +1,38 @@ from core.services.content_targets import get_or_create_community_post_target +from notifications.services import notify_mentions, notify_subscribed_author_posted from .models import CommunityPost def create_community_post(*, author, body: str, mentions: list = None): post = CommunityPost.objects.create(author=author, body=body, mentions=mentions or []) - get_or_create_community_post_target(post) + target = get_or_create_community_post_target(post) + notify_mentions( + actor=author, + target=target, + mention_user_ids=post.mentions, + dedupe_prefix=f"community-post:{post.id}:created", + ) + notify_subscribed_author_posted( + actor=author, + target=target, + content_kind="community_post", + ) return post def update_community_post(*, post: CommunityPost, body: str, mentions: list = None): + previous_mentions = set(post.mentions) post.body = body if mentions is not None: post.mentions = mentions post.save(update_fields=["body", "mentions", "updated_at"]) + notify_mentions( + actor=post.author, + target=post.content_target, + mention_user_ids=set(post.mentions) - previous_mentions, + dedupe_prefix=f"community-post:{post.id}:updated", + ) return post diff --git a/apps/backend/tasks/periodic_tasks_registry.py b/apps/backend/tasks/periodic_tasks_registry.py index d43b8d5..8b50222 100644 --- a/apps/backend/tasks/periodic_tasks_registry.py +++ b/apps/backend/tasks/periodic_tasks_registry.py @@ -22,4 +22,13 @@ }, "args": [1], # grace_days=1 }, + { + "name": "fan out pending notification events", + "task": "notifications.tasks.fan_out_pending_notification_events_task", + "queue_name": "maintenance", + "schedule": { + "every": 1, + "period": "minute", + }, + }, ] diff --git a/apps/backend/users/views/subscriptions.py b/apps/backend/users/views/subscriptions.py index d8f1697..4fc3b0c 100644 --- a/apps/backend/users/views/subscriptions.py +++ b/apps/backend/users/views/subscriptions.py @@ -2,6 +2,7 @@ from rest_framework.permissions import IsAuthenticated from core.views.viewsets import MyModelViewSet +from notifications.services import notify_new_subscriber from ..models import UserSubscription from ..serializers import UserSubscriptionReadSerializer, UserSubscriptionWriteSerializer @@ -34,6 +35,7 @@ def create(self, request, *args, **kwargs): ) input_serializer.is_valid(raise_exception=True) subscription = input_serializer.save(subscriber=request.user) + notify_new_subscriber(subscription=subscription) output_serializer = UserSubscriptionReadSerializer( instance=subscription, context=self.get_serializer_context(), From c48dc76856dd7753dab4d2522fc8c5ecda7c9780 Mon Sep 17 00:00:00 2001 From: Typogalaxy <136544101+Typogalaxy@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:18:17 +1000 Subject: [PATCH 2/6] refactor(backend): clarify notification event model --- apps/backend/notifications/admin.py | 5 +- ...event_reason_channel_payload_recipients.py | 109 ++++++++++++++++++ apps/backend/notifications/models.py | 43 +++++-- apps/backend/notifications/serializers.py | 11 +- apps/backend/notifications/services.py | 48 ++++---- .../notifications/tests/test_services.py | 17 ++- .../backend/notifications/tests/test_views.py | 25 +++- apps/backend/notifications/views.py | 17 ++- apps/backend/users/views/subscriptions.py | 2 - 9 files changed, 220 insertions(+), 57 deletions(-) create mode 100644 apps/backend/notifications/migrations/0002_event_reason_channel_payload_recipients.py diff --git a/apps/backend/notifications/admin.py b/apps/backend/notifications/admin.py index bb80d36..73d2eaa 100644 --- a/apps/backend/notifications/admin.py +++ b/apps/backend/notifications/admin.py @@ -5,8 +5,8 @@ @admin.register(NotificationEvent) class NotificationEventAdmin(admin.ModelAdmin): - list_display = ("id", "event_type", "actor", "target", "delivery_status", "created_at") - list_filter = ("event_type", "delivery_status", "created_at") + list_display = ("id", "reason", "channel", "actor", "target", "delivery_status", "created_at") + list_filter = ("reason", "channel", "delivery_status", "created_at") search_fields = ("dedupe_key", "actor__username") readonly_fields = ("id", "created_at", "updated_at", "delivered_at") @@ -17,4 +17,3 @@ class NotificationDeliveryAdmin(admin.ModelAdmin): list_filter = ("read_at", "created_at") search_fields = ("recipient__username", "event__dedupe_key") readonly_fields = ("id", "created_at", "updated_at") - diff --git a/apps/backend/notifications/migrations/0002_event_reason_channel_payload_recipients.py b/apps/backend/notifications/migrations/0002_event_reason_channel_payload_recipients.py new file mode 100644 index 0000000..8423a8a --- /dev/null +++ b/apps/backend/notifications/migrations/0002_event_reason_channel_payload_recipients.py @@ -0,0 +1,109 @@ +# Generated by Codex on 2026-06-19 + +from django.db import migrations, models + + +def split_payload_and_recipients(apps, schema_editor): + NotificationEvent = apps.get_model("notifications", "NotificationEvent") + UserSubscription = apps.get_model("users", "UserSubscription") + + for event in NotificationEvent.objects.all().iterator(): + payload = dict(event.payload or {}) + recipients = payload.pop("recipient_ids", []) + if event.reason == 4 and event.actor_id: + recipients = list( + UserSubscription.objects.filter( + subscribed_to_id=event.actor_id, + ).values_list("subscriber_id", flat=True) + ) + + if event.reason == 1: + channel = 1 + elif event.reason == 2: + channel = 2 + elif event.reason == 4: + channel = 4 + else: + channel = 3 + + event.payload = payload + event.recipients = [str(recipient_id) for recipient_id in recipients] + event.channel = channel + event.save(update_fields=["payload", "recipients", "channel"]) + + +def merge_recipients_into_payload(apps, schema_editor): + NotificationEvent = apps.get_model("notifications", "NotificationEvent") + + for event in NotificationEvent.objects.all().iterator(): + payload = dict(event.payload or {}) + if event.recipients: + payload["recipient_ids"] = event.recipients + event.payload = payload + event.save(update_fields=["payload"]) + + +class Migration(migrations.Migration): + dependencies = [ + ("notifications", "0001_initial"), + ("users", "0002_usersubscription"), + ] + + operations = [ + migrations.RemoveIndex( + model_name="notificationevent", + name="notificatio_event_t_58f16e_idx", + ), + migrations.RenameField( + model_name="notificationevent", + old_name="event_type", + new_name="reason", + ), + migrations.RenameField( + model_name="notificationevent", + old_name="data", + new_name="payload", + ), + migrations.AddField( + model_name="notificationevent", + name="channel", + field=models.IntegerField( + choices=[ + (1, "mentions"), + (2, "comments"), + (3, "system"), + (4, "subscriptions"), + ], + db_index=True, + default=3, + ), + preserve_default=False, + ), + migrations.AddField( + model_name="notificationevent", + name="recipients", + field=models.JSONField(blank=True, default=list), + ), + migrations.RunPython(split_payload_and_recipients, merge_recipients_into_payload), + migrations.AlterField( + model_name="notificationevent", + name="reason", + field=models.IntegerField( + choices=[ + (1, "mention"), + (2, "reply"), + (3, "system"), + (4, "subscription"), + ], + db_index=True, + ), + ), + migrations.AddIndex( + model_name="notificationevent", + index=models.Index(fields=["channel", "created_at"], name="notificatio_channel_7086bb_idx"), + ), + migrations.AddIndex( + model_name="notificationevent", + index=models.Index(fields=["reason", "created_at"], name="notificatio_reason_c4bfd9_idx"), + ), + ] diff --git a/apps/backend/notifications/models.py b/apps/backend/notifications/models.py index 070d485..f942381 100644 --- a/apps/backend/notifications/models.py +++ b/apps/backend/notifications/models.py @@ -6,11 +6,17 @@ class NotificationEvent(UUIDPrimaryKeyMixin, TimeStampedMixin, models.Model): - class EventType(models.IntegerChoices): + class Reason(models.IntegerChoices): MENTION = 1, _("mention") - COMMENT_REPLY = 2, _("comment reply") - NEW_SUBSCRIBER = 3, _("new subscriber") - SUBSCRIBED_AUTHOR_POSTED = 4, _("subscribed author posted") + REPLY = 2, _("reply") + SYSTEM = 3, _("system") + SUBSCRIPTION = 4, _("subscription") + + class Channel(models.IntegerChoices): + MENTIONS = 1, _("mentions") + COMMENTS = 2, _("comments") + SYSTEM = 3, _("system") + SUBSCRIPTIONS = 4, _("subscriptions") class DeliveryStatus(models.IntegerChoices): PENDING = 1, _("pending") @@ -18,7 +24,8 @@ class DeliveryStatus(models.IntegerChoices): DELIVERED = 3, _("delivered") FAILED = 4, _("failed") - event_type = models.IntegerField(choices=EventType.choices, db_index=True) + reason = models.IntegerField(choices=Reason.choices, db_index=True) + channel = models.IntegerField(choices=Channel.choices, db_index=True) actor = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, @@ -33,7 +40,8 @@ class DeliveryStatus(models.IntegerChoices): blank=True, related_name="notification_events", ) - data = models.JSONField(default=dict, blank=True) + payload = models.JSONField(default=dict, blank=True) + recipients = models.JSONField(default=list, blank=True) dedupe_key = models.CharField(max_length=255, unique=True) delivery_status = models.IntegerField( choices=DeliveryStatus.choices, @@ -47,12 +55,30 @@ class DeliveryStatus(models.IntegerChoices): class Meta: ordering = ["-created_at"] indexes = [ - models.Index(fields=["event_type", "created_at"]), + models.Index(fields=["channel", "created_at"]), + models.Index(fields=["reason", "created_at"]), models.Index(fields=["delivery_status", "created_at"]), ] + @classmethod + def channel_for_reason(cls, reason): + reason = cls.Reason(reason) + return { + cls.Reason.MENTION: cls.Channel.MENTIONS, + cls.Reason.REPLY: cls.Channel.COMMENTS, + cls.Reason.SYSTEM: cls.Channel.SYSTEM, + cls.Reason.SUBSCRIPTION: cls.Channel.SUBSCRIPTIONS, + }[reason] + + def save(self, *args, **kwargs): + self.channel = self.channel_for_reason(self.reason) + update_fields = kwargs.get("update_fields") + if update_fields is not None and "reason" in update_fields and "channel" not in update_fields: + kwargs["update_fields"] = [*update_fields, "channel"] + super().save(*args, **kwargs) + def __str__(self): - return f"{self.get_event_type_display()} event {self.id}" + return f"{self.get_reason_display()} event {self.id}" class NotificationDelivery(UUIDPrimaryKeyMixin, TimeStampedMixin, models.Model): @@ -82,4 +108,3 @@ class Meta: def __str__(self): return f"Notification {self.event_id} for {self.recipient_id}" - diff --git a/apps/backend/notifications/serializers.py b/apps/backend/notifications/serializers.py index 2be50c7..6ad90f2 100644 --- a/apps/backend/notifications/serializers.py +++ b/apps/backend/notifications/serializers.py @@ -6,10 +6,11 @@ class NotificationDeliverySerializer(serializers.ModelSerializer): - event_type = serializers.IntegerField(source="event.event_type", read_only=True) + reason = serializers.IntegerField(source="event.reason", read_only=True) + channel = serializers.IntegerField(source="event.channel", read_only=True) actor = UserListSerializer(source="event.actor", read_only=True) target = serializers.SerializerMethodField() - data = serializers.JSONField(source="event.data", read_only=True) + payload = serializers.JSONField(source="event.payload", read_only=True) def get_target(self, obj): if obj.event.target_id is None: @@ -21,12 +22,12 @@ class Meta: fields = ( "id", "event", - "event_type", + "reason", + "channel", "actor", "target", - "data", + "payload", "read_at", "created_at", ) read_only_fields = fields - diff --git a/apps/backend/notifications/services.py b/apps/backend/notifications/services.py index 4975d00..5f0c61b 100644 --- a/apps/backend/notifications/services.py +++ b/apps/backend/notifications/services.py @@ -13,14 +13,15 @@ def _queue_fan_out(event): ) -def create_event(*, event_type, actor, target, data, dedupe_key): +def create_event(*, reason, actor, target, payload=None, recipients=None, dedupe_key): event, created = NotificationEvent.objects.get_or_create( dedupe_key=dedupe_key, defaults={ - "event_type": event_type, + "reason": reason, "actor": actor, "target": target, - "data": data, + "payload": payload or {}, + "recipients": recipients or [], }, ) if created: @@ -33,10 +34,10 @@ def notify_mentions(*, actor, target, mention_user_ids, dedupe_prefix): if not recipient_ids: return None return create_event( - event_type=NotificationEvent.EventType.MENTION, + reason=NotificationEvent.Reason.MENTION, actor=actor, target=target, - data={"recipient_ids": recipient_ids}, + recipients=recipient_ids, dedupe_key=f"mention:{dedupe_prefix}:{','.join(recipient_ids)}", ) @@ -46,43 +47,36 @@ def notify_comment_reply(*, comment): if replied_to.author_id is None or replied_to.author_id == comment.author_id: return None return create_event( - event_type=NotificationEvent.EventType.COMMENT_REPLY, + reason=NotificationEvent.Reason.REPLY, actor=comment.author, target=comment.content_target, - data={"recipient_ids": [str(replied_to.author_id)]}, + recipients=[str(replied_to.author_id)], dedupe_key=f"comment-reply:{comment.id}:{replied_to.id}", ) -def notify_new_subscriber(*, subscription): - return create_event( - event_type=NotificationEvent.EventType.NEW_SUBSCRIBER, - actor=subscription.subscriber, - target=None, - data={"recipient_ids": [str(subscription.subscribed_to_id)]}, - dedupe_key=f"new-subscriber:{subscription.id}", - ) - - def notify_subscribed_author_posted(*, actor, target, content_kind): + recipient_ids = sorted( + str(subscriber_id) + for subscriber_id in UserSubscription.objects.filter( + subscribed_to_id=actor.id, + ).values_list("subscriber_id", flat=True) + if str(subscriber_id) != str(actor.id) + ) + if not recipient_ids: + return None return create_event( - event_type=NotificationEvent.EventType.SUBSCRIBED_AUTHOR_POSTED, + reason=NotificationEvent.Reason.SUBSCRIPTION, actor=actor, target=target, - data={"content_kind": content_kind}, + payload={"content_kind": content_kind}, + recipients=recipient_ids, dedupe_key=f"subscribed-author-posted:{content_kind}:{target.id}", ) def _recipient_ids(event): - explicit_ids = event.data.get("recipient_ids") - if explicit_ids is not None: - return explicit_ids - if event.event_type == NotificationEvent.EventType.SUBSCRIBED_AUTHOR_POSTED: - return UserSubscription.objects.filter( - subscribed_to_id=event.actor_id, - ).values_list("subscriber_id", flat=True) - return [] + return event.recipients def fan_out_event(*, event_id): diff --git a/apps/backend/notifications/tests/test_services.py b/apps/backend/notifications/tests/test_services.py index ce63e1f..82b6b39 100644 --- a/apps/backend/notifications/tests/test_services.py +++ b/apps/backend/notifications/tests/test_services.py @@ -35,10 +35,17 @@ def test_comment_mentions_and_reply_create_outbox_events(self): self.assertEqual(comment.parent, parent) self.assertEqual( - set(NotificationEvent.objects.values_list("event_type", flat=True)), + set(NotificationEvent.objects.values_list("reason", flat=True)), { - NotificationEvent.EventType.MENTION, - NotificationEvent.EventType.COMMENT_REPLY, + NotificationEvent.Reason.MENTION, + NotificationEvent.Reason.REPLY, + }, + ) + self.assertEqual( + set(NotificationEvent.objects.values_list("channel", flat=True)), + { + NotificationEvent.Channel.MENTIONS, + NotificationEvent.Channel.COMMENTS, }, ) @@ -52,10 +59,14 @@ def test_subscribed_author_event_fans_out_to_subscribers(self): target=post.content_target, content_kind="community_post-extra", ) + UserSubscription.objects.filter(subscriber=self.reader, subscribed_to=self.author).delete() result = fan_out_event(event_id=event.id) self.assertEqual(result["status"], "delivered") + self.assertEqual(event.reason, NotificationEvent.Reason.SUBSCRIPTION) + self.assertEqual(event.channel, NotificationEvent.Channel.SUBSCRIPTIONS) + self.assertEqual(event.payload, {"content_kind": "community_post-extra"}) self.assertEqual( set(event.deliveries.values_list("recipient_id", flat=True)), {self.reader.id, own_subscription.id}, diff --git a/apps/backend/notifications/tests/test_views.py b/apps/backend/notifications/tests/test_views.py index ff86703..188f90f 100644 --- a/apps/backend/notifications/tests/test_views.py +++ b/apps/backend/notifications/tests/test_views.py @@ -11,9 +11,9 @@ def setUp(self): self.recipient = create_user(username="recipient") self.other_user = create_user(username="other") self.event = NotificationEvent.objects.create( - event_type=NotificationEvent.EventType.NEW_SUBSCRIBER, + reason=NotificationEvent.Reason.SYSTEM, actor=self.other_user, - data={"recipient_ids": [str(self.recipient.id)]}, + recipients=[str(self.recipient.id)], dedupe_key="view-event", ) self.delivery = NotificationDelivery.objects.create( @@ -29,13 +29,28 @@ def test_user_lists_only_own_notifications(self): self.assert_success_response(response, status_code=status.HTTP_200_OK, code="listed") self.assertEqual(len(response.data["data"]["results"]), 1) - self.assertEqual(response.data["data"]["results"][0]["event_type"], self.event.event_type) + self.assertEqual(response.data["data"]["results"][0]["reason"], self.event.reason) + self.assertEqual(response.data["data"]["results"][0]["channel"], self.event.channel) + self.assertNotIn("recipients", response.data["data"]["results"][0]) + + def test_user_filters_notifications_by_channel(self): + NotificationDelivery.objects.create(event=self.event, recipient=self.other_user) + self.authenticate(self.recipient) + + response = self.get_json(f"{reverse('notification-list')}?channel=system") + + self.assert_success_response(response, status_code=status.HTTP_200_OK, code="listed") + self.assertEqual(len(response.data["data"]["results"]), 1) + self.assertEqual( + response.data["data"]["results"][0]["channel"], + NotificationEvent.Channel.SYSTEM, + ) def test_user_can_mark_one_and_all_notifications_read(self): second_event = NotificationEvent.objects.create( - event_type=NotificationEvent.EventType.MENTION, + reason=NotificationEvent.Reason.MENTION, actor=self.other_user, - data={"recipient_ids": [str(self.recipient.id)]}, + recipients=[str(self.recipient.id)], dedupe_key="second-view-event", ) second_delivery = NotificationDelivery.objects.create( diff --git a/apps/backend/notifications/views.py b/apps/backend/notifications/views.py index 093a976..e504cbc 100644 --- a/apps/backend/notifications/views.py +++ b/apps/backend/notifications/views.py @@ -2,9 +2,10 @@ from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated +from core.exceptions import ServiceError from core.views.viewsets import MyReadOnlyModelViewSet -from .models import NotificationDelivery +from .models import NotificationDelivery, NotificationEvent from .serializers import NotificationDeliverySerializer from .services import mark_all_deliveries_read, mark_delivery_read @@ -20,7 +21,18 @@ class NotificationDeliveryViewSet(MyReadOnlyModelViewSet): ) def get_queryset(self): - return super().get_queryset().filter(recipient=self.request.user) + queryset = super().get_queryset().filter(recipient=self.request.user) + channel = self.request.query_params.get("channel") + if channel: + try: + channel_value = NotificationEvent.Channel[channel.upper()] + except KeyError as exc: + raise ServiceError( + detail="Invalid notification channel", + code="invalid_notification_channel", + ) from exc + queryset = queryset.filter(event__channel=channel_value) + return queryset @action(detail=False, methods=["get"], url_path="unread_count") def unread_count(self, request): @@ -50,4 +62,3 @@ def mark_all_read(self, request): data={"updated": updated}, status_code=status.HTTP_200_OK, ) - diff --git a/apps/backend/users/views/subscriptions.py b/apps/backend/users/views/subscriptions.py index 4fc3b0c..d8f1697 100644 --- a/apps/backend/users/views/subscriptions.py +++ b/apps/backend/users/views/subscriptions.py @@ -2,7 +2,6 @@ from rest_framework.permissions import IsAuthenticated from core.views.viewsets import MyModelViewSet -from notifications.services import notify_new_subscriber from ..models import UserSubscription from ..serializers import UserSubscriptionReadSerializer, UserSubscriptionWriteSerializer @@ -35,7 +34,6 @@ def create(self, request, *args, **kwargs): ) input_serializer.is_valid(raise_exception=True) subscription = input_serializer.save(subscriber=request.user) - notify_new_subscriber(subscription=subscription) output_serializer = UserSubscriptionReadSerializer( instance=subscription, context=self.get_serializer_context(), From 2d2747a1633b55a320408ee5e4214b9ca4e2e59d Mon Sep 17 00:00:00 2001 From: Typogalaxy <136544101+Typogalaxy@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:48:26 +1000 Subject: [PATCH 3/6] fix(notification): update channel name --- apps/backend/notifications/models.py | 4 ++-- apps/backend/notifications/tests/test_services.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/backend/notifications/models.py b/apps/backend/notifications/models.py index f942381..25e01b6 100644 --- a/apps/backend/notifications/models.py +++ b/apps/backend/notifications/models.py @@ -14,7 +14,7 @@ class Reason(models.IntegerChoices): class Channel(models.IntegerChoices): MENTIONS = 1, _("mentions") - COMMENTS = 2, _("comments") + REPLIES = 2, _("replies") SYSTEM = 3, _("system") SUBSCRIPTIONS = 4, _("subscriptions") @@ -65,7 +65,7 @@ def channel_for_reason(cls, reason): reason = cls.Reason(reason) return { cls.Reason.MENTION: cls.Channel.MENTIONS, - cls.Reason.REPLY: cls.Channel.COMMENTS, + cls.Reason.REPLY: cls.Channel.REPLIES, cls.Reason.SYSTEM: cls.Channel.SYSTEM, cls.Reason.SUBSCRIPTION: cls.Channel.SUBSCRIPTIONS, }[reason] diff --git a/apps/backend/notifications/tests/test_services.py b/apps/backend/notifications/tests/test_services.py index 82b6b39..9d0eb75 100644 --- a/apps/backend/notifications/tests/test_services.py +++ b/apps/backend/notifications/tests/test_services.py @@ -45,7 +45,7 @@ def test_comment_mentions_and_reply_create_outbox_events(self): set(NotificationEvent.objects.values_list("channel", flat=True)), { NotificationEvent.Channel.MENTIONS, - NotificationEvent.Channel.COMMENTS, + NotificationEvent.Channel.REPLIES, }, ) From e8a35c8b1426f3692cc115b0adc669d3e0d7bd52 Mon Sep 17 00:00:00 2001 From: Typogalaxy <136544101+Typogalaxy@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:08:09 +1000 Subject: [PATCH 4/6] chore: update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ce0e77d..d5d9115 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # AlienCommons -> The common land for every TechMC player — a full-stack community platform for publishing, discussion, and discovery. +> The common planet for all Minecraft players — a community platform for publishing, discussion, and discovery. [![License](https://img.shields.io/badge/License-MIT-yellow?style=flat-square)](LICENSE) [![Python](https://img.shields.io/badge/Python-3.14-3776AB?style=flat-square&logo=python)](https://python.org) From d17bbf3c230fc578df72f377710aef221e89a6da Mon Sep 17 00:00:00 2001 From: Typogalaxy <136544101+Typogalaxy@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:23:29 +1000 Subject: [PATCH 5/6] fix(ci): restore node check script --- .../app/features/users/components/ThemeSetting.vue | 8 ++++---- docs/contributors/mkdocs.yml | 14 +++++++------- package.json | 1 + 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/apps/frontend/app/features/users/components/ThemeSetting.vue b/apps/frontend/app/features/users/components/ThemeSetting.vue index 67ac997..b7e7193 100644 --- a/apps/frontend/app/features/users/components/ThemeSetting.vue +++ b/apps/frontend/app/features/users/components/ThemeSetting.vue @@ -18,9 +18,9 @@ function applyTheme(mode) { @click="applyTheme('light')" >
-

This is a line.

+

This is a line.

Light Default

@@ -30,9 +30,9 @@ function applyTheme(mode) { @click="applyTheme('dark')" >
-

This is a line.

+

This is a line.

Dark Default

diff --git a/docs/contributors/mkdocs.yml b/docs/contributors/mkdocs.yml index 351a562..5bd0cba 100644 --- a/docs/contributors/mkdocs.yml +++ b/docs/contributors/mkdocs.yml @@ -5,13 +5,13 @@ nav: - Home: index.md - Overview: overview.md - Product: - - product/index.md - - product/roles.md - - product/articles.md - - product/discovery.md - - product/community.md - - product/moderation.md - - product/open-questions.md + - product/index.md + - product/roles.md + - product/articles.md + - product/discovery.md + - product/community.md + - product/moderation.md + - product/open-questions.md - Architecture: architecture.md - Development: - development/setup.md diff --git a/package.json b/package.json index fe95fac..8db8ac8 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "private": true, "packageManager": "pnpm@11.5.0+sha512.dbfcc4f81cf48597afd4bc391ffdf12c11f1a9fb83a395bfa6b0a2d9cc2fd8ffebafdb1ccbd529632153f793904c2615b7f09fe1a345473fd1c35845172a8eb1", "scripts": { + "check": "vp check", "alienmark-service:build": "pnpm --filter alienmark-service build", "alienmark-service:dev": "pnpm --filter alienmark-service dev", "alienmark-service:start": "pnpm --filter alienmark-service start", From 8d9312c5e4404e31f2bb442892754320036af6fa Mon Sep 17 00:00:00 2001 From: Typogalaxy <136544101+Typogalaxy@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:27:08 +1000 Subject: [PATCH 6/6] fix(ci): build alienmark before node check --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8db8ac8..10ddc11 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "packageManager": "pnpm@11.5.0+sha512.dbfcc4f81cf48597afd4bc391ffdf12c11f1a9fb83a395bfa6b0a2d9cc2fd8ffebafdb1ccbd529632153f793904c2615b7f09fe1a345473fd1c35845172a8eb1", "scripts": { - "check": "vp check", + "check": "pnpm --filter alienmark build && vp check", "alienmark-service:build": "pnpm --filter alienmark-service build", "alienmark-service:dev": "pnpm --filter alienmark-service dev", "alienmark-service:start": "pnpm --filter alienmark-service start",