fix(provenance): scope attachments to originating post + scroll-cap the list
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m19s

The Attachments section aggregated PostAttachment rows across EVERY post an
image was pHash-linked to. When one of those was a 'High Resolution Files'
mega-bundle (dozens of unrelated archives), the list ballooned past the
viewport and overwhelmed the modal's right rail.

- for_image() now scopes attachments to ImageRecord.primary_post_id (the post
  the file was actually captured from), falling back to all linked posts only
  when primary_post_id is unset (older rows / filesystem imports).
- ProvenancePanel wraps the list in a max-height scroll container with a count
  in the heading, mirroring the cards' independent-scroll treatment.

Note: FC stores archives as opaque blobs and never records which archive an
extracted image came from, so attachments can't yet be scoped tighter than the
post. Capturing image->archive containment is tracked as separate work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 21:27:36 -04:00
parent 311fe0ee9c
commit 068def2f24
3 changed files with 99 additions and 9 deletions
+61
View File
@@ -134,6 +134,67 @@ async def test_for_image_null_post_fields_serialize_null(db):
assert e["post"]["attachment_count"] is None
@pytest.mark.asyncio
async def test_for_image_attachments_scoped_to_primary_post(db):
# Image linked to TWO posts; only the primary (originating) post's
# attachments should surface — not the other post's mega-bundle.
rec = await _seed_image(db)
a1, s1, primary = await _seed_post(db, artist_name="P1", slug="p1",
platform="patreon", ext_id="100")
a2, s2, bundle = await _seed_post(db, artist_name="P2", slug="p2",
platform="patreon", ext_id="200")
rec.primary_post_id = primary.id
db.add(ImageProvenance(image_record_id=rec.id, post_id=primary.id,
source_id=s1.id))
db.add(ImageProvenance(image_record_id=rec.id, post_id=bundle.id,
source_id=s2.id))
db.add(PostAttachment(
post_id=primary.id, artist_id=a1.id, sha256="p" + "0" * 63,
path="/images/attachments/p00/keep.zip", original_filename="keep.zip",
ext=".zip", mime="application/zip", size_bytes=9,
))
db.add(PostAttachment(
post_id=bundle.id, artist_id=a2.id, sha256="b" + "0" * 63,
path="/images/attachments/b00/drop.rar", original_filename="drop.rar",
ext=".rar", mime="application/x-rar", size_bytes=9,
))
await db.flush()
payload = await ProvenanceService(db).for_image(rec.id)
names = [a["original_filename"] for a in payload["attachments"]]
assert names == ["keep.zip"]
@pytest.mark.asyncio
async def test_for_image_attachments_fallback_to_all_posts_when_no_primary(db):
# No primary_post_id (older rows / filesystem imports) → preserve the
# aggregate-across-linked-posts behavior so attachments aren't lost.
rec = await _seed_image(db)
a1, s1, p1 = await _seed_post(db, artist_name="F1", slug="f1",
platform="patreon", ext_id="300")
a2, s2, p2 = await _seed_post(db, artist_name="F2", slug="f2",
platform="patreon", ext_id="400")
db.add(ImageProvenance(image_record_id=rec.id, post_id=p1.id,
source_id=s1.id))
db.add(ImageProvenance(image_record_id=rec.id, post_id=p2.id,
source_id=s2.id))
db.add(PostAttachment(
post_id=p1.id, artist_id=a1.id, sha256="c" + "0" * 63,
path="/images/attachments/c00/one.zip", original_filename="one.zip",
ext=".zip", mime="application/zip", size_bytes=9,
))
db.add(PostAttachment(
post_id=p2.id, artist_id=a2.id, sha256="d" + "0" * 63,
path="/images/attachments/d00/two.zip", original_filename="two.zip",
ext=".zip", mime="application/zip", size_bytes=9,
))
await db.flush()
payload = await ProvenanceService(db).for_image(rec.id)
names = sorted(a["original_filename"] for a in payload["attachments"])
assert names == ["one.zip", "two.zip"]
@pytest.mark.asyncio
async def test_for_post_missing_returns_none(db):
svc = ProvenanceService(db)