Skip to content

Commit a60165b

Browse files
Legacy-conversion review fixes: portal attribution, preserved gate, clone flag
pr-review findings on the drop-legacy migration: - Converted rows are attributed to their primary tag slug (one form config per distinct slug; 'legacy' only for untagged comments): gallery membership keys on portal_id, so the catch-all portal emptied every legacy tag gallery (PlanGallery via documents/list?tags=), and moderation authority landed on a portal no team administers. Tags now follow the new [portal, *others] convention. - The old public gate is preserved exactly: score-flagged rows without an explicit APPROVED convert as hidden AND nsfw — the old gate excluded them from public view entirely, and blurred-but-fetchable would have retroactively published testimony no human ever approved. - New submissions.map_is_clone column (backfilled true for pre-existing submitted rows, false for conversions/drafts): takedown demotes draft_status ONLY for clone-backed entries — converted rows reference LIVE user documents, and hiding one used to scratch a real person's working map. Auto-collect modes inherit the correct behavior for free. - Converted content values are BTRIMmed (exact-match place/state/zip filters). - stress_test/seed.py no longer deletes from the dropped tables (its cleanup path was dead on arrival); unused or_ import removed; agent guides repointed from app/comments to app/submissions; stale review_tags wording in TokenScope corrected. - Verified on a seeded probe DB: per-portal attribution, tag order, hidden/nsfw matrix, id offsets, and the map_is_clone backfill all checked row-by-row; fresh-DB upgrade head green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 8a367b9 commit a60165b

9 files changed

Lines changed: 120 additions & 57 deletions

File tree

.agents/WAGTAIL-CUTOVER-FOLLOWUPS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ the Districtr Management System requirements:
2020
GroupApprovalTask(admin) on the page tree and Gallery snippets — partners
2121
submit for moderation; publish permissions removed from non-admins.
2222
- **Locales** pre-provisioned (content.0006).
23-
- Backend: `has_document` filter on `GET /api/comments/admin/list`.
23+
- Backend: `has_document` filter on `GET /api/comments/admin/list (module dropped later in the stack; submissions replaced it)`.
2424

2525
**⏳ DEFERRED — Partner report generation** (decided 2026-08-05: spec later).
2626
The feature list's "Generate a report" has no implementation anywhere. Open

.agents/skills/project/learn-backend/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ Backend conventions for FastAPI + SQLModel/SQLAlchemy services, request dependen
2424
- `backend/app/core/models.py`
2525
- `backend/app/utils.py`
2626
- `backend/app/assignments/assignments.py`
27-
- `backend/app/comments/*`
27+
- `backend/app/submissions/* (public testimony; app/district_notes.py for zone notes)`
2828
- `backend/app/cms/*`
2929
- `backend/app/save_share/*`
3030
- `backend/app/exports/*`

.agents/skills/project/learn-cms-moderation/SKILL.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ CMS authoring/review flows, TipTap extension expectations, and moderation/review
2020
- `app/src/app/store/cmsFormStore.ts`
2121
- `backend/app/cms/main.py`
2222
- `backend/app/cms/models.py`
23-
- `backend/app/comments/main.py`
24-
- `backend/app/comments/models.py`
25-
- `backend/app/comments/moderation.py`
23+
- `backend/app/submissions/main.py`
24+
- `backend/app/submissions/models.py`
25+
- `backend/app/submissions/moderation.py`
2626

2727
## Hard Invariants
2828
- CMS content authoring and publishing must preserve role/scope boundaries.
@@ -33,7 +33,7 @@ CMS authoring/review flows, TipTap extension expectations, and moderation/review
3333

3434
## Preferred Patterns
3535
- Reuse existing API handlers/store actions for CMS CRUD + publish flows.
36-
- Keep moderation and review behavior centralized in backend comments module.
36+
- Keep moderation behavior centralized in the backend submissions module.
3737
- Keep admin filtering behavior aligned with backend query params.
3838
- Add tests when changing moderation/review behavior.
3939

@@ -51,7 +51,7 @@ CMS authoring/review flows, TipTap extension expectations, and moderation/review
5151
5. Confirm admin review filters still map to backend behavior.
5252

5353
## Validation Commands
54-
- `cd backend && pytest -v tests/test_comments.py tests/test_cms.py`
54+
- `cd backend && pytest -v tests/test_submissions.py tests/test_cms.py`
5555
- `cd app && bun run build`
5656

5757
## See Also

backend/app/alembic/versions/d8f1b52c96e3_drop_legacy_comment_tables.py

Lines changed: 93 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,33 @@
66
copied into district_notes by b3d9f47a25c1.
77
88
Form comments are real data (dev has live testimony, e.g. the TN workshop),
9-
so before dropping, every legacy form comment becomes a submission under a
10-
catch-all 'legacy' form config:
9+
so before dropping, every legacy form comment becomes a submission:
1110
12-
- tags are preserved verbatim, so tag-filtered galleries keep showing them;
13-
only the per-portal admin queue groups them under 'legacy' (portal_id is
14-
ON UPDATE CASCADE — re-attribute later with a plain UPDATE if wanted).
11+
- PORTAL ATTRIBUTION: gallery membership and moderation authority both key
12+
on portal_id, so each comment is attributed to its primary tag slug (the
13+
earliest-created tag == the portal page's slug in the legacy flow); a
14+
form config row is created per distinct slug. Untagged comments fall back
15+
to a catch-all 'legacy' config. All created configs get admin_teams='{}'
16+
(no team information exists here): they are moderatable only by
17+
review:review-all holders until an admin grants teams in the CMS — and
18+
they DO accept new public submissions (the portals are real pages), which
19+
is intended.
20+
- tags follow the new-submission convention: [portal_id, *other slugs].
1521
- map attachments keep their LIVE document reference (legacy behavior);
16-
clone-at-submission applies only to new submissions.
17-
- moderation maps to the new bits preserving what the old public gate
18-
showed: hidden = anything REJECTED (comment, commenter, or a tag);
19-
nsfw = any moderation score >= 0.2 without an APPROVED override.
22+
map_is_clone=false marks them so takedown never demotes a real user's
23+
working map (this migration also adds that column).
24+
- moderation preserves the OLD public gate exactly: hidden = anything
25+
REJECTED (comment, commenter, or a tag) OR anything score-flagged
26+
(>= 0.2) without an explicit APPROVED override — the old gate excluded
27+
score-flagged rows from public view entirely, and converting them to
28+
merely-blurred would retroactively publish testimony no human ever
29+
approved. nsfw carries the score flag so an admin who un-hides one still
30+
gets the blur. Reviewers can unhide false positives from the queue.
2031
- submission ids are the legacy comment ids offset past MAX(submissions.id),
2132
so a deploy where pr10..13 already collected new submissions can't collide.
33+
- negative-zone rows (skipped by b3d9f47a25c1 as never-renderable) are in
34+
neither district_notes nor submissions and are dropped with the tables —
35+
a deliberate one-way loss.
2236
2337
Downgrade recreates the tables (final shape as of 0db008690d60 + da39a3ee5e6b)
2438
empty — it does NOT reverse the conversion (converted rows simply remain in
@@ -90,18 +104,30 @@ def _timestamps():
90104

91105

92106
def _convert_legacy_form_comments(bind) -> None:
93-
# Catch-all portal config, only when there is anything to migrate.
107+
# One config per portal the legacy data references: the comment's primary
108+
# (earliest) tag slug IS the portal page slug in the legacy flow, and
109+
# gallery membership + moderation authority both key on portal_id — a
110+
# catch-all portal would empty every legacy tag gallery. Untagged
111+
# comments fall back to 'legacy'.
94112
bind.execute(
95113
sa.text(
96114
f"""
97115
INSERT INTO comments.form_configs
98116
(portal_id, name, fields, required_fields,
99117
require_email_confirm, admin_teams)
100-
SELECT 'legacy', 'Legacy submissions', {_ALL_FIELDS},
101-
'{{}}', false, '{{}}'
102-
WHERE EXISTS (
103-
SELECT 1 FROM comments.comment c {_FORM_COMMENT_FILTER}
104-
)
118+
SELECT DISTINCT
119+
COALESCE(t.primary_slug, 'legacy'),
120+
COALESCE(t.primary_slug, 'Legacy submissions'),
121+
{_ALL_FIELDS}, '{{}}'::varchar(64)[], false,
122+
'{{}}'::varchar(255)[]
123+
FROM comments.comment c
124+
LEFT JOIN LATERAL (
125+
SELECT (array_agg(tg.slug ORDER BY tg.id))[1] AS primary_slug
126+
FROM comments.comment_tag ct
127+
JOIN comments.tag tg ON tg.id = ct.tag_id
128+
WHERE ct.comment_id = c.id
129+
) t ON true
130+
{_FORM_COMMENT_FILTER}
105131
ON CONFLICT (portal_id) DO NOTHING
106132
"""
107133
)
@@ -122,7 +148,8 @@ def _convert_legacy_form_comments(bind) -> None:
122148
c.moderation_score AS c_score,
123149
cm.review_status::text AS m_status,
124150
cm.moderation_score AS m_score,
125-
t.slugs, t.rejected_tag, t.score_flagged_tag, t.max_tag_score
151+
t.slugs, t.rejected_tag, t.score_flagged_tag, t.max_tag_score,
152+
COALESCE((t.slugs)[1], 'legacy') AS portal
126153
FROM comments.comment c
127154
LEFT JOIN comments.document_comment dc ON dc.comment_id = c.id
128155
LEFT JOIN comments.commenter cm ON cm.id = c.commenter_id
@@ -141,35 +168,46 @@ def _convert_legacy_form_comments(bind) -> None:
141168
) t ON true
142169
LEFT JOIN document.document d ON d.document_id = dc.document_id
143170
WHERE dc.zone IS NULL
171+
),
172+
score_flags AS (
173+
SELECT e.*,
174+
COALESCE(
175+
(e.c_score >= 0.2 AND e.c_status IS DISTINCT FROM 'APPROVED')
176+
OR (e.m_score >= 0.2 AND e.m_status IS DISTINCT FROM 'APPROVED')
177+
OR e.score_flagged_tag,
178+
false
179+
) AS score_flagged,
180+
COALESCE(
181+
e.c_status = 'REJECTED'
182+
OR e.m_status = 'REJECTED'
183+
OR e.rejected_tag,
184+
false
185+
) AS was_rejected
186+
FROM enriched e
144187
)
145188
INSERT INTO comments.submissions
146189
(id, portal_id, map_public_id, tags, status, submitted_at,
147-
nsfw, hidden, flagged, moderation_score,
190+
nsfw, hidden, flagged, moderation_score, map_is_clone,
148191
created_at, updated_at)
149192
SELECT
150193
e.id + :offset,
151-
'legacy',
194+
e.portal,
152195
e.public_id,
153-
COALESCE(e.slugs, '{}'),
196+
ARRAY[e.portal]::varchar(255)[]
197+
|| array_remove(COALESCE(e.slugs, '{}'), e.portal),
154198
'submitted',
155199
e.created_at,
156-
COALESCE(
157-
(e.c_score >= 0.2 AND e.c_status IS DISTINCT FROM 'APPROVED')
158-
OR (e.m_score >= 0.2 AND e.m_status IS DISTINCT FROM 'APPROVED')
159-
OR e.score_flagged_tag,
160-
false
161-
),
162-
COALESCE(
163-
e.c_status = 'REJECTED'
164-
OR e.m_status = 'REJECTED'
165-
OR e.rejected_tag,
166-
false
167-
),
200+
e.score_flagged,
201+
-- The OLD public gate fully excluded score-flagged rows that
202+
-- no human APPROVED; blurred-but-fetchable would widen their
203+
-- exposure retroactively, so they convert as hidden too.
204+
e.was_rejected OR e.score_flagged,
168205
e.review_flagged,
169206
GREATEST(e.c_score, e.m_score, e.max_tag_score),
207+
false,
170208
e.created_at,
171209
e.updated_at
172-
FROM enriched e
210+
FROM score_flags e
173211
"""
174212
),
175213
{"offset": offset},
@@ -179,7 +217,7 @@ def _convert_legacy_form_comments(bind) -> None:
179217
sa.text(
180218
f"""
181219
INSERT INTO comments.submissions_content (submission_id, field, value)
182-
SELECT c.id + :offset, f.field, LEFT(f.value, 5000)
220+
SELECT c.id + :offset, f.field, LEFT(BTRIM(f.value), 5000)
183221
FROM comments.comment c
184222
LEFT JOIN comments.commenter cm ON cm.id = c.commenter_id
185223
CROSS JOIN LATERAL (VALUES
@@ -214,6 +252,27 @@ def _convert_legacy_form_comments(bind) -> None:
214252

215253

216254
def upgrade() -> None:
255+
# Whether the submission's map is a submission-owned frozen clone (new
256+
# prompt/form submissions) or a live reference to the author's working
257+
# document (drafts, converted legacy rows, later auto-collect modes).
258+
# Takedown may only demote the draft_status of CLONES.
259+
op.add_column(
260+
"submissions",
261+
sa.Column(
262+
"map_is_clone",
263+
sa.Boolean(),
264+
nullable=False,
265+
server_default="false",
266+
),
267+
schema="comments",
268+
)
269+
# Every pre-existing submitted row was created by clone-at-submission.
270+
op.execute(
271+
sa.text(
272+
"UPDATE comments.submissions SET map_is_clone = true "
273+
"WHERE status = 'submitted'"
274+
)
275+
)
217276
_convert_legacy_form_comments(op.get_bind())
218277
op.drop_table("document_comment", schema="comments")
219278
op.drop_table("comment_tag", schema="comments")
@@ -227,6 +286,7 @@ def upgrade() -> None:
227286

228287

229288
def downgrade() -> None:
289+
op.drop_column("submissions", "map_is_clone", schema="comments")
230290
# Recreates the schema only; rows are unrecoverable without a backup.
231291
op.execute(
232292
sa.text(

backend/app/core/security.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ class TokenScope:
2929
delete_all_content = "delete:delete-all"
3030

3131
review_content = "create:content_review"
32-
# Explicit bypass of per-reviewer tag scoping (the `review_tags` claim).
32+
# Explicit bypass of per-reviewer scoping (the `teams` claim; formerly
33+
# the review_tags claim).
3334
# Deliberately separate from read:read-all: the *-all read/update/delete
3435
# scopes govern access across CMS authorship boundaries, while this one
3536
# widens moderation reach.

backend/app/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
DataError,
2020
OperationalError,
2121
)
22-
from sqlalchemy import text, or_
22+
from sqlalchemy import text
2323
from sqlalchemy.types import Integer
2424
from sqlmodel import Session, String, select, true, update, col, literal
2525
from sqlalchemy.sql import and_, exists

backend/app/submissions/main.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,7 @@ async def create_submission(
289289
tags=_normalized_tags(data.tags, config.portal_id),
290290
status=SubmissionStatus.submitted,
291291
submitted_at=datetime.now(timezone.utc),
292+
map_is_clone=map_public_id is not None,
292293
)
293294
session.add(submission)
294295
session.flush()
@@ -345,6 +346,7 @@ async def finalize_submission(
345346
clone = clone_document_for_submission(session, source)
346347

347348
submission.map_public_id = clone.public_id
349+
submission.map_is_clone = True
348350
submission.tags = _normalized_tags(data.tags, config.portal_id)
349351
submission.status = SubmissionStatus.submitted
350352
submission.submitted_at = datetime.now(timezone.utc)
@@ -598,17 +600,19 @@ async def set_submission_hidden(
598600
):
599601
"""Hard takedown/restore for spam and abuse. Resolves the flag report.
600602
601-
For submitted entries the map is a frozen clone that exists ONLY as a
602-
gallery entry, so takedown also demotes its draft_status: without that,
603+
For submitted CLONE-backed entries the map exists ONLY as a gallery
604+
entry, so takedown also demotes its draft_status: without that,
603605
the abusive map stays fetchable at its enumerable public_id even while
604606
hidden from every listing. Restore puts it back to ready_to_share (the
605-
status every clone has by construction).
607+
status every clone has by construction). Live-referenced maps (converted
608+
legacy rows, drafts, auto-collect modes) are NEVER demoted — that would
609+
mutate a real user's working document.
606610
"""
607611
submission = _get_submission_for_admin(submission_pk, auth_result, session)
608612
submission.hidden = body.hidden
609613
submission.flagged = False
610614
session.add(submission)
611-
if submission.status == SubmissionStatus.submitted:
615+
if submission.status == SubmissionStatus.submitted and submission.map_is_clone:
612616
session.execute(
613617
text(
614618
"""

backend/app/submissions/models.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,15 @@ class Submission(TimeStampMixin, SQLModel, table=True):
173173
Boolean, nullable=False, default=False, server_default="false"
174174
),
175175
)
176+
# True when the map is a submission-owned frozen clone; false for live
177+
# references (drafts, converted legacy rows, auto-collect modes).
178+
# Takedown may only demote the draft_status of clones.
179+
map_is_clone: bool = Field(
180+
default=False,
181+
sa_column=Column(
182+
Boolean, nullable=False, default=False, server_default="false"
183+
),
184+
)
176185
flagged: bool = Field(
177186
default=False,
178187
sa_column=Column(

backend/stress_test/seed.py

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -164,17 +164,6 @@ def delete_documents(session: Session, document_ids: list[str]) -> int:
164164
conn = session.connection()
165165
params = {"ids": chunk}
166166
id_filter = "document_id = ANY(CAST(:ids AS uuid[]))"
167-
conn.execute(
168-
text(
169-
f"""WITH removed AS (
170-
DELETE FROM comments.document_comment WHERE {id_filter}
171-
RETURNING comment_id
172-
)
173-
DELETE FROM comments.comment
174-
WHERE id IN (SELECT comment_id FROM removed)"""
175-
),
176-
params,
177-
)
178167
for table in (
179168
"document.district_unions",
180169
"document.map_document_user_session",

0 commit comments

Comments
 (0)