Module
City Requests
Native citizen service-request intake in the City Viewer — "Report an issue" as an in-app flow instead of an external link. Citizens pick an issue, point at a location, answer the issue's own questions, and the request lands in Cityworks as a service request with a full transaction trail. Cityworks stays the system of record; this module is the front door (the role Auburn FixIt / Rock Solid OneView plays today).
| Status | Draft — design + spike |
| Last updated | — |
| Related | Facility View / City Viewer · Workflow Engine · Forms · Geo Integration · Location |
Module Definition
Auburn FixIt (Rock Solid OneView, a Granicus product) is a hosted front door over the Cityworks APIs the city already licenses: it imports the active service request templates, walks a citizen through issue → location → the template's questions, creates the SR through the Cityworks REST API, and reflects status back. This module brings that front door into the City Viewer, on integration plumbing that already exists in MyGov:
CityworksService— the API chokepoint (token auth,Ams/ServiceRequest/Problems,/Create,/ById, raw passthrough).CityworksHandler(workflow V2) —CreateServiceRequestwith a full field whitelist (ProblemSid,Answers/DateAnswers,X/Y/WKID, theCaller*contact block) and entity tracking (Cityworks/ServiceRequestids in the workflow logs).- The Teams bot's conversation engine — a working, DB-configured guided intake over the same service (
ProblemSelect→Location→DynamicFormsteps, ArcGIS-backed location strategies). The PWA flow is the same state machine wearing a citizen skin. ArcGisService— address search / nearby queries for the location step.- The
cityworkswebhook ingest — the future hook for "your request was closed" notifications.
Cityworks CreateServiceRequest action, exactly like
the library's patron writes. The FormResponse row + workflow logs + entity
tracking are the reconciliation trail; no bespoke write controller.
1 · Curation & Data
One new persistent thing: the curated issue list. It lives in the
DataList system (data.List slug cityapp-report-issues) — the
same staff-editable store that drives the website nav and the City Viewer's services
menu, edited on the same portal page with the same icon picker.
| Item field | Meaning |
|---|---|
Label | Citizen-facing issue name ("Pothole", "Street light out"). |
Value | The Cityworks ProblemSid (the template key). |
Data.icon / Data.iconSvg | Lucide icon (picker-written, same as the services menu). |
Data.group | Optional section header ("Streets", "Trash & recycling", "Water"). |
Data.hint | Optional one-line citizen guidance shown under the label. |
Data.emergency | Optional: renders a "call 911 / call this number instead" interstitial rather than a form (e.g. gas leak). |
Options considered
| Option | Verdict |
|---|---|
| DataList curation Chosen | Zero schema, existing admin page + icon picker, same mechanism as the services menu it sits behind. The join to live template data happens at read time in the API. |
New cms.CitizenRequestTemplate table |
Structured and validatable, but duplicates what DataList already gives us for a list this small; a real table earns its place only if curation grows relational needs (per-template routing, districts). |
| Reuse the bot conversation step configs | Tempting single authoring surface, but bot steps are Teams-shaped (Task Modules, Adaptive Cards) and staff-internal. Revisit if the two intakes' configs start diverging in lockstep. |
Submission tracking needs no new table. The FormResponse is
the transaction record; entity tracking stores the created SR id. The citizen's status
lookup is authorized by a stateless token —
HMAC(requestId, server secret), nothing persisted, nothing enumerable. The
generic forms endpoint can't mint module tokens, so right after submit the app exchanges
its { formResponseId, requestId } pair (both in the submit response) at
POST /city-requests/claim: the pair must match the stored form data and be
recent, closing the enumeration hole.
2 · API & Endpoints
| Route | Method | Auth | Returns / does |
|---|---|---|---|
/city-requests/issues | GET | public | Built The curated list (labels, icons, groups, hints) joined against live template metadata — a curated ProblemSid whose template went inactive in Cityworks is dropped, so staff edits in either system can't 404 a citizen. 5-min server cache; only curated sids are ever served. |
/city-requests/issues/{problemSid} | GET | public | Built The template's question set (Ams/ServiceRequest/QA) shaped for rendering by CityRequestQaShaper (unit-tested against the live 17094 fixture). The raw shape is a branching graph: the shaping emits { branching, firstQuestionId, questions: [{ questionId, text, kind, answers: [{ answerId, text, format, guidance, nextQuestionId }] }] } — kind derives from the answers' AnswerFormats, unknown formats degrade to text, broken branch pointers terminate. |
| submit | POST | public, limited | POST /form/response (per-IP rate limited) — the system Form (id from /city-requests/config) carrying: problemSid, answers, location (X/Y + WKID or address), contact block, PhotoIds. The form's CreateServiceRequest + AddRequestAttachment actions run synchronously; the response carries the FormResponse id plus a trackedEntities list (populated from the workflow's entity-tracking log columns) from which the app reads the created SR's RequestId. |
/city-requests/config | GET | public | Built The submit form binding + client knobs (photo limits, turnstile site key when enabled) — read from the cityapp-config DataList, so wiring the authored form is a portal edit, not a deploy. Null formId = the app keeps its external link. |
/city-requests/claim | POST | public, limited | Built Post-submit token exchange: { formResponseId, requestId } → the HMAC status token, only when the pair matches the stored form data and the response is < 24h old. |
/city-requests/{requestId}/status?token= | GET | token | Built Minimal citizen status (issue label from the curation list, status, submitted/closed dates). HMAC token gate — no enumeration. |
/city-requests/photos | POST | public, limited | Built One photo per call → re-encoded via ImageProcessor.ReencodeUploadAsync (proves it's an image, strips EXIF incl. GPS, downscales to ≤1600px JPEG) → stored under an unguessable id; the submit payload carries the ids and the workflow's AddRequestAttachment action forwards them to Cityworks (best-effort — a failed attach comments on the SR, never fails it). |
/city-requests/geocode?q= | GET | public | Built Address search for the location step — proxies the city's public ArcGIS locator (findAddressCandidates; the Tools/PublicLocator geocoder the Teams bot's default location strategy already uses; URL in cityapp-config), returning up to 5 { label, x, y } candidates in WGS84. Unconfigured or failing search degrades to GPS + manual address text — never blocks the flow. |
/city-requests/nearby?problemSid=&x=&y=&radius= | GET | public | Built The pre-submit "this may already be reported" check (§9). Two-step through the CityworksService chokepoint: Ams/ServiceRequest/Search (filter ProblemSid + Closed=false + MaxResults) → Ams/ServiceRequest/ByIds (hydrate RequestBase), then distance-filtered in code to the nearest ≤5 within radius m (default 150). Coords are WGS84 (x=lng, y=lat); returned SRX/SRY are normalised to WGS84 by magnitude via GeometryHelper.ToWgs84. Only curated ProblemSids served; non-blocking — no coords / no matches / any failure returns [], never an error. |
/city-requests/spike/* | GET | staff | Built Diagnostic dumps of live problems + one template's raw Q&A — the fixture data this design's question renderer is validated against. Removed when the module ships. |
ArcGisService geocoding, not the spatial truth.
This matches the geo-integration stance: coordinates
are canonical, addresses are labels. The live instance stores SR coordinates in
Web Mercator (probed: SRX/SRY ≈ −9,514,878 / 3,844,365);
submit as lat/lng with WKID: 4326 and confirm Cityworks projects it on the
first internal-testing create. Reads normalise defensively: the nearby
lookup does not assume the CwWKID — GeometryHelper.ToWgs84 classifies returned
SRX/SRY by magnitude (WGS84 within ±180/±90 · State Plane 102629 in the
hundred-thousands of feet · Web Mercator 3857 metres in the millions) and reprojects to
WGS84, so it stays correct whichever the instance actually returns. Pin down the real
CwWKID at first live create and this can collapse to the single known case.
Spike findings (validated live, 2026-07)
- Auth: CW 23.13 requires the
Authorization: cityworks {token}header — a query-string token alone returnsStatus 3(unauthenticated).CityworksServicealready sends it. - Endpoint: template Q&A is
Ams/ServiceRequest/QAwith a singularProblemSid; ~222 active templates (ProblemSid,DomainId,ProblemCode,Description) come fromAms/ServiceRequest/Problems. - The Q&A model is a branching graph. Example (ProblemSid 17094, "Garbage Service Interrupt"): questions carry sequence; each answer carries
AnswerFormat(DATE— a date input;THISTEXT— a fixed choice;FREETEXT— free text), optionalTellCallerguidance ("must be submitted one week prior…"), andNextQuestionId(0= done). "Other → explain" is expressed as a THISTEXT choice branching to a FREETEXT question. - Still to confirm at first live create (internal testing, not design-blocking): the exact
Answers/DateAnswerssubmit payload shape (AnswerId + text pairs), and WKID 4326 acceptance on create.
3 · Cityworks Integration & Boundary
- One chokepoint: every call goes through
CityworksService(token, error typing). New method for this module:GetTemplateQuestionsAsync; everything else exists. - Reads are direct (issues list, questions, status, nearby — low latency, not transactions); the write runs through the workflow engine (system Form +
CityworksHandler.CreateServiceRequest) for the durable trail. Identical split to the library module. - Nearby duplicate check adds two typed reads to the
ICityworksServicechokepoint —SearchServiceRequestsAsync(Ams/ServiceRequest/Search→ RequestIds) andGetServiceRequestsByIdsAsync(ByIds→RequestBase) — plus a sharedCityworksSrfield-name constants block the status read also adopts. ReturnedSRX/SRYare reprojected to WGS84 byGeometryHelper.ToWgs84(gainedProjectFromWebMercator+ProjectFromAuburn+ magnitude classification). Server-sideExtentXYspatial prefilter is deferred (a scale optimisation; today's fetch-by-type + in-code distance filter suits transition volumes). - Attachments: a second workflow action on the same form (
AddRequestAttachment, new handler op) consumes the create'sRequestId— attachment failure must not fail the SR (photos are best-effort; the SR comments note a failed attach). - Photos are not portal "Attached Files". Citizen photos ride their own store (
/city-requests/photos) and land in Cityworks viaAddRequestAttachment— they are neverFormResponseUploadrows, so the portal's response-detail gallery shows only thePhotoIdsGUIDs. Deliberate (Cityworks is the image system of record); rendering thumbnails in the portal is an optional later enhancement, not a gap in the write path. - InitiatedByApp: stamp
InitiatedByApp = "MyGov City Viewer"(whitelisted field) so staff can tell app-sourced SRs from FixIt/phone intake during the transition. - Licensing: citizen-sourced SRs ride the same service account Rock Solid would use — confirm with the Cityworks rep that the license covers it (one email, before production cutover).
- Status push (v2): the
cityworkswebhook ingest already receives SR events; closing the loop is a notification fan-out (eNotifier email first, app push later), not new ingestion.
it.Credential by a hardcoded name — CityRequestsController
(and the spike) match "Cityworks", the Teams bot matches "Cityworks-Prod" — via
duplicated LoadCredentialAsync helpers that don't even agree on the name. Wrong pattern.
The workflow handler already does it right: the credential is bound on the action
(RequiresCredential) and passed in by the engine — no string match.
Target: the Conversation bot takes its credential from workflow
config the same way; the City Viewer endpoint takes an app-scoped credential
instead of a controller constant, so distinct apps (and a future admin) can call with distinct
credentials. Open question — where that app config lives: the PWA passing a credential
reference (server-side allow-listed, never the secret), a field on the website/subsite record
(we have the table but no such management today — appealing), or api appsettings as the
interim — but not appsettings-by-default. Its own refactor; not smuggled into the nearby-issues feature.
4 · Location UX
The location step is the module's one genuinely new capability: the City Viewer's first
city-wide map (the facility viewer renders indoor floor plans from our
own geometry; this needs the city itself). Built as a center-pin picker
(cv-location-picker): the pin is fixed at the viewport center and the MAP
moves under it — the touch-friendly inversion of a draggable pin — so the chosen point is
always whatever sits under the pin's tip. Three entries converge on it:
use my location (geolocation flies the map there),
search an address (ArcGIS geocode → candidates → fly there; the address
stays as the human-readable label while the pin coords stay live, so fine-tune panning
refines the point without losing the words), or just pan/zoom the map
directly. Coordinates convert map-center (State Plane) → WGS84 client-side
(js/geo.js's inverse projection).
Options considered — the map
| Option | Verdict |
|---|---|
| City aerial-tile system Chosen · Built | The shared "cheat the base layer" capability (see lightning-proximity §6a): pre-rendered georeferenced imagery + affine math, no map library at all. Zero vendored dependencies, works offline-first, and the storm map proved the pipeline first. Supersedes the earlier Leaflet choice below. |
| Leaflet + Esri basemap tiles | The original pick (tiny, vendorable, drag-pin trivial) — never built; superseded when the aerial-tile system landed as a shared capability serving both this and the storm map. |
| ArcGIS JS SDK | Full Esri fidelity but heavyweight (MBs) for "show a pin" — wrong cost for a PWA precache. |
| No map — address text only | Ships fastest but guts the product: "point at the pothole" is the feature. Kept as the degraded path (address/landmark text still submits; imagery failure keeps the pin working over a plain field). |
X/Y/WKID, the
submission carries MapLocation: { location: { x, y } } (WGS84) — map the form
field as type location and MyGov's ResponseDetailPage renders the embedded
map. Named MapLocation deliberately: Location is a real (string)
Cityworks SR field on CityworksHandler's create whitelist, and this object
must stay OUT of the Cityworks call — un-whitelisted keys are stripped from the SR create
but persist in the stored response.
5 · User Interface
A new routed screen, cv-report (?view=report), presented as a
stepper in the app shell: Issue → Location → Details → Review → Done.
The Services menu's "Report an issue" tile flips from href (FixIt) to
view: "report" — a Data List edit, and the rollback is the same edit.
- Issue — the curated grid (groups as section titles), same tile components as Home; an
emergencyitem short-circuits to the call-this-number interstitial. - Location — the map step above; the confirmed point echoes as an address label.
- Details — a branching wizard, not a flat form (the live Q&A model branches per answer — see §2's spike findings): one question at a time, following the chosen answer's
nextQuestionIduntil terminal. Kinds map to controls (choice → radio pills,date → date input,freetext → textarea); an answer'sguidance(CityworksTellCaller) renders inline the moment it's selected. Then free-text description, up to N photos, and the contact block (name/email/phone; optional "anonymous" per template policy). Back within the wizard rewinds the answer path. A signed-in drawer session prefills contact when city accounts exist. - Review — everything on one card; submit shows the workflow result honestly (the engine returns synchronous success/failure).
- Done — reference number + a "check status" link carrying the HMAC token (also copyable); status view renders the minimal status payload.
AnswerFormat
the API shaping doesn't recognize degrades to a text input, and a broken
nextQuestionId (pointing at a missing question) terminates the wizard cleanly —
a new Cityworks question type or a mis-authored template can never break submission, it
just renders plainly until the shaping learns it.
6 · Anti-abuse & Privacy
- Rate limiting on submit + photo upload (per-IP, ASP.NET rate limiter) — v1 requirement.
- Turnstile-style challenge — wired as a config knob (
/city-requests/configreturns the site key when enabled); off at launch, flipped without an app release if abuse appears. - Photos: image-only, size-capped, re-encoded through
ImageProcessor(which also strips EXIF — location comes from the pin, deliberately, not from photo metadata). - PII: the contact block lands in Cityworks (system of record) and transiently in the
FormResponsepayload — same retention story as every other workflow form; note it in the app's privacy policy page (also needed for Play).
7 · Build Sequence
Dependency order — each step independently shippable/testable. The detailed task list below hangs off these.
- Spike Built — staff-auth dumps of live problems + template Q&A; validates the question shapes, the WKID, and the curation candidates.
- Reads Built —
/issues+/issues/{sid}shaping + tests; list seeded with the 17094 example (the full staff curation pass remains). - Write path Built — handler attachment op, photos, config/claim/status endpoints, per-IP rate limiting. Remaining: staff author the system Form and bind its id in
cityapp-config. - App flow Built —
cv-report(?view=report, Services-tab umbrella): issue grid → location (GPS / geocoder search / manual text) → the branching question wizard → details (description · photos · optional contact) → review → done, with the claim exchange, a local "your reports" memory, and the status view. Verified end-to-end against stubbed endpoints; first live submit still pending the form binding. - Map Built — the center-pin aerial-tile picker (
cv-location-picker, §4) upgraded the location step in place: GPS/geocode fly the map, panning fine-tunes, the pin's spot is what submits (+ theMapLocationportal-map field). - Cutover — menu flip, FixIt link demoted to the website; transition monitoring via
InitiatedByApp.
8 · Dev Task List
Living checklist — remove items as they land (don't mark them done). Tags: api app my data/config
- api Next test create (internal testing): confirm the photo actually landed on the SR in Cityworks —
AddRequestAttachmentran on the first live create (7/13), but that SR was deleted before the attachment was checked. The create itself validated: SR created, WKID 4326 accepted, reference number returned viatrackedEntities. - data/config Curation pass — the list exists with one validated example (17094, via
db/seed-cityapp-report-issues.sql); staff pick the remaining ~10–15 citizen issues from the spike's problem dump (labels, icons, groups; flag any emergency items) in the Data List Manager. - api Turnstile server-side verification on submit — lands when the challenge is first enabled (the site-key knob and config plumbing already exist; off by default).
- data/config Author the system Form ("Citizen service request") with the
CityworksCreateServiceRequest+AddRequestAttachmentactions; put its form id in thecityapp-configDataList (cityRequestsFormId— seeded empty). - my Map the request form's
MapLocationfield as typelocationin the form schema so ResponseDetailPage renders the embedded map (§4's portal-map contract). - data/config Decide whether City Requests' precision warrants targeted L01–L03 aerial regeneration in dense areas (the open item flagged in lightning-proximity §6a).
- app After first live create: reconcile the submitted
Answers/DateAnswersmapping with what Cityworks actually recorded (the Comments transcript is the safety net until then). - my Nothing new required — curation uses the Data List Manager (icon picker shipped). Optional later: a spike-fed "issue browser" to speed the curation pass.
- data/config Cutover: flip the services-menu item to
view: "report"; keep FixIt linked from the website until the retirement decision. - api Post-cutover: delete the spike endpoints; confirm Cityworks licensing email is on file.
9 · Roadmap (post-v1)
| Item | Notes |
|---|---|
| Nearby duplicates Built | Before submit, the review step shows open SRs of the same type near the pin (GET /city-requests/nearby + the cv-report panel). Deferred within it: the server-side ExtentXY spatial prefilter (scale) and dropping the multi-CRS normalisation once the real CwWKID is pinned down. |
| Status push | Webhook ingest → eNotifier email on status change; app push later. Closes the loop without polling. |
| My requests | A drawer group under the future CityAccount (tier: native) listing the citizen's submissions — needs an account to hang them on. |
| FixIt retirement | Once app + website intake cover the volume, the OneView subscription is the savings that funds this module. |