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
| Column | Type | Description |
|---|---|---|
trackingNumber | VARCHAR(50) | Human-readable (e.g. PKS-20250522-ABCD1234) |
estimatedDelivery | TIMESTAMPTZ | Recalculated on every location ping |
lastKnownLocation | JSONB | {lat, lng, name, accuracy} |
lastLocationUpdate | TIMESTAMPTZ | Timestamp of last GPS ping |
tracking_events table
| Column | Type | Description |
|---|---|---|
id | UUID | Primary key |
orderId | UUID FK | References orders |
agentId | UUID FK | References delivery_agents |
type | TrackingEventType | Enum (see below) |
data | JSONB | Event payload |
lat | FLOAT | GPS latitude |
lng | FLOAT | GPS longitude |
accuracy | FLOAT | GPS accuracy in meters |
speed | FLOAT | Speed in m/s |
heading | FLOAT | Direction in degrees |
battery | INT | Device battery percentage |
locationName | VARCHAR | Human-readable location |
correlationId | UUID | End-to-end trace ID |
createdAt | TIMESTAMPTZ | Auto-set |
TrackingEventType enum
| Value | Triggered by |
|---|---|
LOCATION_UPDATE | Agent GPS ping |
STATUS_CHANGE | Order status transition |
PICKUP | Agent confirms pickup |
ARRIVING | Geofence threshold crossed |
DELIVERED | Agent confirms delivery |
FAILED_ATTEMPT | Delivery 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:
- Predict: uncertainty grows by process noise
Q = 1e-5(agent is moving) - Update: Kalman gain
K = P / (P + R)balances prediction vs measurementR= measurement noise derived from the GPSaccuracyfield (metres → degrees)
- 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):
- A
TrackingEventof typeARRIVINGis inserted - The event is published to Redis → forwarded to all WebSocket subscribers
- The notification service is called (internal endpoint) to send a push notification to the buyer
- 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
| Role | Can access |
|---|---|
PLATFORM_ADMIN | All orders |
BUYER | Orders where orders.userId = requester |
DELIVERY_AGENT | Orders assigned to their agent record |
SHOP_OWNER | Orders containing items from their shops |
FLEET_MANAGER | Orders 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:
- A
tracking_eventrow of typeARRIVINGis inserted - WebSocket subscribers receive
{"type":"arriving",...} - 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:
- Put instances behind a load balancer with sticky sessions (or use
session affinityon the WS upgrade request) - All instances share the same Redis — Pub/Sub fan-out means each instance receives every message and forwards to its own local sockets
- Each instance runs its own Redis subscriber client
- 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.