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