PR-002.5: Application Layer — Command/Result pattern, end-to-end flow

- 8 commands (CreateFieldSession, CreateMission, RegisterArtifact,
  CreateObservation, CreateDecisionCase, ApproveDecision, StartReview,
  CompleteReview)
- 4 handlers with validation and orchestration
- Result<T, E> pattern — explicit success/failure, no exceptions
- End-to-end test: Session → Mission → DecisionCase → Approval
- 3 tests proving full flow works in memory
- ADR-007: Command/Result Pattern

Acceptance Criteria:
 CreateFieldSession → CreateMission → CreateDecisionCase → ApproveDecision
 Without PostgreSQL, Redis, S3, API, HTTP, UI
 Business logic verified before infrastructure attached

Next: PR-003 — PostgreSQL adapters (swap InMemory → Postgres)
This commit is contained in:
Bernt
2026-07-02 15:06:59 +00:00
parent 02b51d7814
commit eab9da0100
15 changed files with 665 additions and 0 deletions
@@ -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;
}
@@ -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<Result<ApproveDecisionResult>> {
// 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 });
}
}
@@ -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<Result<CreateDecisionCaseResult>> {
// 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 });
}
}
@@ -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<Result<CreateFieldSessionResult>> {
// 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 });
}
}
@@ -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<Result<CreateMissionResult>> {
// 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 });
}
}
@@ -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';
+19
View File
@@ -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';
@@ -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<T, E = DomainError> = Success<T> | Failure<E>;
export interface Success<T> {
readonly success: true;
readonly data: T;
}
export interface Failure<E> {
readonly success: false;
readonly error: E;
}
export interface DomainError {
readonly code: string;
readonly message: string;
}
// Helper functions
export const ok = <T>(data: T): Success<T> => ({ success: true, data });
export const fail = <E extends DomainError>(error: E): Failure<E> => ({ 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,
});
@@ -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');
});
});