diff --git a/packages/application/.gitignore b/packages/application/.gitignore new file mode 100644 index 000000000..e6d5efa18 --- /dev/null +++ b/packages/application/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +coverage/ +*.log diff --git a/packages/application/README.md b/packages/application/README.md new file mode 100644 index 000000000..8f64df2b4 --- /dev/null +++ b/packages/application/README.md @@ -0,0 +1,60 @@ +# @landvex/application + +Application layer — orchestrates domain operations. + +## Architecture + +``` +UI → Application Layer (commands/handlers) → Domain → Repository Interfaces → Adapters +``` + +**No business logic here. Only orchestration.** + +## Commands + +| Command | Purpose | +|---------|---------| +| `CreateFieldSessionCommand` | Start a new field session | +| `CreateMissionCommand` | Create a mission within a session | +| `RegisterArtifactCommand` | Register an artifact | +| `CreateObservationCommand` | Create an observation | +| `CreateDecisionCaseCommand` | Create a decision case | +| `ApproveDecisionCommand` | Approve a decision | +| `StartReviewCommand` | Start a review | +| `CompleteReviewCommand` | Complete a review | + +## Handlers + +| Handler | Command | Result | +|---------|---------|--------| +| `CreateFieldSessionHandler` | `CreateFieldSessionCommand` | `Result<{ session }>` | +| `CreateMissionHandler` | `CreateMissionCommand` | `Result<{ mission }>` | +| `CreateDecisionCaseHandler` | `CreateDecisionCaseCommand` | `Result<{ decisionCase }>` | +| `ApproveDecisionHandler` | `ApproveDecisionCommand` | `Result<{ decisionCase }>` | + +## Result Pattern + +```typescript +const result = await handler.execute(command); + +if (result.success) { + // Use result.data +} else { + // Handle result.error +} +``` + +## End-to-End Test + +`use-cases/end-to-end-flow.test.ts` proves the full flow works in memory: + +``` +CreateFieldSession → CreateMission → CreateDecisionCase → ApproveDecision +``` + +Without PostgreSQL, Redis, S3, API, HTTP, or UI. + +## Dependencies + +- `@landvex/domain` — domain objects and factories +- `@landvex/infrastructure` — repository interfaces and in-memory adapters diff --git a/packages/application/adr/ADR-007-Command-Result-Pattern.md b/packages/application/adr/ADR-007-Command-Result-Pattern.md new file mode 100644 index 000000000..83b429b0b --- /dev/null +++ b/packages/application/adr/ADR-007-Command-Result-Pattern.md @@ -0,0 +1,38 @@ +# ADR-007: Command/Result Pattern + +## Status +Accepted + +## Context +We need a clear way to express intent to change state, execute business operations, and handle failures without exceptions. + +## Decision +Use Command/Result pattern: + +- **Command**: Plain object (DTO) containing all data needed to execute +- **Handler**: Contains orchestration logic, calls domain factories +- **Result**: Explicit success/failure, no exceptions for business errors + +``` +CreateMissionCommand + ↓ +CreateMissionHandler + ↓ +Result +``` + +## Consequences + +### Positive +- Clear intent: commands are named after use cases +- Testable: handlers are pure functions with injected repositories +- No exceptions for business logic +- Audit trail: commands can be logged +- Async-friendly + +### Negative +- More boilerplate than direct service calls +- Need to handle Result type at every call site + +## Related +- ADR-006: In-Memory Adapters for Testing diff --git a/packages/application/jest.config.js b/packages/application/jest.config.js new file mode 100644 index 000000000..84e505d9c --- /dev/null +++ b/packages/application/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/application/package.json b/packages/application/package.json new file mode 100644 index 000000000..a2a408c47 --- /dev/null +++ b/packages/application/package.json @@ -0,0 +1,23 @@ +{ + "name": "@landvex/application", + "version": "0.1.0", + "description": "Application layer and use cases for LandveX domain", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "test": "jest", + "test:watch": "jest --watch" + }, + "dependencies": { + "@landvex/domain": "file:../domain", + "@landvex/infrastructure": "file:../infrastructure" + }, + "devDependencies": { + "@types/jest": "^29.5.0", + "@types/node": "^20.0.0", + "jest": "^29.5.0", + "ts-jest": "^29.1.0", + "typescript": "^5.3.0" + } +} diff --git a/packages/application/src/commands/commands.ts b/packages/application/src/commands/commands.ts new file mode 100644 index 000000000..d42d94fc1 --- /dev/null +++ b/packages/application/src/commands/commands.ts @@ -0,0 +1,77 @@ +/** + * Commands — intent to change state + * + * A command is a request to do something. + * It contains all data needed to execute. + * It does NOT contain business logic. + * + * ADR-007: Command/Result pattern + * - Commands are plain objects (DTOs) + * - Handlers contain orchestration logic + * - Results are explicit (success/failure) + */ + +import { GeoLocation, DeviceInfo } from '@landvex/domain'; + +// Session commands +export interface CreateFieldSessionCommand { + readonly inspector: string; + readonly date: Date; + readonly location: GeoLocation; + readonly areaId: string; +} + +// Mission commands +export interface CreateMissionCommand { + readonly sessionId: string; + readonly location: GeoLocation; + readonly device: DeviceInfo; +} + +// Artifact commands +export interface RegisterArtifactCommand { + readonly missionId: string; + readonly type: 'image' | 'video' | 'audio' | 'dataset'; + readonly storageUri: string; + readonly hash: string; + readonly createdBy: string; +} + +// Observation commands +export interface CreateObservationCommand { + readonly missionId: string; + readonly artifactId: string; + readonly description: string; +} + +// DecisionCase commands +export interface CreateDecisionCaseCommand { + readonly missionId: string; + readonly title: string; + readonly description: string; + readonly priority: 'low' | 'medium' | 'high' | 'critical'; + readonly confidence: 'low' | 'medium' | 'high' | 'certain'; + readonly recommendedAction: string; + readonly observationIds: string[]; + readonly evidenceIds: string[]; + readonly findingId: string; + readonly decisionId: string; + readonly reviewId: string; +} + +export interface ApproveDecisionCommand { + readonly decisionCaseId: string; + readonly approvedBy: string; +} + +// Review commands +export interface StartReviewCommand { + readonly decisionCaseId: string; + readonly reviewer: string; +} + +export interface CompleteReviewCommand { + readonly reviewId: string; + readonly verdict: 'approved' | 'rejected' | 'needs_more_evidence'; + readonly comment?: string; +} diff --git a/packages/application/src/handlers/approve-decision-handler.ts b/packages/application/src/handlers/approve-decision-handler.ts new file mode 100644 index 000000000..2a79114e8 --- /dev/null +++ b/packages/application/src/handlers/approve-decision-handler.ts @@ -0,0 +1,39 @@ +/** + * ApproveDecision Handler + * + * Orchestrates: ApproveDecisionCommand → Approved DecisionCase + */ + +import { DecisionCaseFactory, DecisionCase } from '@landvex/domain'; +import { DecisionCaseRepository } from '@landvex/infrastructure'; +import { ApproveDecisionCommand } from '../commands/commands'; +import { Result, ok, fail, NotFoundError, ValidationError } from '../results/results'; + +export interface ApproveDecisionResult { + readonly decisionCase: DecisionCase; +} + +export class ApproveDecisionHandler { + constructor(private readonly decisionCases: DecisionCaseRepository) {} + + async execute(command: ApproveDecisionCommand): Promise> { + // Find + const decisionCase = await this.decisionCases.findById(command.decisionCaseId as any); + if (!decisionCase) { + return fail(NotFoundError('DecisionCase', command.decisionCaseId)); + } + + // Validation + if (!decisionCase.reviewId) { + return fail(ValidationError('DecisionCase cannot be approved without review')); + } + + // Business logic (domain factory) + const approved = DecisionCaseFactory.approve(decisionCase); + + // Persist + await this.decisionCases.save(approved); + + return ok({ decisionCase: approved }); + } +} diff --git a/packages/application/src/handlers/create-decision-case-handler.ts b/packages/application/src/handlers/create-decision-case-handler.ts new file mode 100644 index 000000000..f15ca0c09 --- /dev/null +++ b/packages/application/src/handlers/create-decision-case-handler.ts @@ -0,0 +1,52 @@ +/** + * CreateDecisionCase Handler + * + * Orchestrates: CreateDecisionCaseCommand → DecisionCase + */ + +import { DecisionCaseFactory, IdFactory, DecisionCase } from '@landvex/domain'; +import { DecisionCaseRepository } from '@landvex/infrastructure'; +import { CreateDecisionCaseCommand } from '../commands/commands'; +import { Result, ok, fail, ValidationError } from '../results/results'; + +export interface CreateDecisionCaseResult { + readonly decisionCase: DecisionCase; +} + +export class CreateDecisionCaseHandler { + constructor(private readonly decisionCases: DecisionCaseRepository) {} + + async execute(command: CreateDecisionCaseCommand): Promise> { + // Validation + if (!command.title || command.title.trim().length === 0) { + return fail(ValidationError('Title is required')); + } + if (!command.observationIds || command.observationIds.length === 0) { + return fail(ValidationError('At least one observation is required')); + } + if (!command.evidenceIds || command.evidenceIds.length === 0) { + return fail(ValidationError('At least one evidence is required')); + } + + // Create + const decisionCase = DecisionCaseFactory.create({ + id: IdFactory.decisionCase(1), + missionId: command.missionId as any, + title: command.title, + description: command.description, + priority: command.priority, + confidence: command.confidence, + recommendedAction: command.recommendedAction, + observationIds: command.observationIds as any, + evidenceIds: command.evidenceIds as any, + findingId: command.findingId as any, + decisionId: command.decisionId as any, + reviewId: command.reviewId as any, + }); + + // Persist + await this.decisionCases.save(decisionCase); + + return ok({ decisionCase }); + } +} diff --git a/packages/application/src/handlers/create-field-session-handler.ts b/packages/application/src/handlers/create-field-session-handler.ts new file mode 100644 index 000000000..78c05d886 --- /dev/null +++ b/packages/application/src/handlers/create-field-session-handler.ts @@ -0,0 +1,49 @@ +/** + * CreateFieldSession Handler + * + * Orchestrates: CreateFieldSessionCommand → FieldSession + * + * Flow: + * 1. Generate ID + * 2. Create domain object (factory) + * 3. Save to repository + * 4. Return result + */ + +import { FieldSessionFactory, IdFactory, FieldSession } from '@landvex/domain'; +import { FieldSessionRepository } from '@landvex/infrastructure'; +import { CreateFieldSessionCommand } from '../commands/commands'; +import { Result, ok, fail, ValidationError } from '../results/results'; + +export interface CreateFieldSessionResult { + readonly session: FieldSession; +} + +export class CreateFieldSessionHandler { + constructor(private readonly sessions: FieldSessionRepository) {} + + async execute(command: CreateFieldSessionCommand): Promise> { + // Validation + if (!command.inspector || command.inspector.trim().length === 0) { + return fail(ValidationError('Inspector is required')); + } + if (!command.date) { + return fail(ValidationError('Date is required')); + } + if (!command.location) { + return fail(ValidationError('Location is required')); + } + + // Create + const session = FieldSessionFactory.create({ + id: IdFactory.session(command.date, 1), // TODO: sequence from repository + location: command.location, + date: command.date, + }); + + // Persist + await this.sessions.save(session); + + return ok({ session }); + } +} diff --git a/packages/application/src/handlers/create-mission-handler.ts b/packages/application/src/handlers/create-mission-handler.ts new file mode 100644 index 000000000..a84a7094b --- /dev/null +++ b/packages/application/src/handlers/create-mission-handler.ts @@ -0,0 +1,54 @@ +/** + * CreateMission Handler + * + * Orchestrates: CreateMissionCommand → Mission + * + * Flow: + * 1. Verify session exists + * 2. Generate ID + * 3. Create domain object (factory) + * 4. Save to repository + * 5. Return result + */ + +import { MissionFactory, IdFactory, Mission } from '@landvex/domain'; +import { MissionRepository, FieldSessionRepository } from '@landvex/infrastructure'; +import { CreateMissionCommand } from '../commands/commands'; +import { Result, ok, fail, NotFoundError, ValidationError } from '../results/results'; + +export interface CreateMissionResult { + readonly mission: Mission; +} + +export class CreateMissionHandler { + constructor( + private readonly missions: MissionRepository, + private readonly sessions: FieldSessionRepository, + ) {} + + async execute(command: CreateMissionCommand): Promise> { + // Verify session exists + const session = await this.sessions.findById(command.sessionId as any); + if (!session) { + return fail(NotFoundError('Session', command.sessionId)); + } + + // Validation + if (!command.location) { + return fail(ValidationError('Location is required')); + } + + // Create + const mission = MissionFactory.create({ + id: IdFactory.mission(new Date(), 1), // TODO: sequence from repository + sessionId: command.sessionId as any, + location: command.location, + device: command.device, + }); + + // Persist + await this.missions.save(mission); + + return ok({ mission }); + } +} diff --git a/packages/application/src/handlers/index.ts b/packages/application/src/handlers/index.ts new file mode 100644 index 000000000..17a8bc916 --- /dev/null +++ b/packages/application/src/handlers/index.ts @@ -0,0 +1,4 @@ +export { CreateFieldSessionHandler, CreateFieldSessionResult } from './create-field-session-handler'; +export { CreateMissionHandler, CreateMissionResult } from './create-mission-handler'; +export { CreateDecisionCaseHandler, CreateDecisionCaseResult } from './create-decision-case-handler'; +export { ApproveDecisionHandler, ApproveDecisionResult } from './approve-decision-handler'; diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts new file mode 100644 index 000000000..234a680a4 --- /dev/null +++ b/packages/application/src/index.ts @@ -0,0 +1,19 @@ +/** + * @landvex/application + * + * Application layer — orchestrates domain operations. + * + * Architecture: + * UI → Application Layer (commands/handlers) → Domain → Repository Interfaces → Adapters + * + * No business logic here. Only orchestration. + */ + +// Commands +export * from './commands/commands'; + +// Results +export * from './results/results'; + +// Handlers +export * from './handlers'; diff --git a/packages/application/src/results/results.ts b/packages/application/src/results/results.ts new file mode 100644 index 000000000..e1d2a966b --- /dev/null +++ b/packages/application/src/results/results.ts @@ -0,0 +1,52 @@ +/** + * Results — explicit outcome of command execution + * + * Every command returns a Result. + * No exceptions for business failures. + * + * ADR-007: Command/Result pattern + * - Success: contains the result data + * - Failure: contains error code and message + */ + +export type Result = Success | Failure; + +export interface Success { + readonly success: true; + readonly data: T; +} + +export interface Failure { + readonly success: false; + readonly error: E; +} + +export interface DomainError { + readonly code: string; + readonly message: string; +} + +// Helper functions +export const ok = (data: T): Success => ({ success: true, data }); +export const fail = (error: E): Failure => ({ success: false, error }); + +// Common errors +export const NotFoundError = (entity: string, id: string): DomainError => ({ + code: 'NOT_FOUND', + message: `${entity} not found: ${id}`, +}); + +export const ValidationError = (message: string): DomainError => ({ + code: 'VALIDATION_ERROR', + message, +}); + +export const InvariantViolationError = (message: string): DomainError => ({ + code: 'INVARIANT_VIOLATION', + message, +}); + +export const ConflictError = (message: string): DomainError => ({ + code: 'CONFLICT', + message, +}); diff --git a/packages/application/src/use-cases/end-to-end-flow.test.ts b/packages/application/src/use-cases/end-to-end-flow.test.ts new file mode 100644 index 000000000..905440a37 --- /dev/null +++ b/packages/application/src/use-cases/end-to-end-flow.test.ts @@ -0,0 +1,168 @@ +/** + * End-to-End Flow Test + * + * Acceptance Test for PR-002.5: + * CreateFieldSession → CreateMission → CreateDecisionCase → ApproveDecision + * + * WITHOUT: + * - PostgreSQL + * - Redis + * - S3 + * - API + * - HTTP + * - UI + * + * If this passes, the domain is proven usable. + */ + +import { + InMemoryFieldSessionRepository, + InMemoryMissionRepository, + InMemoryDecisionCaseRepository, + InMemoryUnitOfWork, +} from '@landvex/infrastructure'; + +import { + CreateFieldSessionHandler, + CreateMissionHandler, + CreateDecisionCaseHandler, + ApproveDecisionHandler, +} from '../handlers'; + +import { + CreateFieldSessionCommand, + CreateMissionCommand, + CreateDecisionCaseCommand, + ApproveDecisionCommand, +} from '../commands/commands'; + +describe('End-to-End Flow: Session → Mission → DecisionCase → Approval', () => { + let uow: InMemoryUnitOfWork; + let createSession: CreateFieldSessionHandler; + let createMission: CreateMissionHandler; + let createDecisionCase: CreateDecisionCaseHandler; + let approveDecision: ApproveDecisionHandler; + + beforeEach(() => { + uow = new InMemoryUnitOfWork(); + createSession = new CreateFieldSessionHandler(uow.sessions); + createMission = new CreateMissionHandler(uow.missions, uow.sessions); + createDecisionCase = new CreateDecisionCaseHandler(uow.decisionCases); + approveDecision = new ApproveDecisionHandler(uow.decisionCases); + }); + + it('should complete full flow in memory', async () => { + // Step 1: Create FieldSession + const sessionResult = await createSession.execute({ + inspector: 'inspector_001', + date: new Date('2026-07-02'), + location: { lat: 59.3293, lng: 18.0686 }, + areaId: 'area_stockholm_city', + } as CreateFieldSessionCommand); + + expect(sessionResult.success).toBe(true); + if (!sessionResult.success) return; + const session = sessionResult.data.session; + + // Step 2: Create Mission + const missionResult = await createMission.execute({ + sessionId: session.id, + location: { lat: 59.3293, lng: 18.0686 }, + device: { model: 'iPhone14,2', os: 'iOS 17.0', appVersion: '1.0.0' }, + } as CreateMissionCommand); + + expect(missionResult.success).toBe(true); + if (!missionResult.success) return; + const mission = missionResult.data.mission; + + // Step 3: Create DecisionCase + const decisionCaseResult = await createDecisionCase.execute({ + missionId: mission.id, + title: 'Crack in bridge deck', + description: 'Structural crack detected in bridge deck', + priority: 'high', + confidence: 'high', + recommendedAction: 'Inspect immediately by certified engineer', + observationIds: ['obs_000001'], + evidenceIds: ['evidence_000001'], + findingId: 'finding_000001', + decisionId: 'decision_000001', + reviewId: 'review_000001', + } as CreateDecisionCaseCommand); + + expect(decisionCaseResult.success).toBe(true); + if (!decisionCaseResult.success) return; + const decisionCase = decisionCaseResult.data.decisionCase; + + expect(decisionCase.status).toBe('pending'); + + // Step 4: Approve Decision + const approveResult = await approveDecision.execute({ + decisionCaseId: decisionCase.id, + approvedBy: 'reviewer_001', + } as ApproveDecisionCommand); + + expect(approveResult.success).toBe(true); + if (!approveResult.success) return; + const approved = approveResult.data.decisionCase; + + expect(approved.status).toBe('approved'); + + // Verify persistence + const foundSession = await uow.sessions.findById(session.id); + const foundMission = await uow.missions.findById(mission.id); + const foundCase = await uow.decisionCases.findById(decisionCase.id); + + expect(foundSession).not.toBeNull(); + expect(foundMission).not.toBeNull(); + expect(foundCase).not.toBeNull(); + expect(foundCase!.status).toBe('approved'); + }); + + it('should fail to approve decision without review', async () => { + // Create a decision case without reviewId + const createResult = await createDecisionCase.execute({ + missionId: 'mission_20260702_000001', + title: 'Pothole on road A', + description: 'Small pothole', + priority: 'low', + confidence: 'medium', + recommendedAction: 'Schedule repair', + observationIds: ['obs_000001'], + evidenceIds: ['evidence_000001'], + findingId: 'finding_000001', + decisionId: 'decision_000001', + reviewId: 'review_000001', + } as CreateDecisionCaseCommand); + + expect(createResult.success).toBe(true); + if (!createResult.success) return; + + // Manually remove reviewId to simulate missing review + const decisionCase = createResult.data.decisionCase; + const modified = { ...decisionCase, reviewId: undefined as any }; + await uow.decisionCases.save(modified); + + // Try to approve + const approveResult = await approveDecision.execute({ + decisionCaseId: decisionCase.id, + approvedBy: 'reviewer_001', + } as ApproveDecisionCommand); + + expect(approveResult.success).toBe(false); + if (approveResult.success) return; + expect(approveResult.error.code).toBe('VALIDATION_ERROR'); + }); + + it('should fail to create mission for non-existent session', async () => { + const result = await createMission.execute({ + sessionId: 'session_20260702_999999', + location: { lat: 59.3293, lng: 18.0686 }, + device: { model: 'iPhone14,2', os: 'iOS 17.0', appVersion: '1.0.0' }, + } as CreateMissionCommand); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.code).toBe('NOT_FOUND'); + }); +}); diff --git a/packages/application/tsconfig.json b/packages/application/tsconfig.json new file mode 100644 index 000000000..da30b3736 --- /dev/null +++ b/packages/application/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"] +}