feat(mural): add moderated photo wall for events - #397
Merged
Merged
Conversation
aalvaropc
force-pushed
the
feature/event-photo-mural
branch
from
September 15, 2026 12:50
3e9e5d8 to
4b088b8
Compare
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
force-pushed
the
feature/event-photo-mural
branch
from
September 17, 2026 03:23
4b088b8 to
3f0da36
Compare
aalvaropc
marked this pull request as ready for review
September 17, 2026 03:37
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 settingsUploaders 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
muralblock ingdg-ica-data. With no event opted in, nothing currently served changes.