Skip to content

feat(mural): add moderated photo wall for events - #397

Merged
aalvaropc merged 1 commit into
mainfrom
feature/event-photo-mural
Sep 17, 2026
Merged

aalvaropc merged 1 commit into
mainfrom
feature/event-photo-mural

Conversation

@aalvaropc

@aalvaropc aalvaropc commented Sep 14, 2026

Copy link
Copy Markdown
Member

Adds a moderated photo wall for events.

Attendees scan a QR, upload photos from their phone, and the approved ones appear on a projected wall in near real time. Every photo goes into a queue first — nothing reaches the screen without an organizer approving it.

  • /events/<slug>/subir — phone upload page, no login (anonymous sign-in happens invisibly)
  • /events/<slug>/mural — the projected wall, rotating one tile at a time
  • /events/<slug>/fotos — public gallery of the approved photos after the event
  • /admin/events/mural — moderation queue, approved grid and settings

Uploaders can ask for their own photo to be taken down; a moderator executes it, which deletes the image for good.

Opt-in per event via a mural block in gdg-ica-data. With no event opted in, nothing currently served changes.

@aalvaropc
aalvaropc force-pushed the feature/event-photo-mural branch from 3e9e5d8 to 4b088b8 Compare September 15, 2026 12:50
Attendees scan a QR, upload photos from their phone, and the approved ones
appear on a projected wall in near real time. Every photo queues first —
nothing reaches the screen without an organizer approving it.

Routes, all opt-in per event via a `mural` block in gdg-ica-data and 404
otherwise:
  /events/<slug>/subir   phone upload, no visible login
  /events/<slug>/mural   the projected wall
  /events/<slug>/fotos   public gallery after the event
  /admin/events/mural    moderation queue, approved grid, settings

Six API routes, all mutating; the whole read path is a direct Firestore
subscription gated by the rules. Per-uid quota and per-event total are read
and incremented inside the transaction — the roulette lesson, since the
equivalent of two organizers spinning at once is a double tap or a second
tab. Rejection and takedown delete the Storage object before flipping the
status, and unlike deleteCredentialImages a real delete failure surfaces as
a 500 and leaves the photo alone: a document reading "removed" with the
bytes still served by its token URL is the one outcome this cannot produce.

Moderating is scoped to organizers and admins, not per-event like the rest
of event operations: pulling a photo off a public screen is a content
decision, not a floor task. The read rule spells the status clause out
inside itself, because rules restrict rather than filter — a query without
where("status","==","approved") is denied outright, which is what stops
this collection ever serving the pending queue.

Rate limiting departs from the other public endpoints. /join and
/credentials are one call per person per lifetime, so an IP key fits; the
wall is designed for many repeated calls from one venue NAT, where an
8/hour IP cap would strangle it at the ninth photo of the whole event. The
ceilings that bite are in Firestore, immune to the maxInstances multiplier
that in-memory counters carry. Quota exhaustion answers 409, not 429, with
a machine-readable code so the island stops offering the button; being
blocked returns the same text as being closed, since "you are blocked" just
buys an incognito tab and a fresh anonymous uid.

Quota counts uploads, not approvals, and rejecting does not refund: if it
did, spamming would be free. The recovery lever is blocking — that returns
the abuser's unpublished slots to the event and rejects their pending
photos, which is a deliberate act against an identified abuser rather than
an open door.

The moderation queue subscribes per status rather than fetching a window
and filtering client-side. Ordered ascending with a limit, an unfiltered
query returns the OLDEST photos, so past the 301st photo of any status new
pending work never enters the window and the queue silently stops
receiving. Removal requests raised before approval surface in that queue
too, pinned first and in red, with a confirm on approving them — the
handler always accepted them, but nothing rendered them.

Upload idempotency is keyed on a stable per-file id and the document id is
derived from it, so a retry after a dropped connection is recognised inside
the transaction instead of duplicating the photo and charging quota twice.
Re-sending a photo that was rejected uploads it as a new one rather than
colliding with the tombstone.

Supporting refactor: two duplicate image decoders folded into
services/imageStorage.ts, and prepareImage moved to src/lib/ with
forceReencode. That flag is the EXIF fix — prepareImage's shortcut uploads
the file untouched when it already fits, preserving the GPS coordinates a
phone writes into every photo. The server cannot do this, since nothing of
ours decodes a user image, which keeps the image-parser CVE class out of
scope. The size ceilings, previously kept in step by a comment, now have a
parity test.

1783 tests: 628 root, 943 functions, 212 rules.
@aalvaropc
aalvaropc force-pushed the feature/event-photo-mural branch from 4b088b8 to 3f0da36 Compare September 17, 2026 03:23
@aalvaropc
aalvaropc marked this pull request as ready for review September 17, 2026 03:37
@aalvaropc
aalvaropc merged commit adaf35d into main Sep 17, 2026
1 check passed
@aalvaropc
aalvaropc deleted the feature/event-photo-mural branch September 17, 2026 03:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant