Add Docker build workflow and mobile application
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
# Use Node.js 18 alpine image for smaller size
|
||||
FROM node:18-alpine
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package.json and package-lock.json
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install --production
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Expose port (default 5001, can be overridden)
|
||||
EXPOSE 5001
|
||||
|
||||
# Start the application
|
||||
CMD ["npm", "start"]
|
||||
@@ -0,0 +1,247 @@
|
||||
# Transit API
|
||||
|
||||
Transit API voor het beheren van busroutes, navigatie en GPS tracking voor busbestuurders.
|
||||
|
||||
## Project Structuur
|
||||
|
||||
```
|
||||
transit_api/
|
||||
├── index.js # Main API endpoints
|
||||
├── middleware.js # JWT authentication middleware
|
||||
├── db.js # PostgreSQL connection
|
||||
├── schema.sql # Database schema setup
|
||||
├── package.json # Dependencies
|
||||
└── .env # Environment variables
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Dependencies installeren
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Database Schema opzetten
|
||||
|
||||
Voer het volgende uit in PostgreSQL:
|
||||
|
||||
```bash
|
||||
# Via psql command line
|
||||
psql -U your_user -d your_database -f schema.sql
|
||||
```
|
||||
|
||||
Of kopieer-plak de SQL queries rechtstreeks in pgAdmin.
|
||||
|
||||
### 3. Environment variabelen (.env)
|
||||
|
||||
```
|
||||
PG_HOST=localhost
|
||||
PG_PORT=5432
|
||||
PG_USER=your_user
|
||||
PG_PASS=your_password
|
||||
PG_DB=your_database
|
||||
JWT_SECRET=your_jwt_secret
|
||||
PORT=5001
|
||||
```
|
||||
|
||||
### 4. API starten
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
API draait op `http://localhost:5001`
|
||||
|
||||
## API Endpoints
|
||||
|
||||
Alle endpoints vereisen een `Authorization: Bearer {token}` header.
|
||||
|
||||
### Schedule
|
||||
|
||||
**GET** `/schedule/daily?date=2024-05-05`
|
||||
- Ophalen van dagplanning voor chauffeur
|
||||
- Response:
|
||||
```json
|
||||
{
|
||||
"date": "2024-05-05",
|
||||
"routes": [
|
||||
{
|
||||
"id": 1,
|
||||
"startTime": "2024-05-05T08:00:00Z",
|
||||
"status": "scheduled",
|
||||
"busLine": {
|
||||
"lineNumber": 1,
|
||||
"startStop": "Central Station",
|
||||
"endStop": "Airport"
|
||||
},
|
||||
"busType": {
|
||||
"name": "Standard Bus",
|
||||
"seatCapacity": 50,
|
||||
"licensePlate": "BUS-001"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**GET** `/routes/:routeId`
|
||||
- Details van een specifieke route
|
||||
- Response: `{ "route": {...} }`
|
||||
|
||||
### Navigation
|
||||
|
||||
**POST** `/navigation/route`
|
||||
- Opslaan van berekende route van OSRM
|
||||
- Body:
|
||||
```json
|
||||
{
|
||||
"routeId": 1,
|
||||
"startLat": 52.5200,
|
||||
"startLon": 13.4050,
|
||||
"endLat": 52.5300,
|
||||
"endLon": 13.4150,
|
||||
"totalDistance": 15000,
|
||||
"totalDuration": 900,
|
||||
"geojson": {
|
||||
"type": "LineString",
|
||||
"coordinates": [[13.4050, 52.5200], ...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**GET** `/bus-lines/:busLineId/stops`
|
||||
- Alle stops van een buslijn (voor navigatie waypoints)
|
||||
- Response:
|
||||
```json
|
||||
{
|
||||
"stops": [
|
||||
{
|
||||
"id": 1,
|
||||
"order": 1,
|
||||
"name": "Central Station",
|
||||
"latitude": 52.5200,
|
||||
"longitude": 13.4050
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### GPS Tracking
|
||||
|
||||
**POST** `/gps/track`
|
||||
- Real-time positie opslaan
|
||||
- Body:
|
||||
```json
|
||||
{
|
||||
"routeId": 1,
|
||||
"latitude": 52.5210,
|
||||
"longitude": 13.4060,
|
||||
"speed": 45.5,
|
||||
"heading": 180,
|
||||
"accuracy": 5.0
|
||||
}
|
||||
```
|
||||
|
||||
**GET** `/gps/route/:routeId`
|
||||
- Alle opgeslagen positie punten van een route
|
||||
- Response:
|
||||
```json
|
||||
{
|
||||
"positions": [
|
||||
{
|
||||
"id": 1,
|
||||
"latitude": 52.5210,
|
||||
"longitude": 13.4060,
|
||||
"speed": 45.5,
|
||||
"heading": 180,
|
||||
"recordedAt": "2024-05-05T08:15:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Route Status
|
||||
|
||||
**PATCH** `/routes/:routeId/status`
|
||||
- Update route status
|
||||
- Body: `{ "status": "in_progress" }`
|
||||
- Valid statuses: `scheduled`, `in_progress`, `completed`, `cancelled`
|
||||
|
||||
## Database Schema
|
||||
|
||||
### bus_types
|
||||
- `id` - Primary key
|
||||
- `name` - Bus type name (Standard Bus, etc.)
|
||||
- `seat_capacity` - Number of seats
|
||||
- `license_plate` - Unique license plate
|
||||
|
||||
### bus_lines
|
||||
- `id` - Primary key
|
||||
- `line_number` - Unique line number (1, 5, 12, etc.)
|
||||
- `start_stop` - Start stop name
|
||||
- `end_stop` - End stop name
|
||||
- `estimated_duration_minutes` - Expected route duration
|
||||
|
||||
### routes
|
||||
- `id` - Primary key
|
||||
- `user_id` - Chauffeur (references workers.users)
|
||||
- `bus_line_id` - Which line (references bus_lines)
|
||||
- `bus_type_id` - Which bus (references bus_types)
|
||||
- `start_time` - Scheduled start
|
||||
- `status` - scheduled/in_progress/completed/cancelled
|
||||
|
||||
### navigation_routes
|
||||
- `id` - Primary key
|
||||
- `route_id` - Link to route (unique)
|
||||
- `polyline_geojson` - GeoJSON LineString van de route
|
||||
- `total_distance_meters` - Route distance
|
||||
- `total_duration_seconds` - Estimated duration
|
||||
|
||||
### gps_tracking
|
||||
- `id` - Primary key
|
||||
- `route_id` - Which route
|
||||
- `latitude`, `longitude` - Current position
|
||||
- `speed_kmh` - Current speed
|
||||
- `recorded_at` - Timestamp
|
||||
|
||||
## Integration met Flutter App
|
||||
|
||||
```dart
|
||||
// In schedule_service.dart
|
||||
|
||||
// Save navigation route
|
||||
await scheduleService.saveNavigationRoute(
|
||||
routeId: 1,
|
||||
navRoute: navigationRoute,
|
||||
);
|
||||
|
||||
// Track GPS
|
||||
await scheduleService.trackGPS(
|
||||
routeId: 1,
|
||||
currentPos: navigationPoint,
|
||||
speed: 45.5,
|
||||
heading: 180,
|
||||
);
|
||||
|
||||
// Update route status
|
||||
await scheduleService.updateRouteStatus(1, 'in_progress');
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Alle endpoints retourneren error responses:
|
||||
```json
|
||||
{
|
||||
"error": "error_code",
|
||||
"details": "More information"
|
||||
}
|
||||
```
|
||||
|
||||
Common errors:
|
||||
- `missing_token` - No Authorization header
|
||||
- `invalid_token` - Token verification failed
|
||||
- `missing_required_fields` - Required data missing
|
||||
- `invalid_status` - Invalid status value
|
||||
- `route_not_found` - Route not found
|
||||
- `unauthorized` - User doesn't own this resource
|
||||
+331
-2
@@ -1,5 +1,334 @@
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const cors = require('cors');
|
||||
require('dotenv').config();
|
||||
|
||||
const app = express();
|
||||
const db = require('./db');
|
||||
const { authMiddleware } = require('./middleware');
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(cors());
|
||||
|
||||
// ============================================
|
||||
// ROUTES - Get daily schedule
|
||||
// ============================================
|
||||
app.get('/schedule/daily', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { date } = req.query;
|
||||
const userId = req.user.userId;
|
||||
|
||||
if (!date) {
|
||||
return res.status(400).json({ error: 'date query parameter required' });
|
||||
}
|
||||
|
||||
const result = await db.query(`
|
||||
SELECT
|
||||
r.id,
|
||||
r.start_time,
|
||||
r.end_time,
|
||||
r.status,
|
||||
bl.id as line_id,
|
||||
bl.line_number,
|
||||
bl.start_stop,
|
||||
bl.end_stop,
|
||||
bl.estimated_duration_minutes,
|
||||
bl.description,
|
||||
bt.id as bus_type_id,
|
||||
bt.name as bus_type,
|
||||
bt.seat_capacity,
|
||||
bt.license_plate
|
||||
FROM routes r
|
||||
JOIN bus_lines bl ON r.bus_line_id = bl.id
|
||||
JOIN bus_types bt ON r.bus_type_id = bt.id
|
||||
WHERE r.user_id = $1 AND DATE(r.start_time) = $2
|
||||
ORDER BY r.start_time ASC
|
||||
`, [userId, date]);
|
||||
|
||||
const routes = result.rows.map(row => ({
|
||||
id: row.id,
|
||||
startTime: row.start_time,
|
||||
endTime: row.end_time,
|
||||
status: row.status,
|
||||
busLine: {
|
||||
id: row.line_id,
|
||||
lineNumber: row.line_number,
|
||||
startStop: row.start_stop,
|
||||
endStop: row.end_stop,
|
||||
estimatedDuration: row.estimated_duration_minutes,
|
||||
description: row.description,
|
||||
},
|
||||
busType: {
|
||||
id: row.bus_type_id,
|
||||
name: row.bus_type,
|
||||
seatCapacity: row.seat_capacity,
|
||||
licensePlate: row.license_plate,
|
||||
},
|
||||
}));
|
||||
|
||||
res.json({ date, routes });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'internal_error', details: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// ROUTES - Get single route details
|
||||
// ============================================
|
||||
app.get('/routes/:routeId', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { routeId } = req.params;
|
||||
const userId = req.user.userId;
|
||||
|
||||
const result = await db.query(`
|
||||
SELECT
|
||||
r.id,
|
||||
r.start_time,
|
||||
r.end_time,
|
||||
r.status,
|
||||
bl.id as line_id,
|
||||
bl.line_number,
|
||||
bl.start_stop,
|
||||
bl.end_stop,
|
||||
bl.estimated_duration_minutes,
|
||||
bl.description,
|
||||
bt.id as bus_type_id,
|
||||
bt.name as bus_type,
|
||||
bt.seat_capacity,
|
||||
bt.license_plate
|
||||
FROM routes r
|
||||
JOIN bus_lines bl ON r.bus_line_id = bl.id
|
||||
JOIN bus_types bt ON r.bus_type_id = bt.id
|
||||
WHERE r.id = $1 AND r.user_id = $2
|
||||
`, [routeId, userId]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'route_not_found' });
|
||||
}
|
||||
|
||||
const row = result.rows[0];
|
||||
const route = {
|
||||
id: row.id,
|
||||
startTime: row.start_time,
|
||||
endTime: row.end_time,
|
||||
status: row.status,
|
||||
busLine: {
|
||||
id: row.line_id,
|
||||
lineNumber: row.line_number,
|
||||
startStop: row.start_stop,
|
||||
endStop: row.end_stop,
|
||||
estimatedDuration: row.estimated_duration_minutes,
|
||||
description: row.description,
|
||||
},
|
||||
busType: {
|
||||
id: row.bus_type_id,
|
||||
name: row.bus_type,
|
||||
seatCapacity: row.seat_capacity,
|
||||
licensePlate: row.license_plate,
|
||||
},
|
||||
};
|
||||
|
||||
res.json({ route });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'internal_error', details: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// ROUTES - Get stops for a bus line
|
||||
// ============================================
|
||||
app.get('/bus-lines/:busLineId/stops', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { busLineId } = req.params;
|
||||
|
||||
const result = await db.query(`
|
||||
SELECT
|
||||
id,
|
||||
stop_order,
|
||||
stop_name,
|
||||
latitude,
|
||||
longitude,
|
||||
estimated_arrival_minutes
|
||||
FROM route_stops
|
||||
WHERE bus_line_id = $1
|
||||
ORDER BY stop_order ASC
|
||||
`, [busLineId]);
|
||||
|
||||
const stops = result.rows.map(row => ({
|
||||
id: row.id,
|
||||
order: row.stop_order,
|
||||
name: row.stop_name,
|
||||
latitude: parseFloat(row.latitude),
|
||||
longitude: parseFloat(row.longitude),
|
||||
estimatedArrivalMinutes: row.estimated_arrival_minutes,
|
||||
}));
|
||||
|
||||
res.json({ stops });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'internal_error', details: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// NAVIGATION - Save calculated route
|
||||
// ============================================
|
||||
app.post('/navigation/route', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
routeId,
|
||||
startLat,
|
||||
startLon,
|
||||
endLat,
|
||||
endLon,
|
||||
totalDistance,
|
||||
totalDuration,
|
||||
geojson,
|
||||
} = req.body;
|
||||
|
||||
if (!routeId || !startLat || !startLon || !endLat || !endLon || !totalDistance || !totalDuration) {
|
||||
return res.status(400).json({ error: 'missing_required_fields' });
|
||||
}
|
||||
|
||||
const result = await db.query(`
|
||||
INSERT INTO navigation_routes
|
||||
(route_id, start_lat, start_lon, end_lat, end_lon,
|
||||
total_distance_meters, total_duration_seconds, polyline_geojson)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (route_id) DO UPDATE SET
|
||||
start_lat = $2,
|
||||
start_lon = $3,
|
||||
end_lat = $4,
|
||||
end_lon = $5,
|
||||
total_distance_meters = $6,
|
||||
total_duration_seconds = $7,
|
||||
polyline_geojson = $8
|
||||
RETURNING *
|
||||
`, [
|
||||
routeId,
|
||||
startLat,
|
||||
startLon,
|
||||
endLat,
|
||||
endLon,
|
||||
totalDistance,
|
||||
totalDuration,
|
||||
JSON.stringify(geojson),
|
||||
]);
|
||||
|
||||
res.json({ route: result.rows[0] });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'internal_error', details: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// GPS TRACKING - Record current position
|
||||
// ============================================
|
||||
app.post('/gps/track', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { routeId, latitude, longitude, speed, heading, accuracy } = req.body;
|
||||
|
||||
if (!routeId || !latitude || !longitude) {
|
||||
return res.status(400).json({ error: 'missing_required_fields' });
|
||||
}
|
||||
|
||||
await db.query(`
|
||||
INSERT INTO gps_tracking
|
||||
(route_id, latitude, longitude, speed_kmh, heading, accuracy, recorded_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
||||
`, [routeId, latitude, longitude, speed || null, heading || null, accuracy || null]);
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'internal_error', details: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// GPS TRACKING - Get route history
|
||||
// ============================================
|
||||
app.get('/gps/route/:routeId', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { routeId } = req.params;
|
||||
const userId = req.user.userId;
|
||||
|
||||
// Verify user owns this route
|
||||
const routeCheck = await db.query(
|
||||
'SELECT id FROM routes WHERE id = $1 AND user_id = $2',
|
||||
[routeId, userId]
|
||||
);
|
||||
|
||||
if (routeCheck.rows.length === 0) {
|
||||
return res.status(403).json({ error: 'unauthorized' });
|
||||
}
|
||||
|
||||
const result = await db.query(`
|
||||
SELECT
|
||||
id,
|
||||
latitude,
|
||||
longitude,
|
||||
speed_kmh,
|
||||
heading,
|
||||
accuracy,
|
||||
recorded_at
|
||||
FROM gps_tracking
|
||||
WHERE route_id = $1
|
||||
ORDER BY recorded_at ASC
|
||||
`, [routeId]);
|
||||
|
||||
const positions = result.rows.map(row => ({
|
||||
id: row.id,
|
||||
latitude: parseFloat(row.latitude),
|
||||
longitude: parseFloat(row.longitude),
|
||||
speed: row.speed_kmh,
|
||||
heading: row.heading,
|
||||
accuracy: row.accuracy,
|
||||
recordedAt: row.recorded_at,
|
||||
}));
|
||||
|
||||
res.json({ positions });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'internal_error', details: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// ROUTES - Update route status
|
||||
// ============================================
|
||||
app.patch('/routes/:routeId/status', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { routeId } = req.params;
|
||||
const { status } = req.body;
|
||||
const userId = req.user.userId;
|
||||
|
||||
const validStatuses = ['scheduled', 'in_progress', 'completed', 'cancelled'];
|
||||
if (!validStatuses.includes(status)) {
|
||||
return res.status(400).json({ error: 'invalid_status' });
|
||||
}
|
||||
|
||||
const result = await db.query(`
|
||||
UPDATE transit.routes
|
||||
SET status = $1, updated_at = NOW()
|
||||
WHERE id = $2 AND user_id = $3
|
||||
RETURNING *
|
||||
`, [status, routeId, userId]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'route_not_found' });
|
||||
}
|
||||
|
||||
res.json({ route: result.rows[0] });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'internal_error', details: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 5001;
|
||||
app.listen(PORT, () => console.log(`Transit API listening on ${PORT}`));
|
||||
|
||||
module.exports = app;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
require('dotenv').config();
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET;
|
||||
|
||||
// Middleware to verify JWT token
|
||||
const authMiddleware = (req, res, next) => {
|
||||
const auth = req.headers.authorization;
|
||||
if (!auth || !auth.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'missing_token' });
|
||||
}
|
||||
|
||||
const token = auth.slice(7);
|
||||
try {
|
||||
const payload = jwt.verify(token, JWT_SECRET);
|
||||
req.user = payload;
|
||||
next();
|
||||
} catch (err) {
|
||||
return res.status(401).json({ error: 'invalid_token' });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { authMiddleware };
|
||||
@@ -18,6 +18,8 @@
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1",
|
||||
"path": "^0.12.7"
|
||||
"jsonwebtoken": "^9.1.2",
|
||||
"path": "^0.12.7",
|
||||
"pg": "^8.11.3"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user