Skip to main content

Delivery Tracking System

Related docs: Delivery Signing · Order Lifecycle · Microservices · Caching Strategy

Real-time delivery tracking for Pakashop — WebSocket-based, horizontally scalable, with Kalman-filtered GPS and automatic geofencing.


Architecture

Agent device

│ POST /api/v1/tracking/location (every 5 s)

Gateway (port 8000)
│ proxy → ws: true

pakashop-tracking (port 3120)

├── Kalman filter (smooth GPS jitter)
├── Haversine ETA calculation
├── Geofence check (400 m radius)
│ └── ARRIVING event → Notification Service → buyer push

├── INSERT tracking_events
├── UPDATE orders (lastKnownLocation, estimatedDelivery)

└── Redis PUBLISH order:{orderId}:location


Redis Pub/Sub


WebSocket fan-out
┌─────┬──────┬────────┐
│ │ │ │
Buyer Vendor Agent Platform
browser browser app Admin

Microservice layout

services/tracking/
├── src/
│ ├── index.js Express + HTTP server, WebSocket attach, graceful shutdown
│ ├── db.js PostgreSQL pool (mirrors notification service)
│ ├── kalman.filter.js 2-D Kalman filter for GPS smoothing
│ ├── tracking.service.js Core business logic
│ ├── ws.service.js WebSocket server + Redis Pub/Sub fan-out
│ └── routes/
│ ├── api.js Public REST (JWT-protected)
│ └── internal.js Internal REST (INTERNAL_API_KEY-protected)
├── package.json
└── .env.example

Database schema

Orders table additions

ColumnTypeDescription
trackingNumberVARCHAR(50)Human-readable (e.g. PKS-20250522-ABCD1234)
estimatedDeliveryTIMESTAMPTZRecalculated on every location ping
lastKnownLocationJSONB{lat, lng, name, accuracy}
lastLocationUpdateTIMESTAMPTZTimestamp of last GPS ping

tracking_events table

ColumnTypeDescription
idUUIDPrimary key
orderIdUUID FKReferences orders
agentIdUUID FKReferences delivery_agents
typeTrackingEventTypeEnum (see below)
dataJSONBEvent payload
latFLOATGPS latitude
lngFLOATGPS longitude
accuracyFLOATGPS accuracy in meters
speedFLOATSpeed in m/s
headingFLOATDirection in degrees
batteryINTDevice battery percentage
locationNameVARCHARHuman-readable location
correlationIdUUIDEnd-to-end trace ID
createdAtTIMESTAMPTZAuto-set

TrackingEventType enum

ValueTriggered by
LOCATION_UPDATEAgent GPS ping
STATUS_CHANGEOrder status transition
PICKUPAgent confirms pickup
ARRIVINGGeofence threshold crossed
DELIVEREDAgent confirms delivery
FAILED_ATTEMPTDelivery failure

REST API

Base path: /api/v1/tracking

All public endpoints require a valid JWT (cookie token or Authorization: Bearer <token>).

POST /location

Agent posts GPS position. Rate-limited to 20 req/min per agent.

Request body

{
"orderId": "uuid",
"lat": -15.4167,
"lng": 28.2833,
"speed": 12.5,
"heading": 270,
"accuracy": 8.0,
"battery": 82,
"locationName": "Cairo Road, Lusaka",
"timestamp": "2025-05-22T10:30:00Z"
}

Response

{
"success": true,
"eventId": "uuid",
"filteredLat": -15.4168,
"filteredLng": 28.2832,
"eta": "2025-05-22T11:15:00Z"
}

GET /:orderId/current

Full tracking snapshot for an order. Redis-cached for 5 seconds.

Response

{
"orderId": "uuid",
"orderNumber": "PKS-20250522-001",
"trackingNumber": "PKS-20250522-ABCD1234",
"status": "OUT_FOR_DELIVERY",
"estimatedDelivery": "2025-05-22T11:15:00Z",
"lastKnownLocation": { "lat": -15.4167, "lng": 28.2833, "name": "Cairo Road" },
"agent": {
"fullName": "John Banda",
"vehicleType": "Motorcycle",
"phone": "+260977000001"
},
"destination": { "lat": -15.3900, "lng": 28.3220 },
"recentEvents": [ ... ]
}

GET /:orderId/history?limit=50

Timeline of tracking events (newest first, max 200).


GET /:orderId/eta

Recalculate ETA. Optionally pass ?destLat=&destLng=&speed= to override.


GET /admin/active

Admin only. All orders currently OUT_FOR_DELIVERY with agent info and last position.


Internal API

Base path: /internal — protected by X-Internal-Key header.

Called by the main backend, never exposed through the gateway.

POST /internal/status-change

{ "orderId": "uuid", "fromStatus": "CONFIRMED", "toStatus": "OUT_FOR_DELIVERY", "correlationId": "uuid" }

POST /internal/delivery-completed

{ "orderId": "uuid", "deliveredAt": "ISO", "signature": "base64", "photo": "url" }

POST /internal/pickup-confirmed

{ "orderId": "uuid", "pickedUpAt": "ISO", "correlationId": "uuid" }

WebSocket protocol

Endpoint: wss://yourdomain.com/api/v1/tracking/ws

Every client must authenticate within 15 seconds of connecting or the connection is terminated.

Client → Server messages

// 1. Authenticate
{ "type": "auth", "token": "<jwt>" }

// 2. Subscribe to an order stream
{ "type": "subscribe", "orderId": "uuid" }

// 3. Unsubscribe
{ "type": "unsubscribe", "orderId": "uuid" }

// 4. Heartbeat (send every ~25 s)
{ "type": "ping" }

Server → Client messages

// Connection greeting
{ "type": "connected", "message": "Send auth to authenticate" }

// Auth response
{ "type": "auth_ack", "status": "ok", "userId": "uuid", "role": "BUYER" }

// Subscription confirmed
{ "type": "subscribed", "orderId": "uuid", "channel": "order:uuid:location" }

// Live location (every ~5 s while agent is moving)
{
"type": "location_update",
"orderId": "uuid",
"correlationId":"uuid",
"filteredLat": -15.4167,
"filteredLng": 28.2833,
"speed": 12.5,
"heading": 270,
"accuracy": 8.0,
"battery": 82,
"locationName": "Cairo Road, Lusaka",
"estimatedDelivery": "2025-05-22T11:15:00Z",
"timestamp": "2025-05-22T10:30:00Z"
}

// Order status changed
{ "type": "status_change", "orderId": "uuid", "fromStatus": "CONFIRMED", "toStatus": "OUT_FOR_DELIVERY" }

// Agent within 400 m of destination
{ "type": "arriving", "orderId": "uuid", "message": "Your order is arriving soon" }

// Delivered
{ "type": "delivery_completed", "orderId": "uuid", "deliveredAt": "ISO" }

// Heartbeat response
{ "type": "pong", "ts": 1716374400000 }

Kalman filter

GPS coordinates from mobile devices contain noise of ±5–50 m depending on signal quality. Without smoothing, the agent marker jumps erratically on the buyer's map.

The filter maintains a separate 1-D Kalman filter for latitude and longitude (valid approximation for short distances). Each filter tracks:

  • State estimate x — best estimate of true position
  • Estimate uncertainty P — how confident we are

On every GPS update:

  1. Predict: uncertainty grows by process noise Q = 1e-5 (agent is moving)
  2. Update: Kalman gain K = P / (P + R) balances prediction vs measurement
    • R = measurement noise derived from the GPS accuracy field (metres → degrees)
  3. Output is filteredLat / filteredLng — used for map display and geofencing

Filter instances are stored per-order in a Map and cleaned up on delivery complete.


Geofencing

On every location update, the service computes the Haversine distance between the agent's filtered position and the order's destination address.

When the distance drops below GEOFENCE_RADIUS_METERS (default 400 m):

  1. A TrackingEvent of type ARRIVING is inserted
  2. The event is published to Redis → forwarded to all WebSocket subscribers
  3. The notification service is called (internal endpoint) to send a push notification to the buyer
  4. The WhatsApp service sends an "arriving soon" message

A 10-minute cooldown prevents duplicate ARRIVING events for the same order.


ETA calculation

distance = haversine(agentLat, agentLng, destLat, destLng) metres
speed = agent reported speed (m/s) OR default 20 km/h (Lusaka urban average)
eta = now + (distance / speed)

The estimatedDelivery column on the order is updated on every GPS ping (non-blocking, fire-and-forget).


Authorization matrix

RoleCan access
PLATFORM_ADMINAll orders
BUYEROrders where orders.userId = requester
DELIVERY_AGENTOrders assigned to their agent record
SHOP_OWNEROrders containing items from their shops
FLEET_MANAGEROrders assigned to their company's sub-agents

Deployment

1. Run database migration

cd services/backend
npx prisma migrate dev --name add_delivery_tracking
# or on production:
npx prisma migrate deploy

2. Install microservice dependencies

cd services/tracking
npm install

3. Configure environment

Copy .env.example to .env and fill in:

PORT=3120
DIRECT_DATABASE_URL=postgresql://user:pass@localhost:5432/pakashop
REDIS_URL=redis://localhost:6379
JWT_SECRET=<same secret as main backend>
INTERNAL_API_KEY=<same key as other microservices>
GEOFENCE_RADIUS_METERS=400
ALLOWED_ORIGINS=https://pakashop.store,https://www.pakashop.store

4. Install and start systemd service

sudo cp systemd/pakashop-tracking.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable pakashop-tracking
sudo systemctl start pakashop-tracking
sudo systemctl status pakashop-tracking

5. Update gateway

Add to services/gateway/src/index.js before the existing /api/v1 proxy:

const { createProxyMiddleware } = require('http-proxy-middleware');

app.use('/api/v1/tracking', createProxyMiddleware({
target: process.env.TRACKING_SERVICE_URL || 'http://localhost:3120',
changeOrigin: true,
ws: true,
headers: { 'X-Internal-Key': process.env.INTERNAL_API_KEY },
pathRewrite: { '^/api/v1/tracking': '' },
}));

Testing

Manual WebSocket test (wscat)

npm install -g wscat

# Connect
wscat -c wss://localhost:8000/api/v1/tracking/ws

# Authenticate
{"type":"auth","token":"<your_jwt>"}

# Subscribe
{"type":"subscribe","orderId":"<order_uuid>"}

# Heartbeat
{"type":"ping"}

Simulate agent location updates

# POST a location update as a delivery agent
curl -X POST https://localhost:8000/api/v1/tracking/location \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <agent_jwt>" \
-d '{
"orderId": "<order_uuid>",
"lat": -15.4167,
"lng": 28.2833,
"speed": 8.3,
"heading": 90,
"accuracy": 12,
"battery": 75,
"locationName": "Cairo Road"
}'

Test geofence trigger

Set GEOFENCE_RADIUS_METERS=5000 temporarily and post a location close to the order destination. Check that:

  1. A tracking_event row of type ARRIVING is inserted
  2. WebSocket subscribers receive {"type":"arriving",...}
  3. The buyer receives a push notification and WhatsApp message

Health check

curl http://localhost:3120/health
# {"status":"ok","service":"pakashop-tracking","websocket":{"connectedClients":3,"activeChannels":2}}

Monitoring

View logs:

journalctl -u pakashop-tracking -f

Restart after deploy:

sudo systemctl restart pakashop-tracking

Check connected WebSocket clients:

curl http://localhost:3120/health | jq .websocket

Horizontal scaling

The service is stateless with respect to application logic — all state lives in PostgreSQL and Redis. To run multiple instances:

  1. Put instances behind a load balancer with sticky sessions (or use session affinity on the WS upgrade request)
  2. All instances share the same Redis — Pub/Sub fan-out means each instance receives every message and forwards to its own local sockets
  3. Each instance runs its own Redis subscriber client
  4. No shared in-process state (Kalman filter instances are per-order per-process; acceptable since GPS updates from one agent will always route to the same instance via sticky sessions)

For internal use only. Do not distribute outside Pakashop engineering.