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,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;
|
||||
Reference in New Issue
Block a user