1. Spec vs. updated phasing
The client brief (Asgard Fleet Tracking App Development Brief) listed nine core mobile functions. The updated Release 1 phasing keeps the tracking core and defers the rest.
| Brief item | Release 1 | Notes |
|---|---|---|
| Secure login and permissions | Yes | Brief demands role-based access and secure sessions |
| Unit list (search, filter, state, ignition, last update) | Yes | Plus group visibility |
| Live map | Yes | Brief: clustering for thousands of units, optional geofence overlay |
| Vehicle summary (speed, ignition, mileage, sensors) | Yes | Fuel level and "recent events" dropped from the R1 wording |
| History and tracks | Yes | |
| Trips, parking | Yes | Brief calls these reports; R1 treats them as screens, not exports |
| Geofences display, visits, dwell | Basic | |
| Selected reports + PDF/CSV export | Deferred | |
| Notifications (view, filter, acknowledge) | Deferred | |
| Location-sharing links | Deferred | Navigation deep links (Google/Apple Maps) are kept |
| Fuel events, brake, TPMS alerts | Deferred | TPMS values can still surface as "available sensors" |
Two brief items are not features but gate Release 1.
24-month history. "Historical trip data access" only works if Asgard holds the data. Today Asgard keeps 180 days and fetches older ranges from Wialon Local on demand. Once Wialon is retired that fallback is gone, so the storage and partitioning work in brief §3 is an R1 dependency, not a later phase.
Devices → Flespi direct. Ingest already consumes Flespi MQTT, so this is mostly device reconfiguration and a channel change, but it gates the Wialon exit criteria and should be planned alongside R1.
2. API shape decision
The existing API is a single graphene GraphQL endpoint at /graphql, authenticated by a static apiToken argument on every query. There is no REST layer, no OpenAPI document, no JWT and no role model.
Recommendation: add a new versioned REST API at /api/v1 for the mobile app, implemented with Django REST Framework and drf-spectacular so the Swagger document is generated from code, and leave GraphQL untouched for the web platform.
- The brief explicitly asks for "secure, versioned APIs". Path versioning plus generated OpenAPI is the simplest way to prove that.
- The mobile team gets typed client generation straight from the spec.
- Both APIs sit on the same Django models and a shared service layer (trip engine, status calculator, visibility filter), so nothing is duplicated except the transport.
- The alternative, bolting JWT and per-resolver access checks onto GraphQL, touches over a hundred resolvers and still leaves the web app's token model unchanged.
Endpoint summary (24 paths)
| Area | Endpoints |
|---|---|
| Auth | POST /auth/login · POST /auth/refresh · POST /auth/logout · GET /auth/me |
| Groups | GET /groups |
| Vehicles | GET /vehicles · GET /vehicles/{id} · GET /vehicles/{id}/status · GET /vehicles/{id}/sensors · GET /vehicles/{id}/navigation |
| Live map | GET /live (bulk, delta via since, bbox and clustering) · GET /live/stream (SSE, optional) |
| History | GET /vehicles/{id}/track · GET /vehicles/{id}/timeline |
| Trips | GET /vehicles/{id}/trips · GET /vehicles/{id}/stops · GET /trips/{id} |
| Geofences | GET /geofences · GET /geofences/{id} · GET /geofences/{id}/visits · GET /vehicles/{id}/geofences · GET /vehicles/{id}/geofence-visits |
| System | GET /health · GET /config (feature flags, minimum app version, poll interval) |
3. What exists today, per Release 1 item
3.1 Secure login and permissions Extendlarge
| Exists | Gap |
|---|---|
operative.User with bcrypt password, login mutation | No JWT or refresh, no expiry. Static plaintext api_token in the database, generated with random.choice |
Separate Admin, Driver, Mechanic login tables | No role or permission model. "Role" is which table you are in |
Visibility = User.units M2M (flattened from Wialon groups by sync_users) | Group.users does not grant visibility. Decide whether R1 visibility is unit-list or group-based |
ApiToken for integrations | Access checks are inconsistent: historicMessages, historicTrips, generateLocator and gpsHealthReport never verify the caller can see the unit |
Driver.firebase_token plus FCM sender | No push registration for fleet-manager users (fine for R1, needed for R2) |
Work: JWT access and refresh tokens (rotation, revocation list), a me endpoint, a role and permission enum, one visible_units(user) queryset used by every R1 endpoint, and rate limiting on login.
3.2 Vehicle list, search, groups, live status Extendmedium
| Exists | Gap |
|---|---|
entities.Unit (name, plate, ident, type, drivers, groups) | No make, model or VIN fields, only a custom_fields JSON blob |
telemetry.LastMessage, one per unit, trigger-maintained: position, payload, sensors, odometer, geofence ids, last movement time | No stored moving / stopped / parked / offline state. Ignition is only derivable from payload['engine.ignition.status'] |
units query with search and pagination (raw SQL) | Search covers name and plate only. No driver search, no status filter |
groups query | Group membership exposed as ids only |
StationaryState table (trigger-maintained) | Not exposed anywhere |
DriverLinkLog for the current driver | Fine to reuse |
Work: a motion-state calculator (speed, ignition and a staleness threshold from config), promote ignition, fuel and speed to real columns or a materialised view on the last-message table, and a list endpoint with status and group filters plus cursor paging.
3.3 Live fleet map Extendmedium
| Exists | Gap |
|---|---|
| The last-message table gives every unit's last position in one query | No bounding-box filter, no delta (since), no clustering |
SSE endpoint /telemetry/stream on Postgres LISTEN notifyupdate | Broken. The notify-handling loop sits after a return, so clients only ever receive heartbeats (telemetry/views.py:81-85) |
Work: a /live endpoint with a since delta and server-side clustering (PostGIS cluster function or a grid at low zoom). Fix or rewrite SSE, which is optional for R1 since polling every ten seconds is acceptable at fleet sizes in the hundreds.
3.4 Vehicle details: speed, ignition, last update, mileage, sensors Reusesmall to medium
| Exists | Gap |
|---|---|
| Last-message resolvers for speed, ignition, mileage, fuel, RPM, voltage | All read raw Flespi keys from JSON with no unit normalisation |
Sensor config (formula or table per unit) loaded from Excel | Values are never evaluated server-side, so the app would receive raw parameters |
| Extensive TPMS, EBPMS, DTC and overweight models | Trailer-focused, but fine to expose as sensor values with categories |
DailyOdometer, odometerHistoric | Reuse for "today" mileage |
Work: a sensor-value normaliser (key, label, unit, category, updated time) applied to the last message.
3.5 Navigate in Google or Apple Maps Newtrivial
Nothing exists. The endpoint formats the last position into deep links.
3.6 Route history and track replay Extendmedium
| Exists | Gap |
|---|---|
historicMessages(unitId, from, to) returns raw points | No access check, no pagination, no simplification, no ignition per point |
Daily partitions on telemetry_message, PostGIS point column | 180-day retention. Older data is fetched live from Wialon (wialon_fetch.py) |
Work: a paged, simplified track endpoint (Douglas-Peucker via PostGIS), an enforced maximum range, and the 24-month retention decision in section 4.
3.7 Trips, stops, parking, journey details Extendlarge
| Exists | Gap |
|---|---|
get_trips.py: on-the-fly trip detection from raw messages (speed above zero starts, 300 s gap ends) | Recomputed on every request, linear in message count. Timezone hard-coded to Europe/Dublin (get_trips.py:112) |
historicTrips query, tripsReport XLSX/PDF | Rejects ranges older than 180 days |
Trip, CurrentTrip tables plus trigger t00015_telemetry_message_trip_handler | Never read by any resolver. Correctness unverified |
StoodStatus (stationary periods with odometer) | Exposed, but the trigger's lifecycle across migrations is unclear |
Work: a persisted trip and stop engine (ignition-aware, idle versus parked, max and average speed, distance, addresses) that runs incrementally on ingest and backfills history. This is the biggest pure-backend item in R1, and it also unlocks the timeline endpoint and later reports.
3.8 Geofence presence, display, visits, dwell Extendmedium
| Exists | Gap |
|---|---|
resources.Geofence with PostGIS shape, polygon / radial / linear, categories, colour | Visibility via a per-user M2M. No group scoping |
Presence per message (geofences_ids) and a live containment query for all units | Presence has no "entered at" timestamp on the last message |
Geofence enter and exit events via trigger into telemetry_event | Events are kept for 14 days in the query layer |
| Visits and dwell computed inside the geofences XLSX report | No visits table, no query, rejected beyond six months |
Work: a geofence-visit table populated from enter and exit events (or derived from transitions in the per-message geofence ids), plus GeoJSON serialisation of the PostGIS shape.
3.9 Historical trip data access (24 months) Newlarge, mostly infrastructure
Today: 180 days local plus the Wialon fallback. Brief: 24 months in Asgard, searchable through the API, without hurting live performance. See section 4.
4. Cross-cutting backend work R1 depends on
Retention to 24 months
Three options, cheapest first.
Grow the existing partitions
Keep daily partitions, raise the cleanup job to about 730 days, add monthly cold-partition compaction and move partitions older than 90 days to a cheaper tablespace. Storage grows roughly fourfold and needs sizing against current partition sizes.
Adopt TimescaleDB
Native compression of ten to twenty times on telemetry and built-in retention policies. A bigger migration, but the strongest footing for two years of positions.
Keep raw six months, derive the rest
Persist trips, stops, visits and daily odometer for 24 months but raw messages for six. Cheapest by far, but track replay beyond six months would be unavailable, which the client must accept.
- Ingest hardening. Production ingest appears to be an external receptor script (
bash/mqttreceptor.shreferences/home/gmadmin/receptor/app.py, which is not in this repo) alongsidemqttretranslator, which self-restarts every 40 minutes. Bring it into the repo and containerise it before device migration. - Access-control audit. One
visible_units(user)helper, applied everywhere, with tests. - Timezone. Store per user (
User.time_zonealready exists) and stop hard-coding Dublin and London. - Public media. Report files are served unauthenticated from
/public/. Out of R1 scope, but worth flagging to the client alongside the security requirements in brief §5.
5. Rough sizing
Backend only, one senior Django developer, excluding the mobile app, device migration and Wialon data migration. Ranges are wide because the trip engine and retention decisions are open. Bars are drawn to a common scale of 0 to 20 days.
| Area | Size | Dev-days | Range |
|---|---|---|---|
| DRF and spectacular scaffolding, error and paging conventions, CI publish of Swagger | S | 3–4 | |
Auth: JWT, refresh, roles and permissions, me, visibility helper, tests | M | 8–12 | |
| Vehicles list, detail, status, sensors; motion state; sensor normaliser | M | 6–9 | |
Live map: /live delta, bbox and clustering (SSE fix optional, add 3) | M | 5–8 | |
| Track: paged and simplified history | S | 3–4 | |
| Trip and stop engine (persisted, incremental, backfill) plus trips, stops, trip detail, timeline | L | 12–18 | |
| Geofences: GeoJSON, presence with entered-at, visits table and endpoints | M | 6–9 | |
| Navigation links, health, config | S | 1–2 | |
| 24-month retention (partition option, including sizing, archive job, restore test) | M–L | 8–15 | |
| Ingest hardening, receptor into the repo | M | 5–8 | |
| Total | 57–89 | About 12–18 weeks for one developer, 7–10 weeks for two |
Reusable as-is with minimal wrapping: Unit, LastMessage, Geofence with PostGIS presence, DailyOdometer, DriverLinkLog, Group, the TPMS models, daily partitioning and its cleanup command, and the Flespi MQTT consumer.
docs/openapi/mobile-v1.yaml.