Skip to content

Backend/moderation: UMS coverage for thread comments and replies#8510

Merged
aapeliv merged 10 commits into
developfrom
backend/feature/ums-for-comments-replies
May 16, 2026
Merged

Backend/moderation: UMS coverage for thread comments and replies#8510
aapeliv merged 10 commits into
developfrom
backend/feature/ums-for-comments-replies

Conversation

@aapeliv

@aapeliv aapeliv commented May 10, 2026

Copy link
Copy Markdown
Member

Adds UMS (Unified Moderation System) integration for thread Comment and Reply content — the first of five mod-team-prioritised content types being onboarded onto UMS.

New comments and replies are created with ModerationVisibility.shadowed and an initial_review queue item, matching the existing UMS pattern (host requests, event occurrences, etc.). Authors can see their own shadowed content; everyone else sees nothing until a moderator approves. Notifications generated by a new comment or reply are now gated on the new content's own moderation_state_id, so they fire only after that content is approved (rather than firing immediately and only being deferred by the parent event's state, as previously).

total_num_responses now counts only publicly visible content (visible + unlisted); authors of shadowed content will see one extra reply in the list than the count suggests — accepted to avoid threading a context through event_to_pb / discussion_to_pb / page_to_pb.

Adds Moderator.approve_comment, approve_reply, and a packed-id-aware approve_thread_post helper so existing tests can keep asserting visibility assumptions after the default-shadowed change.

⚠️ Migration: autogen will add the moderation_state_id FK columns on comments and replies, but those columns are NOT NULL and the new enum values can't be added in the same transaction as DML. A hand-written data migration is needed before merging — closely modelled on 0131_implement_ums_for_events.py (enum rename/recreate + per-row ModerationState backfill at visible + log entries).

Testing

  • make format — clean
  • make mypy — clean for changed files (one preexisting error in tests/fixtures/misc.py:31 unrelated to this PR)
  • uv run pytest — 938 tests pass; the only failure is test_db.py::test_migrations, which is the expected schema-vs-migration diff that the autogen migration will resolve

New tests in test_threads.py cover:

  • ModerationState + queue item creation on comment/reply post
  • Shadowed comment/reply visible to author only
  • Admin approve flow making content visible to others
  • Admin hide flow removing content from author and others
  • total_num_responses excludes shadowed content

Existing affected tests updated:

  • test_event_threads, test_event_comment_notification_has_moderation_state, test_event_thread_reply_notification_has_moderation_state (test_events.py)
  • test_discussion_notifications_regression (test_discussions.py)

Backend checklist

  • Added tests for any new code or added a regression test if fixing a bug
  • Run the backend locally and it works
  • Added migrations if there are any database changes, rebased onto develop if necessary for linear migration history — see ⚠️ above; needs hand-written data backfill before merge

For maintainers

  • Maintainers can push commits to my branch
  • Maintainers can merge this PR for me

This PR was created with the Couchers PR skill.

@vercel

vercel Bot commented May 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
couchers Ready Ready Preview May 16, 2026 10:09pm

Request Review

@couchersbot

couchersbot Bot commented May 10, 2026

Copy link
Copy Markdown
Contributor

Claude finished @CouchersBot[bot]'s task in 2m 12s —— View job


Migration Review Complete

Posted full migration review as a separate comment (with the `` marker so future pushes update in place).

  • Fetch PR diff
  • Read migration file 0149_backend_moderation_ums_coverage_for_.py
  • Read related model files (discussions.py, moderation.py)
  • Check schema.sql for current production enum + comments/replies state
  • Compare against the prior 0131_implement_ums_for_events.py pattern
  • Post review

Overall risk: HIGH RISK. As the PR description already flags, the migration is the bare autogen scaffold and will fail against production data on three independent issues: NOT NULL add-column without a backfill (lines 20, 29), missing 'comment' / 'reply' values in the moderationobjecttype enum, and no ModerationState / ModerationLog backfill for existing rows. downgrade() also doesn't clean up the rows the backfill would create. 0131_implement_ums_for_events.py is the right template — details and a step-by-step in the linked comment.

@couchersbot

couchersbot Bot commented May 10, 2026

Copy link
Copy Markdown
Contributor

Migration Review

One of our most common issues is migrations that don't work with existing data in production. This is very hard to catch with CI, so we have this bot to check for potential problems.

Overall Risk: LOW RISK (down from HIGH — chain conflict resolved by rebase + renumber)

Files Reviewed

  • 0151_backend_moderation_ums_coverage_for_.py — extends the moderationobjecttype enum with comment and reply, adds moderation_state_id FK + index to comments and replies, backfills a ModerationState (visible) and ModerationLog create entry per existing row, then enforces NOT NULL.

Findings

The chain is now linear and the migration body itself is well-formed:

  • Migration chain RESOLVED: Previously flagged because two migrations both claimed revision="0149". The PR has been rebased onto develop and the file renamed to 0151_backend_moderation_ums_coverage_for_.py with revision="0151" and down_revision="0150". Chain is now 0148 → 0149_shadowed_at → 0150_photogallery → 0151_this_PR — linear and merge-safe.

  • Enum update OK: Uses the rename/recreate pattern, which is correct since ADD VALUE cannot be used in the same transaction as DML referencing the new value.

  • NOT NULL handling OK: moderation_state_id is added as nullable, backfilled, then alter_column(..., nullable=False) is applied — correct ordering.

  • Backfill OK: One moderation_states row per existing comment/reply at visibility='visible', with matching moderation_log rows (action='create', moderator_user_id = author_user_id, new_visibility='visible'). moderation_log.time is filled by the column's server_default=now().

  • Backfill IDs OK: (MAX(id) WHERE id < 2000000) + ROW_NUMBER(), matching the reserved-range convention used by 0131_implement_ums_for_events.py.

  • Downgrade OK: Deletes moderation_log + moderation_states rows for object_type IN ('comment', 'reply') after dropping the FK/index/column, mirroring 0131's pattern. Enum values intentionally left in place (PostgreSQL can't remove enum values cleanly).

Minor notes (non-blocking):

  • Lock contention LOW: The CTE-based backfill issues INSERT ... SELECT FROM comments and UPDATE comments while holding the ACCESS EXCLUSIVE lock acquired by the earlier add_column in the same transaction. On large comments / replies tables this could be slow and block reads/writes. The same pattern shipped fine in 0131, so this is informational only.

Summary

Migration is safe to merge. The previous chain conflict was the only blocking issue and is fully resolved by the rebase + renumber to 0151. The upgrade/downgrade body is well-formed and follows the established 0131_implement_ums_for_events.py template.

Comment on lines +19 to +29
def upgrade() -> None:
op.add_column("comments", sa.Column("moderation_state_id", sa.BigInteger(), nullable=False))
op.create_index(op.f("ix_comments_moderation_state_id"), "comments", ["moderation_state_id"], unique=False)
op.create_foreign_key(
op.f("fk_comments_moderation_state_id_moderation_states"),
"comments",
"moderation_states",
["moderation_state_id"],
["id"],
)
op.add_column("replies", sa.Column("moderation_state_id", sa.BigInteger(), nullable=False))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH RISK — this migration will fail in production. As the PR description already notes, this is the autogen scaffold and needs hand-written data migration work before merge. Three blocking issues:

  1. nullable=False on add_column with no default / no backfill — PostgreSQL will reject the ALTER TABLE comments ADD COLUMN moderation_state_id BIGINT NOT NULL outright on any non-empty comments (and replies) table, because there is no value to fill into existing rows. CI's fresh DB passes only because the tables are empty.
  2. Missing enum valuesmoderationobjecttype in production is ('host_request', 'group_chat', 'friend_request', 'event_occurrence') (see schema.sql:162). The model adds comment and reply, but this migration does not. Any insert into moderation_states with object_type='comment' or 'reply' will fail. Because ADD VALUE is not transactional and cannot be used in the same transaction as DML that references the new value, the rename-and-recreate pattern from 0131_implement_ums_for_events.py:24-33 should be used.
  3. No backfill of ModerationState / ModerationLog — every existing comment and reply needs a ModerationState row (at visible) and a ModerationLog entry created, then the FK column populated, before the NOT NULL constraint can be applied.

Suggested shape (mirroring 0131_implement_ums_for_events.py):

  1. Rename + recreate moderationobjecttype enum to add comment and reply.
  2. add_column as nullable for both tables.
  3. Insert one moderation_states row per existing comments / replies row at visibility='visible'.
  4. Insert matching moderation_log rows (action='create', moderator_user_id = author_user_id, new_visibility='visible').
  5. UPDATE comments / replies SET moderation_state_id = ms.id FROM moderation_states ms WHERE ms.object_type = ... AND ms.object_id = ....
  6. op.alter_column(..., nullable=False).
  7. Then create the index + FK.

The downgrade() should also clean up the moderation_states / moderation_log rows for object_type IN ('comment', 'reply'), like 0131's downgrade does for event_occurrence.

@couchersbot

couchersbot Bot commented May 10, 2026

Copy link
Copy Markdown
Contributor

Claude finished @aapeliv's task in 3m 54s —— View job


Migration Review Complete

Re-reviewed 0149_backend_moderation_ums_coverage_for_.py after the rewrite. Updated the existing migration-review comment (with the `` marker) in place.

  • Fetch PR diff
  • Read updated migration file 0149_backend_moderation_ums_coverage_for_.py
  • Verify against schema.sql (production enum, moderation_seq, comments/replies columns, unique constraint on (object_type, object_id))
  • Compare against the 0131_implement_ums_for_events.py template
  • Update the migration-review comment

Overall risk: now LOW RISK (down from HIGH). All three previously blocking issues are resolved: enum is updated via the correct rename/recreate pattern; moderation_state_id is added nullable, backfilled, then made NOT NULL; both ModerationState and ModerationLog rows are populated for every existing comment and reply; downgrade cleans up the corresponding moderation rows. Only remaining note is potential lock contention on large comments/replies tables during backfill, which is informational rather than blocking (same pattern shipped fine in 0131).
Branch

return divmod(thread_id, 10)


_PUBLIC_VISIBILITIES = (ModerationVisibility.visible, ModerationVisibility.unlisted)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, always use helpers

author=user_model_to_pb(author_user, session, context),
),
moderation_state_id=occurrence.moderation_state_id,
moderation_state_id=comment.moderation_state_id,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

need a mechanism for parent-> child dependency

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think for now it's fine as for all objects, children are unreachable if parent is not visible

Comment thread app/backend/src/tests/fixtures/misc.py
@couchersbot

couchersbot Bot commented May 10, 2026

Copy link
Copy Markdown
Contributor

Claude finished @aapeliv's task in 2m 21s —— View job


Migration Re-Review Complete

Re-reviewed 0149_backend_moderation_ums_coverage_for_.py after the rewrite to use the < 2000000 backfill ID range. Updated the existing migration-review comment (with the `` marker) in place.

  • Read updated migration 0149_backend_moderation_ums_coverage_for_.py
  • Verified the backfill ID-range change matches 0131_implement_ums_for_events.py exactly
  • Cross-checked with schema.sql (production enum has 4 values, comments/replies lack the FK column — both confirmed)
  • Confirmed moderation_log.time has server_default=now(), so omitting it from the INSERT is fine
  • Updated the migration-review comment

Overall risk: still LOW RISK. The rewrite is exactly what was requested in the review (MAX(id) WHERE id < 2000000) + ROW_NUMBER() per the 0131 template). Migration is well-formed and safe to merge. Only remaining note is potential lock contention on large comments/replies tables during backfill, which is informational rather than blocking (same pattern shipped fine in 0131).
Branch

@tristanlabelle tristanlabelle left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me, modulo your own todo comments.

Comment thread app/backend/src/tests/fixtures/misc.py Outdated
packed_thread_id uses the convention from couchers.servicers.threads.pack_thread_id:
the trailing digit is depth (1=Comment, 2=Reply); the rest is the database id.
"""
database_id, depth = divmod(packed_thread_id, 10)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use unpack_thread_id to avoid duplicating the logic. You could also move the comment about how it works to that function's documentation.

"""
comments = select(func.count()).select_from(Comment).where(Comment.thread_id == database_id)
replies = (
def total_num_responses(session: Session, context: CouchersContext, database_id: int) -> int:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The caveat in your PR description could be useful in a comment here.

total_num_responses now counts only publicly visible content (visible + unlisted); authors of shadowed content will see one extra reply in the list than the count suggests — accepted to avoid threading a context through event_to_pb / discussion_to_pb / page_to_pb.

Comment thread app/backend/src/couchers/servicers/threads.py
@aapeliv
aapeliv force-pushed the backend/feature/ums-for-comments-replies branch from 65242f4 to 476ce19 Compare May 15, 2026 02:56
@couchersbot

couchersbot Bot commented May 15, 2026

Copy link
Copy Markdown
Contributor

Claude finished @aapeliv's task in 3m 50s —— View job


Claude finished migration re-review.


Migration Re-Review Complete

Updated the existing migration-review comment (with the `` marker) in place, and posted an inline comment on the revision identifiers.

  • Re-fetched PR diff
  • Re-read updated migration 0149_backend_moderation_ums_coverage_for_.py
  • Cross-checked develop migrations (c080a8a) for chain integrity
  • Updated the migration-review marker comment
  • Posted inline comment on the revision IDs

Overall risk: HIGH RISK (re-raised from LOW). The migration body itself is still well-formed and safe on its own, but develop has moved on since the last review: it now contains 0149_backend_feature_implement_shadowed_at.py and 0150_add_last_updated_field_to_photogallery.py. After merging this PR, Alembic would see two heads both claiming revision="0149" and down_revision="0148" — a branched migration history, which CLAUDE.md explicitly forbids.

Fix: rebase onto develop and renumber to 0151_… with revision="0151" and down_revision="0150". No body changes should be needed.
Branch

Comment on lines +13 to +14
revision = "0149"
down_revision = "0148"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Migration chain conflict. develop now has 0149_backend_feature_implement_shadowed_at.py (revision="0149", down_revision="0148") and 0150_add_last_updated_field_to_photogallery.py (revision="0150", down_revision="0149"). After merging this PR Alembic would see two heads both claiming revision="0149" from 0148 — branched, non-linear history, which CLAUDE.md forbids.

Rebase onto develop and renumber this migration to 0151_… with revision="0151" and down_revision="0150". The upgrade/downgrade body shouldn't need changes — the two new migrations on develop touch users / lite_users and photo_galleries, which don't interact with comments / replies / moderation_states.

Suggested change
revision = "0149"
down_revision = "0148"
revision = "0151"
down_revision = "0150"

@couchersbot

couchersbot Bot commented May 15, 2026

Copy link
Copy Markdown
Contributor

Claude finished @aapeliv's task in 1m 54s —— View job


Migration Re-Review Complete

Updated the existing migration-review comment (with the `` marker) in place.

  • Fetched latest PR diff
  • Read updated migration 0151_backend_moderation_ums_coverage_for_.py
  • Verified the chain 0148 → 0149_shadowed_at → 0150_photogallery → 0151_this_PR is now linear
  • Cross-checked schema.sql — production enum still has 4 values; comments/replies still lack the FK column
  • Updated the migration-review marker comment

Overall risk: now LOW RISK (down from HIGH). The rebase + renumber fully resolves the chain conflict flagged in the previous review. The migration body itself was already approved: well-formed enum rename/recreate, nullable-add → backfill → NOT NULL ordering, proper ModerationState + ModerationLog backfill with the reserved < 2000000 ID range, downgrade cleans up moderation rows for comment/reply. Only remaining note is informational potential lock contention on large comments/replies tables during backfill — same pattern shipped fine in 0131_implement_ums_for_events.py.

Branch

aapeliv and others added 10 commits May 16, 2026 18:00
New comments and replies start shadowed and require moderator approval;
notifications about them are deferred via the new content's own
moderation_state_id. total_num_responses now counts only publicly
visible content. Adds Moderator.approve_comment / approve_reply test
helpers and updates affected tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The autogen migration only added the moderation_state_id FK columns. This
extends it with the enum rename/recreate trick (Postgres can't ADD VALUE
in the same transaction as DML using it) and a per-row backfill that
creates a 'visible' ModerationState + 'create' log entry for every
existing comment and reply, so the new NOT NULL FK columns can be
populated and historical content stays visible.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…n 0149

- threads.total_num_responses/thread_to_pb now use where_moderated_content_visible
  and take a CouchersContext; callers in discussions/events/pages updated.
- Migration 0149 backfill switched from nextval('moderation_seq') to the
  (MAX(id) WHERE id < 2000000) + ROW_NUMBER() pattern from 0131.
- thread_to_pb / total_num_responses now take viewing_user_id instead of
  the full CouchersContext; callers pass context.user_id.
- approve_thread_post uses unpack_thread_id rather than duplicating the
  divmod-by-10 convention; moved the depth convention to pack_thread_id's
  docstring.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@aapeliv
aapeliv force-pushed the backend/feature/ums-for-comments-replies branch from bc4cc78 to 4180a6f Compare May 16, 2026 22:06
@couchersbot

couchersbot Bot commented May 16, 2026

Copy link
Copy Markdown
Contributor

Claude finished @aapeliv's task in 1m 23s —— View job


Migration Review

One of our most common issues is migrations that don't work with existing data in production. This is very hard to catch with CI, so we have this bot to check for potential problems.

Overall Risk: LOW RISK

Files Reviewed

  • 0152_backend_moderation_ums_coverage_for_.py — extends the moderationobjecttype enum with comment and reply, adds a moderation_state_id FK + index to comments and replies, backfills one ModerationState (visible) and one ModerationLog create entry per existing row, then enforces NOT NULL.

Findings

The migration body is well-formed and the chain is linear:

  • Migration chain OK: Previously flagged for a chain conflict (file was 0151 while develop already had a 0151). Now renamed to 0152_… with revision="0152" / down_revision="0151". develop's head is 0151_add_machine_readable_data_payload_to_.py (revision="0151", down_revision="0150"), so the chain 0150 → 0151 → 0152 is linear and merge-safe.
  • Enum update OK: Uses the rename/recreate pattern (RENAME TO …_oldCREATE TYPE with all 6 values → ALTER COLUMN … TYPE … USINGDROP TYPE). Correct, since ADD VALUE can't run in the same transaction as DML referencing the new value. schema.sql:162 confirms production currently has only the 4 base values.
  • NOT NULL handling OK: moderation_state_id is added nullable, backfilled, then alter_column(..., nullable=False) — correct ordering.
  • Backfill OK: One moderation_states row per existing comments/replies row at visibility='visible', plus matching moderation_log rows (action='create', moderator_user_id = author_user_id, new_visibility='visible', reason provided). moderation_log.time is filled by the column's server_default=now(), so omitting it is fine. Backfill IDs use the reserved < 2000000 range (MAX(id) … + ROW_NUMBER()), matching the 0131_implement_ums_for_events.py convention.
  • Downgrade OK: Drops FK/index/column for both tables, then deletes moderation_log + moderation_states rows for object_type IN ('comment','reply'). Enum values intentionally left in place (PostgreSQL can't cleanly remove enum values).

Minor notes (non-blocking):

  • Downgrade vs. live data LOW: downgrade() deletes moderation_log and moderation_states rows for comment/reply, but not any moderation_queue rows. After this PR ships, new comments/replies are created shadowed with an initial_review queue item; if a downgrade is ever run while such queue rows exist, the DELETE FROM moderation_states would hit an FK violation from moderation_queue.moderation_state_id. This matches 0131's downgrade exactly and downgrades on production are rare — informational only.
  • Lock contention LOW: The backfill issues INSERT … SELECT FROM comments / UPDATE comments (and the same for replies) while holding the ACCESS EXCLUSIVE lock from the earlier add_column in the same transaction. On large tables this could block reads/writes briefly. Same pattern shipped fine in 0131 — informational only.

Summary

Migration is safe to merge. The previously blocking chain conflict is fully resolved by the rebase + renumber to 0152, and the upgrade/downgrade body follows the established 0131_implement_ums_for_events.py template.
Branch

@aapeliv
aapeliv merged commit c01a93a into develop May 16, 2026
5 of 6 checks passed
@aapeliv
aapeliv deleted the backend/feature/ums-for-comments-replies branch May 16, 2026 22:15
@github-actions github-actions Bot added the release notes: pending Add to stuff that should be included in release notes label May 16, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📝 Release Notes

This PR should be included in the release notes.

Suggested release note:

Added moderation review for thread comments and replies so new posts only become visible and send notifications after approval
🤖 Bot Debug Information

Model: couchers.openai.gpt-5.4
Decision: include
Reasoning: This PR adds important moderation coverage for comments and replies so new thread posts are hidden until approved and related notifications are delayed until approval, which is a meaningful safety and moderation change that can affect user-visible behavior.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release notes: pending Add to stuff that should be included in release notes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants