ASP.NET Core Minimal API. Data model: docs/data-model/erd.md. Decision: ADR-0003.
The machine-readable contract is the OpenAPI spec at /openapi/v1.json (running API).
From source:
docker compose up -d db minio # PostGIS (5432) + MinIO (9000, console 9001)
dotnet run --project apps/api/OpenQuest.Api # http://localhost:5076, applies migrations, seeds catalog + adminOr everything in Docker (PostGIS, MinIO, this API as a container from apps/api/Dockerfile, and the importer):
docker compose up -d --build # API on http://localhost:5076
docker compose logs api | grep admin # generated admin password (first start only)Don't combine the two: the api container already uses port 5076. To switch to dotnet run, stop it with docker compose stop api.
appsettings.Development.json (committed) only holds non-secret values that match docker-compose.yml. In the container, docker-compose.yml points the connection string and MinIO to the service names (db, minio). Secrets are never in git:
- JWT key: generated randomly on every start in Development (tokens die on restart). Elsewhere
Jwt__Keyis required. - Admin: created on first start. Without
Admin__Passworda password is generated and printed once in the log (Created admin 'admin' with generated password: ...). To choose your own:Admin__Password=... dotnet run ..., ordotnet user-secrets set Admin:Password "..." --project apps/api/OpenQuest.Api. Outside DevelopmentAdmin__Passwordis required. - Everything else: see
.env.example.
The API does not import data. Assets come from the separate importer (apps/importer), which writes the open data tables; the API only reads them. The schema still belongs to the API: the importer waits until the API has applied its migrations and seeded the asset types.
dotnet test # unit tests + API integration testsAPI integration tests need PostGIS. By default they start a Testcontainers container. To reuse a running server instead
(for example the compose one), set OPENQUEST_TEST_DB="Host=localhost;Port=5432;Database=postgres;Username=openquest;Password=openquest";
each run then creates and drops its own database.
- JSON is camelCase, enum values are
snake_casestrings (verify_attribute,removed_at_source). Coordinates are WGS84 (lat,lon). - Auth:
Authorization: Bearer <token>from/auth/loginor/auth/register. Roles:player,moderator,admin. - Errors:
{ "error": "<code>", "message": "...", "details": ... }with a matching HTTP status; validation errors use RFC 7807 problem details.
| Step | Call | Notes |
|---|---|---|
| Sign up | POST /auth/register {username, password} |
No e-mail. Response contains recoveryCodes (8): shown once, tell the player to save them |
| Log in | POST /auth/login |
|
| Forgot password | POST /auth/recover {username, recoveryCode, newPassword} |
Each code works once. POST /me/recovery-codes {password} creates a fresh set |
| Map | GET /quests/nearby?lat&lon&radius |
Nearest first; full quests and quests you already hold are hidden. GET /assets/nearby shows all objects |
| Accept | POST /quests/{id}/claim |
409 no_free_slots / already_claimed / quest_unavailable. Claim expires after claimTtlMinutes (default 30). POST /claims/{id}/cancel |
| Complete | POST /claims/{id}/submit (multipart) |
fields lat, lon, payload (JSON text), photo (optional file) |
| Status | GET /me/claims |
Includes submission status and the moderator's rejection reason |
| Progress | GET /me |
Besides id, username and role: totalPoints, level (level, current, required, percent, isMaxLevel) and cardCount |
| Cards | GET /me/cards?offset&limit, GET /me/collection |
The player's tree cards (newest first) and the tree book, see "Tree cards" below |
| Points | GET /me/points?offset&limit |
The player's ledger, newest first: amount, reason (quest_approved), submissionId, questTitle, createdAt |
payload per task type (JSON Schema is in task_type.result_schema):
| Task type | payload | Photo |
|---|---|---|
photo |
{} |
required |
verify_attribute |
{"value":"Tilia"} (optional confirmed, note) |
optional |
measure |
{"value":123.5} (positive number) |
optional |
condition_report |
{"condition":"good|damaged|dead|gone"} |
optional |
Submit errors: 422 outside_geofence (with distanceMeters), 422 invalid_submission (with details.problems), 422 invalid_photo,
409 duplicate_photo, 409 claim_expired, 409 already_submitted, 413 photo_too_large. The quest's task config (for example {"attribute":"genus"}) is part of every quest returned to the client.
Approved photos are public at GET /media/{id} (only after approval, never before).
| Role | Calls |
|---|---|
moderator, admin |
GET /admin/submissions?status=pending, GET /admin/media/{id}, POST /admin/submissions/{id}/review {approved, reason} (reason required to reject; rejecting frees the slot) |
admin |
POST /admin/quests (creates a campaign with one quest per selected asset), GET /admin/quests, POST /admin/quests/{id}/status, GET /admin/campaigns, GET /admin/sync/runs, GET /admin/assets/{id}/history, GET /admin/reports, GET /admin/readings, GET /admin/publications, POST /admin/publications/retry, GET /admin/outbox |
Create quests for trees without a known genus inside a map rectangle:
POST /admin/quests
{ "taskType": "verify_attribute", "taskConfig": { "attribute": "genus" },
"maxCompletions": 2, "rewardPoints": 20,
"target": { "attributeFilter": { "genus": null },
"bbox": { "minLon": 7.60, "minLat": 51.95, "maxLon": 7.64, "maxLat": 51.97 }, "limit": 100 } }target accepts assetIds, bbox, attributeFilter (JSONB containment on asset attributes, for example {"quality_flags":["placeholder_genus"]}), withoutApprovedPhoto and withOpenReport (assets with an open report from an external feed: a category such as tree_damage / oak_processionary_moth, or any). assetType selects tree (default) or natural_monument.
An asset never gets the same quest twice.
The phone app (Android/iOS, a Capacitor shell around the web frontend) runs its web view under https://localhost (Android) and capacitor://localhost (iOS).
Both are in Cors:Origins of the Development settings and in .env.example; a deployment has to list them in Cors__Origins as well.
Photos come straight from the phone camera: POST /claims/{id}/submit takes a JPEG up to Storage:MaxPhotoBytes (10 MB, a 2048 px JPEG is about 1 to 2 MB);
EXIF is stripped and the orientation applied on the server, so the app does not have to. To reach the API from a phone on the same network, start it with
--urls http://0.0.0.0:5076 and use the computer's address as API URL in the app.
Every approved submission can give a tree card: the genus of the tree (for a genus quest the genus the player found out), with a rarity of common, uncommon, rare
or legendary (ADR-0009). How rare it turns out to be depends on how frequent that genus is among the trees of the
tree's district: an abundant genus (the lime trees of Münster) gives mostly common cards, a scarce one (a Ginkgo among a thousand Tilia) has much better chances of
a rare one, and a genus that does not grow in the district at all the best. The dice roll comes from the submission's id, so a redelivered event gives the same card.
No genus known and none found out: no card.
Chances go up further when the submission brings something new: new_information (the data had no genus or another one) and condition_fact (damaged, dead or gone
was reported). Each moves the genus one class towards scarce. The reasons are on the card (reasons: scarce_in_district, new_to_district, new_information,
condition_fact), so the app can say why a card is special.
| Who | Call | Notes |
|---|---|---|
| player | GET /me/cards?offset&limit |
Cards, newest first: genus, rarity, frequency (abundant, common, scarce, very_scarce), share of the genus in the district, reasons, district, tree position, obtainedAt |
| player | GET /me/collection |
The tree book: totalCards, distinctGenera, byRarity (all four), genera[] with count, bestRarity, firstObtainedAt (best first) |
| player | GET /districts/{id}/genera?limit |
Which genera grow in a district: treeCount, share, frequency, most frequent first, plus knownGenusTrees and calculatedAt |
| admin | POST /admin/districts/{id}/genera/refresh |
Recalculate the genus statistics now |
The genus statistics are counted from the assets inside the district's outline (about 75 ms per district for 43,000 trees). They are recalculated when missing, after the district
was redrawn or changed, or when older than Gamification:GenusStatsMaxAgeHours (24), because the importer changes the assets on its own. Districts with fewer than 30 trees of
known genus (MinSample) do not have reliable statistics: nothing counts as scarce there. The thresholds and chances can be tuned per deployment (Gamification:Rarity,
see .env.example); the defaults are in RarityProfile.Default in the core.
Players are sent to the assets nobody has looked at for a long time, on a rhythm (ADR-0010). Two parts:
1. Selecting stale assets. asset_activity records when a player last had an asset approved (last_verified_at, verification_count; no row = never). Quest targets
(POST /admin/quests, and the schedules below) can use it, and places:
target field |
Selects |
|---|---|
notVerifiedForDays: 365 |
assets nobody verified for a year (or ever); with a limit the never-verified first, then the longest unchecked |
districtId |
assets inside this district's outline |
cityId |
assets inside any active district of the city |
They combine with the other selectors (attributeFilter, limit, ...). A quest whose endsAt has passed no longer blocks a new quest of the same kind for that asset.
2. Weekly schedules. A schedule is a template; every week on its weekday and time (in the city's time zone, week = Monday to Sunday) the API creates the quests, open for
durationHours. rewardPoints is the bonus that makes the quest worth more than a normal one. A run happens once per schedule and week (restarts and several instances are safe);
if the API was down at the time it catches up later in the same week as long as the window is still open.
| Call | Notes |
|---|---|
POST /admin/quest-schedules |
Create. Body below. Checked by creating the quests once and rolling back (400/422 with the reasons) |
GET /admin/quest-schedules, GET .../{id} |
With nextRunAt (null while paused) and lastRun |
PUT /admin/quest-schedules/{id} |
Change the given fields; isEnabled: false pauses it |
DELETE /admin/quest-schedules/{id} |
Deletes the schedule and its run history; created quests stay |
GET /admin/quest-schedules/{id}/runs |
Runs, newest first: periodKey (2026-W39), questsCreated, campaignId, error |
POST /admin/quest-schedules/{id}/preview |
{ "wouldCreate": n } right now, nothing saved |
POST /admin/quest-schedules/{id}/run |
Create the quests now, additionally to the weekly runs (also when paused) |
{
"name": "Sunday check", "cityId": "…", "weekday": "sunday", "time": "08:00", "durationHours": 24,
"taskType": "photo", "title": "Sunday check: when did you last see this tree?", "maxCompletions": 1, "rewardPoints": 30,
"target": { "notVerifiedForDays": 365, "limit": 50 }
}Without districtId and cityId in the target it applies to the schedule's city (its districts). A schedule only finds assets inside districts, so draw them first. Set
Gamification:QuestScheduleIntervalSeconds (60) to change how often the worker checks the clock.
Sync events. After every successful run the importer sends pg_notify('sync_finished', run_id); the API listens and puts an AssetSyncCompleted event into the outbox
(also for runs it missed while it was down, the last two days). Register an IEventHandler<AssetSyncCompleted> to react; the built-in one marks the district genus
statistics as out of date.
Decisions in ADR-0011.
Problems on a tree. A condition_report payload may contain issues: a list (each once) of root_lift, trunk_damage, dead_branches, crown_damage, fungus,
cavity, leaning, pests, vandalism, next to the required condition. Approved, it is published as a second change (attribute issues).
Trees that are missing in the data. Task type report_new_tree: the quest belongs to a district, not to an asset.
POST /admin/quests
{ "taskType": "report_new_tree", "taskConfig": { "dataSource": "de-muenster-trees" }, "maxCompletions": 5, "rewardPoints": 40,
"title": "Fehlt hier ein Baum?", "target": { "districtId": "…" } } // or "cityId": one quest per active district| Who | Call | Notes |
|---|---|---|
| player | GET /quests/areas?lat&lon |
Quests of the districts the position lies in. asset is null, area names the district. GET /quests/nearby does not return them |
| player | POST /quests/{id}/claim, POST /claims/{id}/submit |
Photo required; payload: optional genus, species, note; lat/lon is the tree's position |
| moderator | GET /admin/proposals?status= |
The reported trees. Approve or reject the submission as usual |
Errors: outside_area (not inside the district), tree_already_known (a tree or an open report within Game:NewTreeMinDistanceMeters, 5 m), data_source_unknown. An approved report is
published with the other accepted changes: a feature at the reported position with attribute = "new_tree", empty external_id and new_value = {genus, species, note, photo_url}.
The points go to the quest's district; there is no card. Clients must accept asset: null in QuestDto and AdminSubmissionDto (see area).
Automatic review of photos (off by default). After a submission with a photo, the API can ask the web app's photo verification (POST /api/verify, ADR-0002) and approve the
submission when the verdict is a clear approve; review and reject leave it to a moderator (nothing is ever rejected automatically). The verdict, reasons and details are stored in auto_review
and shown in GET /admin/submissions (autoReview; reviewedBy is null when the check approved it).
| Setting | Default | Meaning |
|---|---|---|
AutoReview:Enabled |
false |
Switch it on |
AutoReview:VerifyUrl |
Full address of the web app's verification, e.g. https://openquest.fun/api/verify |
|
AutoReview:TaskTypes |
["photo"] |
Which task types are checked (only with a photo). A reported new tree changes the city's data: add report_new_tree only on purpose |
AutoReview:TimeoutSeconds |
90 |
The web app rate limits /api/verify per IP (10 per minute); answers like 429 or 5xx are retried with backoff by the outbox and end with the submission staying for a moderator.
Swap the checker by registering another ISubmissionAutoReviewer (core) instead of the HTTP one.
The API serves a small admin panel at /panel/ (static files in apps/api/OpenQuest.Api/wwwroot/panel, no build step, plain JavaScript modules,
Leaflet is vendored; only the map tiles come from OpenStreetMap). Log in with an admin (or moderator) account. It uses the REST endpoints below, so it
doubles as a reference for the frontend team.
- Cities & districts (admin): pick or create a city, draw districts on the map (click the corners in order, drag to move, right-click to delete a point; the numbers show the order), live check while drawing (crossing edges are marked, overlaps with other districts are highlighted, saving is blocked until the shape is valid), edit, deactivate, delete, import GeoJSON, and see the district leaderboard (all time / this week).
- Moderation (moderator, admin): the review queue with photo, answer and distance; approve (pays the points) or reject with a reason.
The panel sends a Content-Security-Policy (scripts only from itself); the token is kept in sessionStorage and is gone when the tab closes.
Admins split a city into districts ("Stadtviertel") in the admin panel; players see them on the map and compete in a district leaderboard (ADR-0007). A city has any number of districts, a district belongs to one city. The outline is an ordered ring of points: the points are connected in the order they were drawn, the last one back to the first. Edges must not cross, and districts of one city must not overlap (sharing a border is fine).
All shapes are GeoJSON (Polygon, or a Feature holding one): coordinates are [lon, lat], the ring may be open or closed, the order is kept,
holes and MultiPolygon are not supported. The API stores the corners as ordered district_point rows and derives the area (geom) and a centre for labels.
| Who | Call | Notes |
|---|---|---|
| admin | POST /admin/cities {name, key?, countryCode?, centerLat?, centerLon?, defaultZoom?, timezone?} |
key defaults to a slug of the name, timezone to Europe/Berlin (decides where a leaderboard week starts). PUT /admin/cities/{id}, DELETE /admin/cities/{id} (409 while it has districts), GET /admin/cities |
| admin | POST /admin/cities/{id}/districts/validate {geometry, districtId?} |
Dry run for the drawing tool: returns {valid, pointCount, centroidLat, centroidLon, problems[]} and saves nothing. Pass districtId when redrawing an existing district |
| admin | POST /admin/cities/{id}/districts {name, description?, color?, key?, geometry} |
201 with the district. color is #RRGGBB. Problems: 422 invalid_geometry with details.problems[], 409 name_taken / key_taken |
| admin | PUT /admin/districts/{id} {name?, description?, color?, isActive?} |
Deactivate instead of deleting to keep the leaderboard history. Re-activating checks for overlaps again |
| admin | PUT /admin/districts/{id}/geometry {geometry} |
Replaces the whole outline. Points already awarded keep the district they were earned in |
| admin | DELETE /admin/districts/{id} |
Only if no points were earned there (409 district_has_points) |
| admin | POST /admin/cities/{id}/districts/import {geoJson, nameProperty, keyProperty?, descriptionProperty?} |
Creates one district per feature of a FeatureCollection. All or nothing: on problems 422 invalid_import with details.features[] (index, name, problems) |
| admin | GET /admin/cities/{id}/districts?geometry=true, GET /admin/districts/{id} |
Include deactivated districts |
| player | GET /cities, GET /cities/{idOrKey} |
Cities with map centre, zoom, time zone, number of districts |
| player | GET /cities/{id}/districts?geometry=true |
Active districts with name, description, colour, centre, points; with geometry=true also the outline as GeoJSON |
| player | GET /districts/{id} |
One district plus rank in its city and contributors |
| player | GET /districts/lookup?lat&lon |
The district a position lies in (404 no_district if none) |
| player | GET /cities/{id}/leaderboard?period=all|week |
Ranking of the districts: rank, points, contributors, contributions. week = Monday 00:00 to Sunday in the city's time zone. Equal points and contributors share a rank |
| player | GET /districts/{id}/leaderboard?period=&limit= |
The players with the most points in the district |
Reading needs a login, writing needs the admin role. Points are awarded to the district the quest's tree lies in (at the moment of approval);
trees outside every district count for the player only.
Draw a district (what a Leaflet drawing tool sends and receives, [lon, lat]):
POST /admin/cities/{cityId}/districts
{ "name": "Kreuzviertel", "description": "Studentisch, viele Linden", "color": "#2C8054",
"geometry": { "type": "Polygon",
"coordinates": [[[7.6100, 51.9600], [7.6250, 51.9600], [7.6250, 51.9700], [7.6100, 51.9700], [7.6100, 51.9600]]] } }Problems come as a list, the same for the dry run and for saving. Edge indices count the points of the ring; lat/lon is where two edges cross:
{ "error": "invalid_geometry", "details": { "problems": [
{ "code": "self_intersection", "message": "Edge 0 and edge 2 cross or touch each other.", "edgeA": 0, "edgeB": 2, "lat": 51.965, "lon": 7.617 },
{ "code": "overlaps_district", "message": "The outline overlaps the district 'Dom'.", "districtId": "…", "districtName": "Dom", "overlapRatio": 0.42 } ] } }Problem codes: self_intersection, overlaps_district, too_few_points, too_many_points (max 5000), duplicate_point, zero_area, invalid_coordinate,
invalid_geometry, geometry_required, unsupported_geometry (not a Polygon), holes_not_supported.
The API does not import anything; a separate importer loads the city's data set and records every run in sync_run and every change of an asset in asset_snapshot. The API shows the result read-only:
GET /admin/sync/runs: the latest runs with status, counters and error;GET /admin/reports?status=open&category=tree_damage: reports from external feeds (e.g. the city's "Mängelmelder"), with the linked asset;GET /admin/readings?metric=soil_moisture_grass_sand_0_60cm&days=14: environment readings such as daily soil moisture;GET /admin/assets/{id}/history: the versions of an asset (created,updated,removed).
Player results never overwrite asset data. A submission proposes an attribute_change; the moment a moderator approves it,
the change is pushed to open data (ADR-0004); nobody has to export anything:
approve -> transaction: change = accepted + outbox event (same commit) -> NOTIFY -> handler -> publishers
|-> public feed /open-data/{dataSource}/changes.geojson | .csv (always)
'-> GitHub repository file (if configured)
- The feed URL is stable, public, carries the required attribution, and is meant to be linked by the city (see ADR-0001).
- Delivery is retried with backoff if a channel fails;
GET /admin/outboxshows pending / dead messages,POST /admin/publications/retryre-emits events for accepted-but-unpublished changes. - Rejected submissions publish nothing. Other parts of the system can react to
SubmissionApproved/SubmissionRejected(rewards later) by registering a handler.
Composition/ServiceRegistration.cs is the only place that names implementations; everything else depends on small interfaces
(IQuestClaimService, ISubmissionService, IContributionPublisher, IBlobWriter, ...). Extend by adding a handler
(IEventHandler<T>), a publisher (IContributionPublisher), or a startup task (IStartupTask).
Gamification beyond points, levels, the district leaderboard, cards, weekly quests and new-tree reports (badges), statistics, media.captured_at (EXIF time is dropped, not stored),
account deletion (user.deleted_at is honored on login but there is no endpoint), street name enrichment, admin-created moderators (set user.role in the database for now).