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:
Bernt
2026-07-02 16:01:29 +00:00
parent 2f0ab18e81
commit 13780baeb8
9 changed files with 470 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
coverage/
*.log
uploads/
+67
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/*.test.ts'],
collectCoverageFrom: ['src/**/*.ts', '!src/**/*.test.ts'],
};
+35
View File
@@ -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"
}
}
+41
View File
@@ -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');
});
});
+178
View File
@@ -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;
+19
View File
@@ -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"]
}