Summary
A user-facing form that proposes a real-world place for the catalog: a photo, a
name, the device's coordinates, an optional collection and optional notes. It
posts one multipart request and lands a PatchSubmission row in the admin
review queue. Nothing about it reaches the catalog until an admin promotes it.
It is a proposal surface, not an authoring one — the submitter picks no type, sets no geofence, and never learns what happened to their submission.
Status
Built, reachable, and FLAGGED OFF. submit_patch has
defaultEnabled: false in both registries — mobile
mobile/src/config/feature-flags.ts:33 and backend
backend/src/admin/feature-flag-definitions.ts:40 — and the backend's own
description says why: "Off until the submission review pipeline is ready — the
admin Submissions queue is unaffected."
With the flag off, the drawer row is hidden and the screen itself redirects home
(mobile/app/submit-patch.tsx:354). The admin queue and
the POST /patch-submissions endpoint are not gated by the flag; only the
mobile entry points are.
User-facing surfaces
| Surface | Where |
|---|---|
/submit-patch |
mobile/app/submit-patch.tsx, deep link scout://submit-patch |
| Drawer row | mobile/src/components/navigation/drawerSections.ts:362, gated on the flag |
Gone. No FAB renders on Home — mobile/app/(drawer)/index.tsx has no FAB and nothing outside the drawer reads submitPatchEnabled. The drawer row is the only tap target |
|
| Signed-out bounce | /auth?returnTo=/submit-patch — the endpoint requires a session |
| Admin queue | /admin → Submissions |
It is NOT reached from Settings. Settings → Help & feedback → "Send feedback
or suggest patches" goes to mobile/app/(drawer)/feedback.tsx?type=suggestion,
a different screen that files a Feedback row, not a PatchSubmission.
How it works
- The screen gates on the flag before rendering anything
(
app/submit-patch.tsx:229):flags-loadingwhile the server answer is in flight,disabled→<Redirect href="/" />,readyotherwise. - The user attaches a photo, types a name, and the screen reads coordinates off the Zustand store (the current device fix — the screen has no location UI of its own). An optional collection comes from the synced catalog; an optional free-text collection name is offered when none fits.
canSubmitrequires a photo, a trimmed name of at leastNAME_MIN_CHARS(3), a fix, and no submission already in flight (submitPatchFormState,app/submit-patch.tsx:78).- On submit the photo is compressed, packed into a
FormDatawithname,latitude,longitude, and any ofcollectionId/suggestedCollectionName/userNotes, and posted toPOST /patch-submissions(mobile/src/api/submissions.ts). - Both outcomes are a native
Alert— "Submission received" or "Submission failed". Nothing on the screen changes. - An admin reviews the queue and promotes a submission into a real patch, or dismisses it.
Data model
PatchSubmission (backend/prisma/schema.prisma:290):
| Field | Note |
|---|---|
userId |
who proposed it |
name |
VarChar(100) — the mobile field's maxLength matches |
latitude / longitude |
Decimal(10,7), the device fix at submit time |
photoUrl, photoBlurhash |
the compressed upload on S3 |
collectionId |
optional, SetNull — deleting a collection orphans the submission, never deletes it |
suggestedCollectionName |
VarChar(60), free text when no collection fits |
userNotes |
VarChar(500) — matches NOTES_MAX_CHARS |
promotedAt / dismissedAt |
both null = still queued |
API surface
| Endpoint | Auth | Note |
|---|---|---|
POST /patch-submissions |
JwtAuthGuard — a session is required |
multipart; the only write the app makes |
GET /api/admin/patch-submissions |
AdminGuard |
the queue |
GET /api/admin/patch-submissions/:id |
AdminGuard |
one submission |
POST /api/admin/patch-submissions/promote |
AdminGuard + ContentWriteGuard |
creates the patch |
ContentWriteGuard is the content-editing gate
(backend/src/admin/content-write.guard.ts:18), so promotion only works where
CONTENT_EDITING_ENABLED=true — i.e. locally. On prod it throws
CONTENT_READ_ONLY. A promoted patch is authored locally and then published,
like every other piece of content. See backend/docs/CONTENT_PUBLISH.md.
Key files
mobile/app/submit-patch.tsx— the screen. Split intoSubmitPatchScreenViewModel,SubmitPatchScreenViewModelImpl:174(every hook) and a pureSubmitPatchScreenLayout:310, per the house pattern (screen-mocks.md). ExportssubmitPatchFormState:78,NAME_MIN_CHARS:43,NOTES_MAX_CHARS:46.mobile/src/api/submissions.ts— the whole client. One method,create.mobile/src/dev/mocks/submit-patch.tsx— 11 gallery states,scout://dev-screen-mock/submit-patch.backend/src/patch-submissions/patch-submissions.controller.ts— the user endpoint.backend/src/patch-submissions/patch-submissions.admin.controller.ts— the queue and promote.
Configuration and flags
submit_patch—defaultEnabled: false, in both the mobile and backend registries. A flag must be registered in both or it cannot be turned on; see the feature-flag notes indocs/features/admin-and-content-ops.md.CONTENT_EDITING_ENABLED— required for promotion. Local only, fail-closed.
Edge cases and known limits
- An in-flight submission gives no on-screen feedback at all.
canSubmitis false wheneverisSubmitting, so the gold CTA branch — the one holding the spinner — is unreachable, and the grey branch has no spinner. Tapping Submit changes nothing until the Alert fires. (feedback.tsxhas the same bug, one degree milder: its grey branch does render a spinner.) - The flag gate is a bare
ActivityIndicator, which CLAUDE.md's house rule forbids for a full-page wait. useContent()'sisLoadinganderrorare dropped — the screen destructures{ collections }only. While the catalog is in flight, and permanently if it fails, the collection picker is a search box over an empty list, indistinguishable from a catalog with no collections. Tracked with the wider sweep.- No duplicate or nearby-patch detection. A submission naming a place Scout already has passes every gate and lands in the queue.
- Failure classification sniffs a message string —
message.includes('Session expired')picks the "Signed out" alert. - A denied location permission is byte-identical to "no fix yet." The screen has no location UI and cannot tell them apart.
- Component-local state is invisible to the view model:
PhotoField.permissionDenied,CollectionPickerField's query/selection/ dropdown, andNotesField's expanded toggle all live inside their components, so no mock state can stage them.
What this feature does NOT do
- It does NOT ship.
submit_patchis off by default in both registries. No ordinary user has ever seen this screen. - It does NOT create a patch. It creates a
PatchSubmission. A human promotes it, locally, and publishes. - It does NOT tell the submitter anything, ever. The mobile client has one
method —
create. There is no read endpoint, no list, no status. A user cannot see whether a submission was promoted, dismissed, or is still queued, and cannot edit or withdraw one. - It does NOT let a user classify the place. No type, no category — the
optional collection is the only classifier, and
collection_type, geofence, artwork and*Dataare all set by an admin at promotion. - It does NOT set a geofence. A submission carries a point, nothing more.
- It does NOT work offline or without an account — the endpoint requires a
session, and the screen bounces through
/auth?returnTo=. - It does NOT dedupe. See above.
Tests that cover it
mobile/screen-tests/submit-patch.test.tsx— the screen, including the flag gate.mobile/screen-tests/screen-mocks.test.tsx— the 11 gallery states render with real catalog rows:flags-loading,empty,no-fix,name-too-short(one character under the gate),photo-missing,ready,known-place(a place Scout already has),long-name(exactly 100),notes,notes-at-limit(500/500),submitting.mobile/src/components/navigation/__tests__/drawerSections.test.ts— the drawer row's flag gate.
Open questions
- Is the queue ever reviewed?
promotedAt/dismissedAtexist and the admin screen exists, but with the flag off there is nothing in the queue to review, and I did not check prod for rows. - What is meant to happen to a submitter whose patch is promoted? There is no notification path today and no field recording one.