From 13780baeb8f837a97a5371b8449a14489950707e Mon Sep 17 00:00:00 2001 From: Bernt Date: Thu, 2 Jul 2026 16:01:29 +0000 Subject: [PATCH] =?UTF-8?q?PR-004A:=20Mission=20Import=20API=20=E2=80=94?= =?UTF-8?q?=20MVP-0=20First=20Field=20Upload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- packages/api/.gitignore | 5 + packages/api/README.md | 67 +++++++ .../api/adr/ADR-010-Mission-Import-API.md | 36 ++++ packages/api/jest.config.js | 7 + packages/api/package.json | 35 ++++ packages/api/src/index.ts | 41 ++++ .../api/src/routes/mission-import.test.ts | 82 ++++++++ packages/api/src/routes/mission-import.ts | 178 ++++++++++++++++++ packages/api/tsconfig.json | 19 ++ 9 files changed, 470 insertions(+) create mode 100644 packages/api/.gitignore create mode 100644 packages/api/README.md create mode 100644 packages/api/adr/ADR-010-Mission-Import-API.md create mode 100644 packages/api/jest.config.js create mode 100644 packages/api/package.json create mode 100644 packages/api/src/index.ts create mode 100644 packages/api/src/routes/mission-import.test.ts create mode 100644 packages/api/src/routes/mission-import.ts create mode 100644 packages/api/tsconfig.json diff --git a/packages/api/.gitignore b/packages/api/.gitignore new file mode 100644 index 000000000..2c070051c --- /dev/null +++ b/packages/api/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +coverage/ +*.log +uploads/ diff --git a/packages/api/README.md b/packages/api/README.md new file mode 100644 index 000000000..6a06def07 --- /dev/null +++ b/packages/api/README.md @@ -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. diff --git a/packages/api/adr/ADR-010-Mission-Import-API.md b/packages/api/adr/ADR-010-Mission-Import-API.md new file mode 100644 index 000000000..0a50271e9 --- /dev/null +++ b/packages/api/adr/ADR-010-Mission-Import-API.md @@ -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 diff --git a/packages/api/jest.config.js b/packages/api/jest.config.js new file mode 100644 index 000000000..84e505d9c --- /dev/null +++ b/packages/api/jest.config.js @@ -0,0 +1,7 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: ['**/*.test.ts'], + collectCoverageFrom: ['src/**/*.ts', '!src/**/*.test.ts'], +}; diff --git a/packages/api/package.json b/packages/api/package.json new file mode 100644 index 000000000..e0f2a1df3 --- /dev/null +++ b/packages/api/package.json @@ -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" + } +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts new file mode 100644 index 000000000..c698f96be --- /dev/null +++ b/packages/api/src/index.ts @@ -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; diff --git a/packages/api/src/routes/mission-import.test.ts b/packages/api/src/routes/mission-import.test.ts new file mode 100644 index 000000000..83efcfc52 --- /dev/null +++ b/packages/api/src/routes/mission-import.test.ts @@ -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'); + }); +}); diff --git a/packages/api/src/routes/mission-import.ts b/packages/api/src/routes/mission-import.ts new file mode 100644 index 000000000..a04c2e4d3 --- /dev/null +++ b/packages/api/src/routes/mission-import.ts @@ -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; diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json new file mode 100644 index 000000000..da30b3736 --- /dev/null +++ b/packages/api/tsconfig.json @@ -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"] +}