PR-004A: Mission Import API — MVP-0 First Field Upload
- POST /api/v1/missions/import — multipart upload with video - GET /api/v1/missions/:id — retrieve mission - GET /api/v1/missions — list active missions - Express + multer for file handling - Uses application layer handlers (PR-002.5) - In-memory repositories (swap for PostgreSQL in PR-003B) Acceptance Tests (5/5 passing): ✅ Import mission with video ✅ Reject upload without video ✅ Retrieve mission by ID ✅ 404 for non-existent mission ✅ Health check MVP-0 Definition of Done: ✅ Phone → Upload → Store → Retrieve ✅ No AI required ✅ First real artifact produced Next: PR-005A — Minimal Mission Import UI
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
*.log
|
||||||
|
uploads/
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# @landvex/api
|
||||||
|
|
||||||
|
Mission Import API for LandveX Intelligence Lab.
|
||||||
|
|
||||||
|
## MVP-0: First Field Upload
|
||||||
|
|
||||||
|
Minimal API for importing missions with video files.
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| POST | `/api/v1/missions/import` | Import mission with video |
|
||||||
|
| GET | `/api/v1/missions/:id` | Get mission by ID |
|
||||||
|
| GET | `/api/v1/missions` | List active missions |
|
||||||
|
| GET | `/health` | Health check |
|
||||||
|
|
||||||
|
### Import Mission
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3000/api/v1/missions/import \
|
||||||
|
-F "inspector=erik_svensson" \
|
||||||
|
-F "location[lat]=59.3293" \
|
||||||
|
-F "location[lng]=18.0686" \
|
||||||
|
-F "device[model]=iPhone14,2" \
|
||||||
|
-F "device[os]=iOS 17.0" \
|
||||||
|
-F "device[appVersion]=1.0.0" \
|
||||||
|
-F "video=@/path/to/video.mp4"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"missionId": "mission_20260702_000001",
|
||||||
|
"sessionId": "session_20260702_000001",
|
||||||
|
"artifactId": "artifact_000001",
|
||||||
|
"status": "created",
|
||||||
|
"file": {
|
||||||
|
"originalName": "video.mp4",
|
||||||
|
"size": 12345678,
|
||||||
|
"path": "uploads/abc123.mp4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Express Router → Application Handlers → Domain → In-Memory Repositories
|
||||||
|
```
|
||||||
|
|
||||||
|
**No AI. No processing. Just store and track.**
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- `@landvex/application` — use cases
|
||||||
|
- `@landvex/infrastructure` — in-memory repositories (swap for PostgreSQL in PR-003B)
|
||||||
|
- `@landvex/domain` — domain objects
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
5 tests verifying MVP-0 acceptance criteria.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# ADR-010: Mission Import API (MVP-0)
|
||||||
|
|
||||||
|
## Status
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
We need the first external interface for LandveX Intelligence Lab. The goal is to prove the end-to-end flow: phone → upload → store → retrieve.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
Implement minimal Mission Import API:
|
||||||
|
|
||||||
|
- POST /api/v1/missions/import — upload video, create mission
|
||||||
|
- GET /api/v1/missions/:id — retrieve mission
|
||||||
|
- No AI, no processing, just store and track
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Express Router → Application Handlers → Domain → In-Memory Repositories
|
||||||
|
```
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
- First real API endpoint
|
||||||
|
- Proves end-to-end flow works
|
||||||
|
- Can be tested with real phone uploads
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
- In-memory storage (will swap for PostgreSQL)
|
||||||
|
- No authentication yet
|
||||||
|
- No file validation beyond extension
|
||||||
|
|
||||||
|
## Related
|
||||||
|
- ADR-007: Command/Result Pattern
|
||||||
|
- ADR-009: Persistence Independence
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
module.exports = {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
roots: ['<rootDir>/src'],
|
||||||
|
testMatch: ['**/*.test.ts'],
|
||||||
|
collectCoverageFrom: ['src/**/*.ts', '!src/**/*.test.ts'],
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "@landvex/api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Mission Import API for LandveX Intelligence Lab",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"dev": "ts-node src/index.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@landvex/domain": "file:../domain",
|
||||||
|
"@landvex/infrastructure": "file:../infrastructure",
|
||||||
|
"@landvex/application": "file:../application",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"helmet": "^7.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/multer": "^1.4.11",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/jest": "^29.5.0",
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"jest": "^29.5.0",
|
||||||
|
"ts-jest": "^29.1.0",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.3.0",
|
||||||
|
"supertest": "^6.3.3",
|
||||||
|
"@types/supertest": "^6.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* LandveX Intelligence Lab API
|
||||||
|
*
|
||||||
|
* MVP-0: First Field Upload
|
||||||
|
*
|
||||||
|
* Minimal API for mission import.
|
||||||
|
* No AI. No processing. Just store and track.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
|
||||||
|
import missionImportRoutes from './routes/mission-import';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(helmet());
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
app.use('/api/v1/missions', missionImportRoutes);
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get('/health', (_req, res) => {
|
||||||
|
res.json({ status: 'ok', version: '0.1.0-mvp0' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
if (require.main === module) {
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`🚀 LandveX API running on port ${PORT}`);
|
||||||
|
console.log(`📹 Mission Import: POST /api/v1/missions/import`);
|
||||||
|
console.log(`🏥 Health Check: GET /health`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default app;
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Mission Import API Tests
|
||||||
|
*
|
||||||
|
* Acceptance Test for MVP-0: First Field Upload
|
||||||
|
*
|
||||||
|
* Verifies:
|
||||||
|
* - POST /api/v1/missions/import creates mission with video
|
||||||
|
* - GET /api/v1/missions/:id retrieves mission
|
||||||
|
* - No AI, no processing, just store and track
|
||||||
|
*/
|
||||||
|
|
||||||
|
import request from 'supertest';
|
||||||
|
import app from '../index';
|
||||||
|
|
||||||
|
describe('Mission Import API (MVP-0)', () => {
|
||||||
|
it('should import a mission with video', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/v1/missions/import')
|
||||||
|
.field('inspector', 'erik_svensson')
|
||||||
|
.field('location[lat]', '59.3293')
|
||||||
|
.field('location[lng]', '18.0686')
|
||||||
|
.field('device[model]', 'iPhone14,2')
|
||||||
|
.field('device[os]', 'iOS 17.0')
|
||||||
|
.field('device[appVersion]', '1.0.0')
|
||||||
|
.attach('video', Buffer.from('fake video content'), 'test-video.mp4');
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(response.body.missionId).toBeDefined();
|
||||||
|
expect(response.body.sessionId).toBeDefined();
|
||||||
|
expect(response.body.artifactId).toBeDefined();
|
||||||
|
expect(response.body.status).toBe('created');
|
||||||
|
expect(response.body.file.originalName).toBe('test-video.mp4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject upload without video', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/v1/missions/import')
|
||||||
|
.field('inspector', 'erik_svensson')
|
||||||
|
.field('location[lat]', '59.3293')
|
||||||
|
.field('location[lng]', '18.0686');
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error).toBe('Video file required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should retrieve mission by id', async () => {
|
||||||
|
// First create a mission
|
||||||
|
const createResponse = await request(app)
|
||||||
|
.post('/api/v1/missions/import')
|
||||||
|
.field('inspector', 'erik_svensson')
|
||||||
|
.field('location[lat]', '59.3293')
|
||||||
|
.field('location[lng]', '18.0686')
|
||||||
|
.field('device[model]', 'iPhone14,2')
|
||||||
|
.field('device[os]', 'iOS 17.0')
|
||||||
|
.field('device[appVersion]', '1.0.0')
|
||||||
|
.attach('video', Buffer.from('fake video content'), 'test-video.mp4');
|
||||||
|
|
||||||
|
const missionId = createResponse.body.missionId;
|
||||||
|
|
||||||
|
// Then retrieve it
|
||||||
|
const getResponse = await request(app)
|
||||||
|
.get(`/api/v1/missions/${missionId}`);
|
||||||
|
|
||||||
|
expect(getResponse.status).toBe(200);
|
||||||
|
expect(getResponse.body.id).toBe(missionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for non-existent mission', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/api/v1/missions/nonexistent_123');
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return health check', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/health');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.status).toBe('ok');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* Mission Import API
|
||||||
|
*
|
||||||
|
* Minimal API for importing missions with video files.
|
||||||
|
* MVP-0: First Field Upload
|
||||||
|
*
|
||||||
|
* POST /api/v1/missions/import
|
||||||
|
* - Accepts multipart/form-data
|
||||||
|
* - Creates FieldSession + Mission + Artifact
|
||||||
|
* - Returns mission ID
|
||||||
|
*
|
||||||
|
* No AI. No processing. Just store and track.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import multer from 'multer';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CreateFieldSessionHandler,
|
||||||
|
CreateMissionHandler,
|
||||||
|
CreateFieldSessionCommand,
|
||||||
|
CreateMissionCommand,
|
||||||
|
} from '@landvex/application';
|
||||||
|
|
||||||
|
import {
|
||||||
|
InMemoryFieldSessionRepository,
|
||||||
|
InMemoryMissionRepository,
|
||||||
|
InMemoryArtifactRegistry,
|
||||||
|
} from '@landvex/infrastructure';
|
||||||
|
|
||||||
|
import { IdFactory, ArtifactType } from '@landvex/domain';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// In-memory repositories for MVP-0
|
||||||
|
// TODO: Swap for PostgreSQL in PR-003B
|
||||||
|
const sessions = new InMemoryFieldSessionRepository();
|
||||||
|
const missions = new InMemoryMissionRepository();
|
||||||
|
const artifacts = new InMemoryArtifactRegistry();
|
||||||
|
|
||||||
|
const createSession = new CreateFieldSessionHandler(sessions);
|
||||||
|
const createMission = new CreateMissionHandler(missions, sessions);
|
||||||
|
|
||||||
|
// File upload config
|
||||||
|
const upload = multer({
|
||||||
|
dest: 'uploads/',
|
||||||
|
limits: {
|
||||||
|
fileSize: 500 * 1024 * 1024, // 500MB max
|
||||||
|
},
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
const allowed = ['.mp4', '.mov', '.avi', '.mkv'];
|
||||||
|
const ext = path.extname(file.originalname).toLowerCase();
|
||||||
|
if (allowed.includes(ext)) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error('Only video files allowed'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/missions/import
|
||||||
|
*
|
||||||
|
* Body (multipart/form-data):
|
||||||
|
* - inspector: string
|
||||||
|
* - location[lat]: number
|
||||||
|
* - location[lng]: number
|
||||||
|
* - device[model]: string
|
||||||
|
* - device[os]: string
|
||||||
|
* - device[appVersion]: string
|
||||||
|
* - video: File
|
||||||
|
*/
|
||||||
|
router.post('/import', upload.single('video'), async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { inspector, location, device } = req.body;
|
||||||
|
const videoFile = req.file;
|
||||||
|
|
||||||
|
if (!videoFile) {
|
||||||
|
return res.status(400).json({ error: 'Video file required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Create FieldSession
|
||||||
|
const sessionResult = await createSession.execute({
|
||||||
|
inspector,
|
||||||
|
date: new Date(),
|
||||||
|
location: {
|
||||||
|
lat: parseFloat(location.lat),
|
||||||
|
lng: parseFloat(location.lng),
|
||||||
|
},
|
||||||
|
areaId: 'pilot_area_001',
|
||||||
|
} as CreateFieldSessionCommand);
|
||||||
|
|
||||||
|
if (!sessionResult.success) {
|
||||||
|
return res.status(400).json({ error: sessionResult.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = sessionResult.data.session;
|
||||||
|
|
||||||
|
// Step 2: Create Mission
|
||||||
|
const missionResult = await createMission.execute({
|
||||||
|
sessionId: session.id,
|
||||||
|
location: {
|
||||||
|
lat: parseFloat(location.lat),
|
||||||
|
lng: parseFloat(location.lng),
|
||||||
|
},
|
||||||
|
device: {
|
||||||
|
model: device.model || 'Unknown',
|
||||||
|
os: device.os || 'Unknown',
|
||||||
|
appVersion: device.appVersion || '1.0',
|
||||||
|
},
|
||||||
|
} as CreateMissionCommand);
|
||||||
|
|
||||||
|
if (!missionResult.success) {
|
||||||
|
return res.status(400).json({ error: missionResult.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
const mission = missionResult.data.mission;
|
||||||
|
|
||||||
|
// Step 3: Register Artifact (video file)
|
||||||
|
const artifactId = IdFactory.artifact(1);
|
||||||
|
await artifacts.register({
|
||||||
|
id: artifactId,
|
||||||
|
type: ArtifactType.VIDEO,
|
||||||
|
version: { major: 1, minor: 0, patch: 0 },
|
||||||
|
hash: { algorithm: 'sha256', value: 'pending' }, // TODO: Calculate hash
|
||||||
|
createdBy: inspector,
|
||||||
|
storageUri: {
|
||||||
|
protocol: 'file',
|
||||||
|
path: videoFile.path,
|
||||||
|
},
|
||||||
|
lineage: [artifactId],
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return mission info
|
||||||
|
return res.status(201).json({
|
||||||
|
missionId: mission.id,
|
||||||
|
sessionId: session.id,
|
||||||
|
artifactId,
|
||||||
|
status: 'created',
|
||||||
|
file: {
|
||||||
|
originalName: videoFile.originalname,
|
||||||
|
size: videoFile.size,
|
||||||
|
path: videoFile.path,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Mission import failed:', error);
|
||||||
|
return res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/missions/:id
|
||||||
|
*
|
||||||
|
* Retrieve mission by ID
|
||||||
|
*/
|
||||||
|
router.get('/:id', async (req: Request, res: Response) => {
|
||||||
|
const mission = await missions.findById(req.params.id as any);
|
||||||
|
if (!mission) {
|
||||||
|
return res.status(404).json({ error: 'Mission not found' });
|
||||||
|
}
|
||||||
|
return res.json(mission);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/missions
|
||||||
|
*
|
||||||
|
* List all missions
|
||||||
|
*/
|
||||||
|
router.get('/', async (_req: Request, res: Response) => {
|
||||||
|
const allMissions = await missions.findActive();
|
||||||
|
return res.json(allMissions);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user