From f2ffb6e7d116e8272c3bb9026339a93734fb723e Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Tue, 10 Mar 2026 18:58:44 -0300 Subject: [PATCH 1/8] Ensure we send hasMarkedAsRead request only after loading comments list --- front_end/src/components/comment_feed/index.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/front_end/src/components/comment_feed/index.tsx b/front_end/src/components/comment_feed/index.tsx index 1e4fba339b..fb2fd7d80b 100644 --- a/front_end/src/components/comment_feed/index.tsx +++ b/front_end/src/components/comment_feed/index.tsx @@ -241,17 +241,18 @@ const CommentFeed: FC = ({ if (user?.id && postId) { fetchUserComments(user.id); - // Send BE request that user has read the post - const handler = setTimeout(() => { - markPostAsRead(postId).then(); - }, 200); - - return () => { - clearTimeout(handler); - }; } }, [postId, user?.id]); + // Mark post as read after initial comments load completes + const hasMarkedAsRead = useRef(false); + useEffect(() => { + if (!isLoading && postId && user?.id && !hasMarkedAsRead.current) { + hasMarkedAsRead.current = true; + markPostAsRead(postId); + } + }, [isLoading, postId, user?.id]); + const feedOptions: GroupButton[] = [ { value: "public", From fb8e2a58bbea9b4b5f01074865e3e7eb2a2c6a94 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Tue, 10 Mar 2026 19:15:19 -0300 Subject: [PATCH 2/8] Backend unread pagination: prioritize threads with unread comments for the current user --- comments/serializers/common.py | 1 + comments/services/feed.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/comments/serializers/common.py b/comments/serializers/common.py index 9b40ced69a..3e1e23c589 100644 --- a/comments/serializers/common.py +++ b/comments/serializers/common.py @@ -25,6 +25,7 @@ class CommentFilterSerializer(serializers.Serializer): focus_comment_id = serializers.IntegerField(required=False, allow_null=True) is_private = serializers.BooleanField(required=False, allow_null=True) include_deleted = serializers.BooleanField(required=False, allow_null=True) + last_viewed_at = serializers.DateTimeField(required=False, allow_null=True) def validate_post(self, value: int): try: diff --git a/comments/services/feed.py b/comments/services/feed.py index 4d557dea12..95ab386e88 100644 --- a/comments/services/feed.py +++ b/comments/services/feed.py @@ -1,3 +1,5 @@ +from datetime import datetime + from django.db.models import Q, Case, When, Value, IntegerField, Exists, OuterRef from comments.models import Comment @@ -13,6 +15,7 @@ def get_comments_feed( is_private=None, focus_comment_id: int = None, include_deleted=False, + last_viewed_at: datetime = None, ): user = user if user and user.is_authenticated else None sort = sort or "-created_at" @@ -40,6 +43,35 @@ def get_comments_feed( order_by_args.append("-is_pinned_thread") + # Prioritize threads with unread comments for the current user + if last_viewed_at: + unread_comments = Comment.objects.filter( + on_post=post, + created_at__gt=last_viewed_at, + is_soft_deleted=False, + is_private=False, + ) + unread_root_ids = { + root_id or comment_id + for root_id, comment_id in unread_comments.values_list( + "root_id", "id" + ) + } + + if unread_root_ids: + qs = qs.annotate( + has_unread_thread=Case( + When( + Q(pk__in=unread_root_ids) + | Q(root_id__in=unread_root_ids), + then=Value(1), + ), + default=Value(0), + output_field=IntegerField(), + ), + ) + order_by_args.append("-has_unread_thread") + if author is not None: qs = qs.filter(author_id=author) From 69b600a0220215e21544fce7905e5455f0dd617b Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Wed, 11 Mar 2026 11:03:50 -0300 Subject: [PATCH 3/8] Added tests --- comments/services/feed.py | 7 +- tests/unit/test_comments/test_views.py | 115 +++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 5 deletions(-) diff --git a/comments/services/feed.py b/comments/services/feed.py index 95ab386e88..55b829ac56 100644 --- a/comments/services/feed.py +++ b/comments/services/feed.py @@ -53,17 +53,14 @@ def get_comments_feed( ) unread_root_ids = { root_id or comment_id - for root_id, comment_id in unread_comments.values_list( - "root_id", "id" - ) + for root_id, comment_id in unread_comments.values_list("root_id", "id") } if unread_root_ids: qs = qs.annotate( has_unread_thread=Case( When( - Q(pk__in=unread_root_ids) - | Q(root_id__in=unread_root_ids), + Q(pk__in=unread_root_ids) | Q(root_id__in=unread_root_ids), then=Value(1), ), default=Value(0), diff --git a/tests/unit/test_comments/test_views.py b/tests/unit/test_comments/test_views.py index 0ac2c71e2d..6886e8566f 100644 --- a/tests/unit/test_comments/test_views.py +++ b/tests/unit/test_comments/test_views.py @@ -1,4 +1,5 @@ from datetime import timedelta +from urllib.parse import urlencode import pytest # noqa from django.urls import reverse @@ -14,6 +15,7 @@ KeyFactorNews, ) from comments.services.feed import get_comments_feed +from posts.models import PostUserSnapshot from questions.models import Forecast from questions.services.forecasts import create_forecast from tests.unit.test_comments.factories import factory_comment, factory_key_factor @@ -842,3 +844,116 @@ def test_comment_edit_include_forecast_closed_question( comment.refresh_from_db() # Should attach forecast active at closure time assert comment.included_forecast == forecast + + +class TestUnreadThreadPrioritization: + """Test that threads with unread comments are prioritized on the first page + and that ordering remains consistent across paginated requests even after + markPostAsRead updates the snapshot.""" + + @pytest.fixture() + def setup(self, user1, user2): + post = factory_post(author=user1) + now = timezone.now() + viewed_at = now - timedelta(hours=1) + + # Create 3 old root comments (before viewed_at) — all "read" + old_roots = [] + for i in range(3): + with freeze_time(now - timedelta(hours=3, minutes=i)): + c = factory_comment(author=user2, on_post=post, text=f"old_root_{i}") + old_roots.append(c) + + # Create 2 old root comments that have NEW replies (after viewed_at) + roots_with_new_replies = [] + for i in range(2): + with freeze_time(now - timedelta(hours=3, minutes=10 + i)): + root = factory_comment( + author=user2, on_post=post, text=f"root_with_reply_{i}" + ) + with freeze_time(now - timedelta(minutes=30 - i)): + factory_comment( + author=user2, + on_post=post, + parent=root, + text=f"new_reply_{i}", + ) + roots_with_new_replies.append(root) + + # Create 3 new root comments (after viewed_at) — all "unread" + new_roots = [] + for i in range(3): + with freeze_time(now - timedelta(minutes=20 - i)): + c = factory_comment(author=user2, on_post=post, text=f"new_root_{i}") + new_roots.append(c) + + return { + "post": post, + "viewed_at": viewed_at, + "old_roots": old_roots, + "roots_with_new_replies": roots_with_new_replies, + "new_roots": new_roots, + } + + def test_unread_threads_first_across_pages(self, user1_client, setup, user1): + """With 8 root comments (5 unread threads, 3 read), page size 3: + - Page 1 should contain 3 unread threads + - Page 2 should contain 2 unread + 1 read thread + - Page 3 should contain remaining 2 read threads + Even after markPostAsRead fires between page 1 and page 2, + the ordering should stay consistent because last_viewed_at is + passed as a query param.""" + post = setup["post"] + viewed_at = setup["viewed_at"] + old_roots = setup["old_roots"] + roots_with_new_replies = setup["roots_with_new_replies"] + new_roots = setup["new_roots"] + + unread_root_ids = {c.pk for c in new_roots} | { + c.pk for c in roots_with_new_replies + } + read_root_ids = {c.pk for c in old_roots} + + params = urlencode( + { + "post": post.pk, + "limit": 3, + "sort": "-created_at", + "use_root_comments_pagination": "true", + "last_viewed_at": viewed_at.isoformat(), + } + ) + + # Page 1: all 3 root comments should be from unread threads + response = user1_client.get(f"/api/comments/?{params}") + assert response.status_code == 200 + page1_root_ids = { + r["id"] for r in response.data["results"] if r["parent_id"] is None + } + assert page1_root_ids.issubset(unread_root_ids) + assert len(page1_root_ids) == 3 + + # Simulate markPostAsRead (updates snapshot to now) + PostUserSnapshot.update_viewed_at(post, user1) + + # Page 2: uses same last_viewed_at, so ordering is stable + response = user1_client.get(response.data["next"]) + assert response.status_code == 200 + page2_root_ids = { + r["id"] for r in response.data["results"] if r["parent_id"] is None + } + # Should have remaining 2 unread + 1 read + assert (page2_root_ids & unread_root_ids) == unread_root_ids - page1_root_ids + assert len(page2_root_ids & read_root_ids) >= 1 + + # Page 3: remaining read threads + response = user1_client.get(response.data["next"]) + assert response.status_code == 200 + page3_root_ids = { + r["id"] for r in response.data["results"] if r["parent_id"] is None + } + assert page3_root_ids.issubset(read_root_ids) + + # All roots accounted for, no duplicates + all_paged = page1_root_ids | page2_root_ids | page3_root_ids + assert all_paged == unread_root_ids | read_root_ids From c9c86105116a3e95a6cfaa2738933db587b0bd52 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Wed, 11 Mar 2026 12:54:23 -0300 Subject: [PATCH 4/8] Fixed combination with is_focused_comment --- comments/services/feed.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/comments/services/feed.py b/comments/services/feed.py index 55b829ac56..4def2333e5 100644 --- a/comments/services/feed.py +++ b/comments/services/feed.py @@ -108,7 +108,14 @@ def get_comments_feed( output_field=IntegerField(), ) ) - order_by_args.append("-is_focused_comment") + # Insert after pinned but before unread prioritization + # so focused comment always appears on the first page + pinned_idx = ( + order_by_args.index("-is_pinned_thread") + 1 + if "-is_pinned_thread" in order_by_args + else 0 + ) + order_by_args.insert(pinned_idx, "-is_focused_comment") if sort: if "vote_score" in sort: From 00fa864c89d2131a50d12d7320b657da29810a3f Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Thu, 12 Mar 2026 00:28:19 -0300 Subject: [PATCH 5/8] Moved soft_delete_user and clean_user_data_delete to the separate service --- posts/models.py | 8 +++- users/admin.py | 13 ++++-- users/models.py | 89 +--------------------------------------- users/services/common.py | 85 +++++++++++++++++++++++++++++++++++++- users/views.py | 7 ++-- 5 files changed, 105 insertions(+), 97 deletions(-) diff --git a/posts/models.py b/posts/models.py index 589424d2ed..a3e22f5976 100644 --- a/posts/models.py +++ b/posts/models.py @@ -1041,11 +1041,17 @@ class Meta: @classmethod def update_last_forecast_date(cls, post: Post, user: User): + now = timezone.now() cls.objects.update_or_create( user=user, post=post, defaults={ - "last_forecast_date": timezone.now(), + "last_forecast_date": now, + }, + create_defaults={ + "last_forecast_date": now, + "comments_count": post.get_comment_count(), + "viewed_at": now, }, ) diff --git a/users/admin.py b/users/admin.py index 0e63f4ddab..0d63b69862 100644 --- a/users/admin.py +++ b/users/admin.py @@ -11,6 +11,11 @@ from projects.models import ProjectUserPermission from questions.models import Forecast from users.models import User, UserCampaignRegistration, UserSpamActivity +from users.services.common import ( + clean_user_data_delete, + mark_user_as_spam, + soft_delete_user, +) from users.services.spam_detection import ( CONFIDENCE_THRESHOLD, check_profile_data_for_spam, @@ -306,15 +311,15 @@ def bio_length(self, obj): def mark_selected_as_spam(self, request, queryset: QuerySet[User]): for user in queryset: - user.mark_as_spam() + mark_user_as_spam(user) def soft_delete_selected(self, request, queryset: QuerySet[User]): for user in queryset: - user.soft_delete() + soft_delete_user(user) def clean_user_data_deletion(self, request, queryset: QuerySet[User]): for user in queryset: - user.clean_user_data_delete() + clean_user_data_delete(user) clean_user_data_deletion.short_description = ( "One click Personal Data deletion (GDPR compliant)" @@ -329,7 +334,7 @@ def run_profile_spam_detection_on_selected(self, request, queryset: QuerySet[Use ) if is_spam: - user.mark_as_spam() + mark_user_as_spam(user) send_deactivation_email(user.email) def get_fields(self, request, obj=None): diff --git a/users/models.py b/users/models.py index 423bd8013b..9c7c48e43d 100644 --- a/users/models.py +++ b/users/models.py @@ -5,12 +5,10 @@ from django.conf import settings from django.contrib.auth.models import AbstractUser, UserManager from django.contrib.postgres.fields import ArrayField -from django.db import models, transaction +from django.db import models from django.db.models import QuerySet from django.utils import timezone -from social_django.models import UserSocialAuth -from authentication.models import ApiKey from utils.models import TimeStampedModel if TYPE_CHECKING: @@ -206,91 +204,6 @@ def update_username(self, val: str): self.old_usernames.append((self.username, timezone.now().isoformat())) self.username = val - def mark_as_spam(self): - self.is_spam = True - self.soft_delete() - - def soft_delete(self: "User") -> None: - # set to inactive - self.is_active = False - - from posts.models import Post - - # set soft delete comments - comments = self.comment_set.all() - posts: QuerySet[Post] = Post.objects.filter(comments__in=comments).distinct() - comments.update(is_soft_deleted=True) - # update comment counts on said questions - for post in posts: - post.update_comment_count() - - # soft delete user's authored posts - self.posts.update(curation_status=Post.CurationStatus.DELETED) - - self.save() - - @transaction.atomic - def clean_user_data_delete(self: "User") -> None: - # Update User object - self.is_active = False - self.bio = "" - self.old_usernames = [] - self.website = None - self.twitter = None - self.linkedin = None - self.facebook = None - self.github = None - self.good_judgement_open = None - self.kalshi = None - self.manifold = None - self.infer = None - self.hypermind = None - self.occupation = None - self.location = None - self.profile_picture = None - self.unsubscribed_mailing_tags = [] - self.language = None - self.username = "deleted_user-" + str(self.id) - self.first_name = "" - self.last_name = "" - self.email = "" - self.set_password(None) - self.save() - - # Comments - self.comment_set.filter(is_private=True).delete() - # don't touch public comments - - # Token - ApiKey.objects.filter(user=self).delete() - - # Social Auth login credentials - UserSocialAuth.objects.filter(user=self).delete() - - # Posts (Notebooks/Questions) - from posts.models import Post - - def hard_delete_post(post: Post): - if question := post.question: - question.delete() - if group_of_questions := post.group_of_questions: - group_of_questions.delete() - if conditional := post.conditional: - conditional.delete() - if notebook := post.notebook: - notebook.delete() - post.delete() - - posts = self.posts.all() - for post in posts: - # keep if there is at least one non-author comment - if post.comments.exclude(author=self).exists(): - continue - # keep if there is at least one non-author forecast - if post.forecasts.exclude(author=self).exists(): - continue - hard_delete_post(post) - class UserCampaignRegistration(TimeStampedModel): """ diff --git a/users/services/common.py b/users/services/common.py index 48abbfb641..2abf9bc474 100644 --- a/users/services/common.py +++ b/users/services/common.py @@ -5,14 +5,17 @@ from django.contrib.auth.password_validation import validate_password from django.contrib.auth.tokens import PasswordResetTokenGenerator from django.core.signing import TimestampSigner, BadSignature, SignatureExpired -from django.db import IntegrityError +from django.db import transaction, IntegrityError from django.db.models import Case, IntegerField, Q, QuerySet, When from django.shortcuts import get_object_or_404 from django.utils import timezone from rest_framework.exceptions import ValidationError +from social_django.models import UserSocialAuth from authentication.jwt_session import revoke_all_user_tokens +from authentication.models import ApiKey from comments.models import Comment +from comments.services.common import soft_delete_comment from notifications.constants import MailingTags from posts.models import Post from posts.services.common import get_post_permission_for_user @@ -315,3 +318,83 @@ def register_user_to_campaign( ) except IntegrityError: raise ValidationError("User already registered") + + +@transaction.atomic +def soft_delete_user(user: User) -> None: + user.is_active = False + + comments = user.comment_set.filter(is_soft_deleted=False).select_related("on_post") + for comment in comments: + # Soft-delete comments and update counters + soft_delete_comment(comment) + + user.posts.update(curation_status=Post.CurationStatus.DELETED) + + user.save() + + +@transaction.atomic +def mark_user_as_spam(user: User) -> None: + user.is_spam = True + soft_delete_user(user) + + +@transaction.atomic +def clean_user_data_delete(user: User) -> None: + # Update User object + user.is_active = False + user.bio = "" + user.old_usernames = [] + user.website = None + user.twitter = None + user.linkedin = None + user.facebook = None + user.github = None + user.good_judgement_open = None + user.kalshi = None + user.manifold = None + user.infer = None + user.hypermind = None + user.occupation = None + user.location = None + user.profile_picture = None + user.unsubscribed_mailing_tags = [] + user.language = None + user.username = "deleted_user-" + str(user.id) + user.first_name = "" + user.last_name = "" + user.email = "" + user.set_password(None) + user.save() + + # Comments + user.comment_set.filter(is_private=True).delete() + # don't touch public comments + + # Token + ApiKey.objects.filter(user=user).delete() + + # Social Auth login credentials + UserSocialAuth.objects.filter(user=user).delete() + + def hard_delete_post(post: Post): + if question := post.question: + question.delete() + if group_of_questions := post.group_of_questions: + group_of_questions.delete() + if conditional := post.conditional: + conditional.delete() + if notebook := post.notebook: + notebook.delete() + post.delete() + + posts = user.posts.all() + for post in posts: + # keep if there is at least one non-author comment + if post.comments.exclude(author=user).exists(): + continue + # keep if there is at least one non-author forecast + if post.forecasts.exclude(author=user).exists(): + continue + hard_delete_post(post) diff --git a/users/views.py b/users/views.py index ffd52ac8e0..857a0a5769 100644 --- a/users/views.py +++ b/users/views.py @@ -27,6 +27,7 @@ ) from users.services.common import ( get_users, + mark_user_as_spam, user_unsubscribe_tags, send_email_change_confirmation_email, change_email_from_token, @@ -48,7 +49,7 @@ @permission_classes([IsAdminUser]) def mark_as_spam_user_api_view(request, pk): user_to_mark_as_spam: User = get_object_or_404(User, pk=pk) - user_to_mark_as_spam.mark_as_spam() + mark_user_as_spam(user_to_mark_as_spam) return Response(status=status.HTTP_200_OK) @@ -127,7 +128,7 @@ def update_profile_api_view(request: Request) -> Response: is_spam, _ = check_profile_update_for_spam(user, serializer) if is_spam: - user.mark_as_spam() + mark_user_as_spam(user) send_deactivation_email(user.email) return Response( data={ @@ -256,7 +257,7 @@ def update_bot_profile_api_view(request: Request, pk: int): is_spam, _ = check_profile_update_for_spam(bot, serializer) if is_spam: - user.mark_as_spam() + mark_user_as_spam(user) send_deactivation_email(user.email) return Response( From c73017aae252189ffde622dc413c264c08317d59 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Thu, 12 Mar 2026 00:54:32 -0300 Subject: [PATCH 6/8] Added sync_snapshot_comments_count command --- .../commands/sync_snapshot_comments_count.py | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 comments/management/commands/sync_snapshot_comments_count.py diff --git a/comments/management/commands/sync_snapshot_comments_count.py b/comments/management/commands/sync_snapshot_comments_count.py new file mode 100644 index 0000000000..064888d0c1 --- /dev/null +++ b/comments/management/commands/sync_snapshot_comments_count.py @@ -0,0 +1,70 @@ +from django.core.management.base import BaseCommand +from django.db.models import Count, Subquery, OuterRef +from django.db.models.functions import Coalesce + +from comments.models import Comment +from posts.models import PostUserSnapshot + + +BATCH_SIZE = 5000 + + +class Command(BaseCommand): + help = "Sync PostUserSnapshot.comments_count to match actual comment counts at viewed_at" + + def add_arguments(self, parser): + parser.add_argument( + "--dry-run", + action="store_true", + help="Only report mismatches without updating", + ) + + def handle(self, *args, **options): + dry_run = options["dry_run"] + + correct_count = Coalesce( + Subquery( + Comment.objects.filter( + on_post_id=OuterRef("post_id"), + is_private=False, + is_soft_deleted=False, + created_at__lte=OuterRef("viewed_at"), + ) + .order_by() + .values("on_post_id") + .annotate(cnt=Count("id")) + .values("cnt") + ), + 0, + ) + + total = PostUserSnapshot.objects.count() + processed = 0 + updated = 0 + batch = [] + + for pk in PostUserSnapshot.objects.values_list("pk", flat=True).iterator( + chunk_size=BATCH_SIZE + ): + batch.append(pk) + if len(batch) >= BATCH_SIZE: + if not dry_run: + updated += PostUserSnapshot.objects.filter( + pk__in=batch + ).update(comments_count=correct_count) + processed += len(batch) + batch = [] + if processed % 50000 == 0: + self.stdout.write(f" processed {processed}/{total}...") + + if batch: + if not dry_run: + updated += PostUserSnapshot.objects.filter(pk__in=batch).update( + comments_count=correct_count + ) + processed += len(batch) + + self.stdout.write( + f"\nDone. Total: {total}, processed: {processed}, " + f"updated: {updated}{' (dry-run)' if dry_run else ''}" + ) From df7d7f80771b32aec5da18936f8dc71f33c7e9bf Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Thu, 12 Mar 2026 00:54:40 -0300 Subject: [PATCH 7/8] Small fix --- .../management/commands/sync_snapshot_comments_count.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/comments/management/commands/sync_snapshot_comments_count.py b/comments/management/commands/sync_snapshot_comments_count.py index 064888d0c1..84987df0c1 100644 --- a/comments/management/commands/sync_snapshot_comments_count.py +++ b/comments/management/commands/sync_snapshot_comments_count.py @@ -5,7 +5,6 @@ from comments.models import Comment from posts.models import PostUserSnapshot - BATCH_SIZE = 5000 @@ -49,9 +48,9 @@ def handle(self, *args, **options): batch.append(pk) if len(batch) >= BATCH_SIZE: if not dry_run: - updated += PostUserSnapshot.objects.filter( - pk__in=batch - ).update(comments_count=correct_count) + updated += PostUserSnapshot.objects.filter(pk__in=batch).update( + comments_count=correct_count + ) processed += len(batch) batch = [] if processed % 50000 == 0: From 545b4f9101ff485d88b07a3ca91e7f145516807a Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Thu, 12 Mar 2026 11:19:10 -0300 Subject: [PATCH 8/8] Adjusted `clean_user_data_delete` --- users/services/common.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/users/services/common.py b/users/services/common.py index 2abf9bc474..0c63ada301 100644 --- a/users/services/common.py +++ b/users/services/common.py @@ -358,7 +358,9 @@ def clean_user_data_delete(user: User) -> None: user.hypermind = None user.occupation = None user.location = None - user.profile_picture = None + if user.profile_picture: + user.profile_picture.delete(save=False) + user.metadata = None user.unsubscribed_mailing_tags = [] user.language = None user.username = "deleted_user-" + str(user.id)