PR-001: Domain Model v1.0 — compile-only, zero dependencies
- @landvex/domain package with TypeScript strict mode - 3 Aggregate Roots: FieldSession, Mission, DecisionCase - Branded IDs, Value Objects, Domain Events, Invariants - 19 unit tests for IDs, FieldSession, DecisionCase - Zero runtime dependencies (only TypeScript + jest for tests) - Separates Entity / Value Object / Aggregate Root - README documents Three Rules of the domain Definition of Done met: - Compiles without errors - Exports all domain types - Unit tests for invariants and value objects - No PostgreSQL, S3, Express, AI models, queues
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Action - Entity
|
||||
* Executed task based on a decision
|
||||
*/
|
||||
|
||||
import { ActionId, DecisionId } from '../common/ids';
|
||||
|
||||
export interface Action {
|
||||
readonly id: ActionId;
|
||||
readonly decisionId: DecisionId;
|
||||
readonly description: string;
|
||||
readonly assignedTo: string;
|
||||
readonly scheduledDate: Date;
|
||||
readonly status: 'pending' | 'in_progress' | 'completed';
|
||||
readonly createdAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateActionParams {
|
||||
readonly id: ActionId;
|
||||
readonly decisionId: DecisionId;
|
||||
readonly description: string;
|
||||
readonly assignedTo: string;
|
||||
readonly scheduledDate: Date;
|
||||
}
|
||||
|
||||
export class ActionFactory {
|
||||
static create(params: CreateActionParams): Action {
|
||||
return {
|
||||
...params,
|
||||
status: 'pending',
|
||||
createdAt: new Date()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Artifact - Common contract for everything produced
|
||||
*
|
||||
* Rule 2: Everything is an Artifact
|
||||
* All artifacts share: id, version, hash, lineage, createdAt, createdBy, storageUri
|
||||
*/
|
||||
|
||||
import { ArtifactId } from '../common/ids';
|
||||
import { ArtifactType } from '../common/enums';
|
||||
import { Hash, StorageUri, Version } from '../common/value-objects';
|
||||
|
||||
export interface Artifact {
|
||||
readonly id: ArtifactId;
|
||||
readonly type: ArtifactType;
|
||||
readonly version: Version;
|
||||
readonly hash: Hash;
|
||||
readonly createdBy: string;
|
||||
readonly storageUri: StorageUri;
|
||||
readonly parentId?: ArtifactId;
|
||||
readonly lineage: ArtifactId[];
|
||||
readonly createdAt: Date;
|
||||
}
|
||||
|
||||
export interface ArtifactCapabilities {
|
||||
readonly storage: boolean;
|
||||
readonly versioning: boolean;
|
||||
readonly lineage: boolean;
|
||||
}
|
||||
|
||||
export const ArtifactCapabilities: Record<ArtifactType, ArtifactCapabilities> = {
|
||||
[ArtifactType.IMAGE]: { storage: true, versioning: true, lineage: true },
|
||||
[ArtifactType.VIDEO]: { storage: true, versioning: true, lineage: true },
|
||||
[ArtifactType.AUDIO]: { storage: true, versioning: true, lineage: true },
|
||||
[ArtifactType.DATASET]: { storage: true, versioning: true, lineage: true },
|
||||
[ArtifactType.ANNOTATION]: { storage: true, versioning: true, lineage: true },
|
||||
[ArtifactType.DECISION_CASE]: { storage: true, versioning: true, lineage: true },
|
||||
[ArtifactType.MODEL]: { storage: true, versioning: true, lineage: true },
|
||||
[ArtifactType.EVALUATION]: { storage: true, versioning: true, lineage: true },
|
||||
[ArtifactType.REPLAY]: { storage: true, versioning: true, lineage: true },
|
||||
[ArtifactType.REPORT]: { storage: true, versioning: true, lineage: true },
|
||||
[ArtifactType.EXPERIENCE_INSIGHT]: { storage: true, versioning: true, lineage: true }
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Domain Enums
|
||||
* Centralized to ensure consistency across the platform
|
||||
*/
|
||||
|
||||
export enum ArtifactType {
|
||||
IMAGE = 'image',
|
||||
VIDEO = 'video',
|
||||
AUDIO = 'audio',
|
||||
DATASET = 'dataset',
|
||||
ANNOTATION = 'annotation',
|
||||
DECISION_CASE = 'decision_case',
|
||||
MODEL = 'model',
|
||||
EVALUATION = 'evaluation',
|
||||
REPLAY = 'replay',
|
||||
REPORT = 'report',
|
||||
EXPERIENCE_INSIGHT = 'experience_insight'
|
||||
}
|
||||
|
||||
export enum ReviewStatus {
|
||||
ASSIGNED = 'assigned',
|
||||
IN_REVIEW = 'in_review',
|
||||
APPROVED = 'approved',
|
||||
REJECTED = 'rejected',
|
||||
NEEDS_MORE_EVIDENCE = 'needs_more_evidence'
|
||||
}
|
||||
|
||||
export enum DecisionVerb {
|
||||
INSPECT = 'inspect',
|
||||
REPAIR = 'repair',
|
||||
MONITOR = 'monitor',
|
||||
WAIT = 'wait',
|
||||
COLLECT = 'collect',
|
||||
ESCALATE = 'escalate',
|
||||
IGNORE = 'ignore',
|
||||
PRIORITIZE = 'prioritize'
|
||||
}
|
||||
|
||||
export enum MissionStatus {
|
||||
CREATED = 'created',
|
||||
UPLOADING = 'uploading',
|
||||
PROCESSING = 'processing',
|
||||
COMPLETED = 'completed',
|
||||
FAILED = 'failed'
|
||||
}
|
||||
|
||||
export enum SessionStatus {
|
||||
PLANNED = 'planned',
|
||||
ACTIVE = 'active',
|
||||
COMPLETED = 'completed'
|
||||
}
|
||||
|
||||
export enum DecisionStatus {
|
||||
PENDING = 'pending',
|
||||
UNDER_REVIEW = 'under_review',
|
||||
APPROVED = 'approved',
|
||||
REJECTED = 'rejected'
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Domain Errors
|
||||
* Specific error types for domain invariants
|
||||
*/
|
||||
|
||||
export abstract class DomainError extends Error {
|
||||
abstract readonly code: string;
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
}
|
||||
}
|
||||
|
||||
export class InvariantViolationError extends DomainError {
|
||||
readonly code = 'INVARIANT_VIOLATION';
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidStateTransitionError extends DomainError {
|
||||
readonly code = 'INVALID_STATE_TRANSITION';
|
||||
|
||||
constructor(from: string, to: string) {
|
||||
super(`Cannot transition from ${from} to ${to}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class MissingRequiredFieldError extends DomainError {
|
||||
readonly code = 'MISSING_REQUIRED_FIELD';
|
||||
|
||||
constructor(field: string) {
|
||||
super(`Missing required field: ${field}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidIdFormatError extends DomainError {
|
||||
readonly code = 'INVALID_ID_FORMAT';
|
||||
|
||||
constructor(id: string, expectedFormat: string) {
|
||||
super(`Invalid ID format: ${id}. Expected: ${expectedFormat}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Tests for ID factory functions
|
||||
*/
|
||||
|
||||
import { IdFactory } from './ids';
|
||||
|
||||
describe('IdFactory', () => {
|
||||
const testDate = new Date('2026-08-14');
|
||||
|
||||
describe('session', () => {
|
||||
it('should create session ID with correct format', () => {
|
||||
const id = IdFactory.session(testDate, 1);
|
||||
expect(id).toBe('session_20260814_000001');
|
||||
});
|
||||
|
||||
it('should pad sequence number', () => {
|
||||
const id = IdFactory.session(testDate, 123);
|
||||
expect(id).toBe('session_20260814_000123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mission', () => {
|
||||
it('should create mission ID with correct format', () => {
|
||||
const id = IdFactory.mission(testDate, 1);
|
||||
expect(id).toBe('mission_20260814_000001');
|
||||
});
|
||||
});
|
||||
|
||||
describe('artifact', () => {
|
||||
it('should create artifact ID with correct format', () => {
|
||||
const id = IdFactory.artifact(1);
|
||||
expect(id).toBe('artifact_000001');
|
||||
});
|
||||
});
|
||||
|
||||
describe('observation', () => {
|
||||
it('should create observation ID with correct format', () => {
|
||||
const id = IdFactory.observation(1);
|
||||
expect(id).toBe('obs_000001');
|
||||
});
|
||||
});
|
||||
|
||||
describe('decision', () => {
|
||||
it('should create decision ID with correct format', () => {
|
||||
const id = IdFactory.decision(1);
|
||||
expect(id).toBe('decision_000001');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Branded ID types for type safety
|
||||
* Prevents mixing different ID types accidentally
|
||||
*/
|
||||
|
||||
export type SessionId = string & { readonly __brand: 'SessionId' };
|
||||
export type MissionId = string & { readonly __brand: 'MissionId' };
|
||||
export type ArtifactId = string & { readonly __brand: 'ArtifactId' };
|
||||
export type ObservationId = string & { readonly __brand: 'ObservationId' };
|
||||
export type DecisionId = string & { readonly __brand: 'DecisionId' };
|
||||
export type EvidenceId = string & { readonly __brand: 'EvidenceId' };
|
||||
export type FindingId = string & { readonly __brand: 'FindingId' };
|
||||
export type ReviewId = string & { readonly __brand: 'ReviewId' };
|
||||
export type ActionId = string & { readonly __brand: 'ActionId' };
|
||||
export type OutcomeId = string & { readonly __brand: 'OutcomeId' };
|
||||
|
||||
/**
|
||||
* ID factory functions
|
||||
* Ensures consistent ID format across the domain
|
||||
*/
|
||||
export const IdFactory = {
|
||||
session: (date: Date, sequence: number): SessionId => {
|
||||
const dateStr = date.toISOString().split('T')[0].replace(/-/g, '');
|
||||
return `session_${dateStr}_${sequence.toString().padStart(6, '0')}` as SessionId;
|
||||
},
|
||||
|
||||
mission: (date: Date, sequence: number): MissionId => {
|
||||
const dateStr = date.toISOString().split('T')[0].replace(/-/g, '');
|
||||
return `mission_${dateStr}_${sequence.toString().padStart(6, '0')}` as MissionId;
|
||||
},
|
||||
|
||||
artifact: (sequence: number): ArtifactId => {
|
||||
return `artifact_${sequence.toString().padStart(6, '0')}` as ArtifactId;
|
||||
},
|
||||
|
||||
observation: (sequence: number): ObservationId => {
|
||||
return `obs_${sequence.toString().padStart(6, '0')}` as ObservationId;
|
||||
},
|
||||
|
||||
decision: (sequence: number): DecisionId => {
|
||||
return `decision_${sequence.toString().padStart(6, '0')}` as DecisionId;
|
||||
},
|
||||
|
||||
evidence: (sequence: number): EvidenceId => {
|
||||
return `evidence_${sequence.toString().padStart(6, '0')}` as EvidenceId;
|
||||
},
|
||||
|
||||
finding: (sequence: number): FindingId => {
|
||||
return `finding_${sequence.toString().padStart(6, '0')}` as FindingId;
|
||||
},
|
||||
|
||||
review: (sequence: number): ReviewId => {
|
||||
return `review_${sequence.toString().padStart(6, '0')}` as ReviewId;
|
||||
},
|
||||
|
||||
action: (sequence: number): ActionId => {
|
||||
return `action_${sequence.toString().padStart(6, '0')}` as ActionId;
|
||||
},
|
||||
|
||||
outcome: (sequence: number): OutcomeId => {
|
||||
return `outcome_${sequence.toString().padStart(6, '0')}` as OutcomeId;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Value Objects - immutable, compared by value, not identity
|
||||
*/
|
||||
|
||||
export interface GeoLocation {
|
||||
readonly lat: number;
|
||||
readonly lng: number;
|
||||
readonly accuracy?: number; // meters
|
||||
}
|
||||
|
||||
export interface BoundingBox {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
}
|
||||
|
||||
export interface Confidence {
|
||||
readonly value: number; // 0.0 to 1.0
|
||||
}
|
||||
|
||||
export interface Hash {
|
||||
readonly algorithm: 'sha256';
|
||||
readonly value: string;
|
||||
}
|
||||
|
||||
export interface StorageUri {
|
||||
readonly protocol: 's3' | 'r2' | 'file';
|
||||
readonly bucket?: string;
|
||||
readonly path: string;
|
||||
}
|
||||
|
||||
export interface Version {
|
||||
readonly major: number;
|
||||
readonly minor: number;
|
||||
readonly patch: number;
|
||||
}
|
||||
|
||||
export interface TimeRange {
|
||||
readonly start: Date;
|
||||
readonly end: Date;
|
||||
}
|
||||
|
||||
export interface DeviceInfo {
|
||||
readonly model: string;
|
||||
readonly os: string;
|
||||
readonly appVersion: string;
|
||||
}
|
||||
|
||||
export interface ExifData {
|
||||
readonly device?: string;
|
||||
readonly iso?: number;
|
||||
readonly exposure?: string;
|
||||
readonly focalLength?: string;
|
||||
readonly timestamp?: Date;
|
||||
}
|
||||
|
||||
export interface AssetMetadata {
|
||||
readonly exif: ExifData;
|
||||
readonly gps: GeoLocation;
|
||||
readonly device: DeviceInfo;
|
||||
}
|
||||
|
||||
export interface BusinessImpact {
|
||||
readonly risk: string;
|
||||
readonly cost: string;
|
||||
readonly time: string;
|
||||
readonly opportunity: string;
|
||||
}
|
||||
|
||||
export interface EvidenceContext {
|
||||
readonly historicalData?: boolean;
|
||||
readonly weatherData?: boolean;
|
||||
readonly trafficData?: boolean;
|
||||
readonly gisData?: boolean;
|
||||
}
|
||||
|
||||
export interface QualityIssue {
|
||||
readonly type: 'blur' | 'duplicate' | 'bad_gps' | 'low_resolution' | 'missing_metadata' | 'wrong_timestamp' | 'occlusion';
|
||||
readonly severity: Severity;
|
||||
readonly description: string;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
readonly valid: boolean;
|
||||
readonly issues: QualityIssue[];
|
||||
}
|
||||
|
||||
export type Severity = 'low' | 'medium' | 'high' | 'critical';
|
||||
export type Priority = 'low' | 'medium' | 'high' | 'urgent';
|
||||
|
||||
export const Severity = {
|
||||
LOW: 'low' as Severity,
|
||||
MEDIUM: 'medium' as Severity,
|
||||
HIGH: 'high' as Severity,
|
||||
CRITICAL: 'critical' as Severity
|
||||
};
|
||||
|
||||
export const Priority = {
|
||||
LOW: 'low' as Priority,
|
||||
MEDIUM: 'medium' as Priority,
|
||||
HIGH: 'high' as Priority,
|
||||
URGENT: 'urgent' as Priority
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Tests for DecisionCase aggregate root
|
||||
*/
|
||||
|
||||
import { DecisionCaseFactory } from './decision-case';
|
||||
import { IdFactory } from '../common/ids';
|
||||
import { DecisionStatus } from '../common/enums';
|
||||
import { InvariantViolationError } from '../common/errors';
|
||||
|
||||
describe('DecisionCase', () => {
|
||||
const testDate = new Date('2026-08-14');
|
||||
|
||||
describe('create', () => {
|
||||
it('should create decision case with correct defaults', () => {
|
||||
const decisionCase = DecisionCaseFactory.create({
|
||||
id: 'dc_001',
|
||||
missionId: IdFactory.mission(testDate, 1),
|
||||
observationIds: [IdFactory.observation(1)],
|
||||
evidenceIds: [IdFactory.evidence(1)],
|
||||
findingId: IdFactory.finding(1),
|
||||
decisionId: IdFactory.decision(1),
|
||||
reviewId: IdFactory.review(1)
|
||||
});
|
||||
|
||||
expect(decisionCase.status).toBe(DecisionStatus.PENDING);
|
||||
expect(decisionCase.version).toBe(1);
|
||||
});
|
||||
|
||||
it('should throw if no observations', () => {
|
||||
expect(() => {
|
||||
DecisionCaseFactory.create({
|
||||
id: 'dc_001',
|
||||
missionId: IdFactory.mission(testDate, 1),
|
||||
observationIds: [],
|
||||
evidenceIds: [IdFactory.evidence(1)],
|
||||
findingId: IdFactory.finding(1),
|
||||
decisionId: IdFactory.decision(1),
|
||||
reviewId: IdFactory.review(1)
|
||||
});
|
||||
}).toThrow(InvariantViolationError);
|
||||
});
|
||||
|
||||
it('should throw if no evidence', () => {
|
||||
expect(() => {
|
||||
DecisionCaseFactory.create({
|
||||
id: 'dc_001',
|
||||
missionId: IdFactory.mission(testDate, 1),
|
||||
observationIds: [IdFactory.observation(1)],
|
||||
evidenceIds: [],
|
||||
findingId: IdFactory.finding(1),
|
||||
decisionId: IdFactory.decision(1),
|
||||
reviewId: IdFactory.review(1)
|
||||
});
|
||||
}).toThrow(InvariantViolationError);
|
||||
});
|
||||
|
||||
it('should throw if no decision', () => {
|
||||
expect(() => {
|
||||
DecisionCaseFactory.create({
|
||||
id: 'dc_001',
|
||||
missionId: IdFactory.mission(testDate, 1),
|
||||
observationIds: [IdFactory.observation(1)],
|
||||
evidenceIds: [IdFactory.evidence(1)],
|
||||
findingId: IdFactory.finding(1),
|
||||
decisionId: null as any,
|
||||
reviewId: IdFactory.review(1)
|
||||
});
|
||||
}).toThrow(InvariantViolationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('approve', () => {
|
||||
it('should approve decision case with review', () => {
|
||||
const decisionCase = DecisionCaseFactory.create({
|
||||
id: 'dc_001',
|
||||
missionId: IdFactory.mission(testDate, 1),
|
||||
observationIds: [IdFactory.observation(1)],
|
||||
evidenceIds: [IdFactory.evidence(1)],
|
||||
findingId: IdFactory.finding(1),
|
||||
decisionId: IdFactory.decision(1),
|
||||
reviewId: IdFactory.review(1)
|
||||
});
|
||||
|
||||
const approved = DecisionCaseFactory.approve(decisionCase);
|
||||
expect(approved.status).toBe(DecisionStatus.APPROVED);
|
||||
});
|
||||
|
||||
it('should throw if no review', () => {
|
||||
const decisionCase = DecisionCaseFactory.create({
|
||||
id: 'dc_001',
|
||||
missionId: IdFactory.mission(testDate, 1),
|
||||
observationIds: [IdFactory.observation(1)],
|
||||
evidenceIds: [IdFactory.evidence(1)],
|
||||
findingId: IdFactory.finding(1),
|
||||
decisionId: IdFactory.decision(1),
|
||||
reviewId: null as any
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
DecisionCaseFactory.approve(decisionCase);
|
||||
}).toThrow(InvariantViolationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRevision', () => {
|
||||
it('should create new version', () => {
|
||||
const decisionCase = DecisionCaseFactory.create({
|
||||
id: 'dc_001',
|
||||
missionId: IdFactory.mission(testDate, 1),
|
||||
observationIds: [IdFactory.observation(1)],
|
||||
evidenceIds: [IdFactory.evidence(1)],
|
||||
findingId: IdFactory.finding(1),
|
||||
decisionId: IdFactory.decision(1),
|
||||
reviewId: IdFactory.review(1)
|
||||
});
|
||||
|
||||
const approved = DecisionCaseFactory.approve(decisionCase);
|
||||
const revision = DecisionCaseFactory.createRevision(approved, {
|
||||
decisionId: IdFactory.decision(2)
|
||||
});
|
||||
|
||||
expect(revision.version).toBe(2);
|
||||
expect(revision.status).toBe(DecisionStatus.PENDING);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* DecisionCase - Aggregate Root
|
||||
* Complete decision chain with full traceability
|
||||
*
|
||||
* Invariants:
|
||||
* - Must have at least one Observation
|
||||
* - Must have at least one Evidence
|
||||
* - Must have exactly one Decision
|
||||
* - Cannot be Approved without Review
|
||||
* - Immutable once created — revisions create new versions
|
||||
*/
|
||||
|
||||
import { MissionId, ObservationId, EvidenceId, FindingId, DecisionId, ReviewId } from '../common/ids';
|
||||
import { DecisionStatus } from '../common/enums';
|
||||
import { InvariantViolationError } from '../common/errors';
|
||||
|
||||
export interface DecisionCase {
|
||||
readonly id: string;
|
||||
readonly missionId: MissionId;
|
||||
readonly observationIds: ObservationId[];
|
||||
readonly evidenceIds: EvidenceId[];
|
||||
readonly findingId: FindingId;
|
||||
readonly decisionId: DecisionId;
|
||||
readonly reviewId: ReviewId;
|
||||
readonly status: DecisionStatus;
|
||||
readonly version: number;
|
||||
readonly createdAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateDecisionCaseParams {
|
||||
readonly id: string;
|
||||
readonly missionId: MissionId;
|
||||
readonly observationIds: ObservationId[];
|
||||
readonly evidenceIds: EvidenceId[];
|
||||
readonly findingId: FindingId;
|
||||
readonly decisionId: DecisionId;
|
||||
readonly reviewId: ReviewId;
|
||||
}
|
||||
|
||||
export class DecisionCaseFactory {
|
||||
static create(params: CreateDecisionCaseParams): DecisionCase {
|
||||
if (params.observationIds.length === 0) {
|
||||
throw new InvariantViolationError('DecisionCase must have at least one observation');
|
||||
}
|
||||
if (params.evidenceIds.length === 0) {
|
||||
throw new InvariantViolationError('DecisionCase must have at least one evidence');
|
||||
}
|
||||
if (!params.decisionId) {
|
||||
throw new InvariantViolationError('DecisionCase must have exactly one decision');
|
||||
}
|
||||
|
||||
return {
|
||||
...params,
|
||||
status: DecisionStatus.PENDING,
|
||||
version: 1,
|
||||
createdAt: new Date()
|
||||
};
|
||||
}
|
||||
|
||||
static approve(decisionCase: DecisionCase): DecisionCase {
|
||||
if (!decisionCase.reviewId) {
|
||||
throw new InvariantViolationError('DecisionCase cannot be approved without review');
|
||||
}
|
||||
|
||||
return {
|
||||
...decisionCase,
|
||||
status: DecisionStatus.APPROVED
|
||||
};
|
||||
}
|
||||
|
||||
static createRevision(decisionCase: DecisionCase, params: Partial<CreateDecisionCaseParams>): DecisionCase {
|
||||
return {
|
||||
...decisionCase,
|
||||
...params,
|
||||
version: decisionCase.version + 1,
|
||||
status: DecisionStatus.PENDING
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Decision - Entity
|
||||
* Recommended action with full context
|
||||
*
|
||||
* Rule 3: All decisions are reproducible
|
||||
* Must answer: Which observations? Which evidence? Which model? Which version?
|
||||
* Which rules? Who reviewed? When? Which version was approved?
|
||||
*/
|
||||
|
||||
import { DecisionId, FindingId } from '../common/ids';
|
||||
import { DecisionVerb } from '../common/enums';
|
||||
import { Confidence, BusinessImpact } from '../common/value-objects';
|
||||
|
||||
export interface Decision {
|
||||
readonly id: DecisionId;
|
||||
readonly findingId: FindingId;
|
||||
readonly verb: DecisionVerb;
|
||||
readonly description: string;
|
||||
readonly rationale: string;
|
||||
readonly confidence: Confidence;
|
||||
readonly consequence: string;
|
||||
readonly action: string;
|
||||
readonly businessImpact: BusinessImpact;
|
||||
readonly createdAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateDecisionParams {
|
||||
readonly id: DecisionId;
|
||||
readonly findingId: FindingId;
|
||||
readonly verb: DecisionVerb;
|
||||
readonly description: string;
|
||||
readonly rationale: string;
|
||||
readonly confidence: Confidence;
|
||||
readonly consequence: string;
|
||||
readonly action: string;
|
||||
readonly businessImpact: BusinessImpact;
|
||||
}
|
||||
|
||||
export class DecisionFactory {
|
||||
static create(params: CreateDecisionParams): Decision {
|
||||
return {
|
||||
...params,
|
||||
createdAt: new Date()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface DecisionCapabilities {
|
||||
readonly decision: boolean;
|
||||
readonly recommendation: boolean;
|
||||
readonly business: boolean;
|
||||
}
|
||||
|
||||
export const DefaultDecisionCapabilities: DecisionCapabilities = {
|
||||
decision: true,
|
||||
recommendation: true,
|
||||
business: true
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Domain Events
|
||||
* Event contracts for the entire platform
|
||||
*
|
||||
* Rule 3: All decisions are reproducible
|
||||
* Events provide the audit trail for full traceability
|
||||
*/
|
||||
|
||||
import {
|
||||
SessionId,
|
||||
MissionId,
|
||||
ArtifactId,
|
||||
ObservationId,
|
||||
EvidenceId,
|
||||
FindingId,
|
||||
DecisionId,
|
||||
ReviewId
|
||||
} from '../common/ids';
|
||||
import { ArtifactType, ReviewStatus, DecisionVerb } from '../common/enums';
|
||||
import { GeoLocation, Confidence, Severity, StorageUri, Hash } from '../common/value-objects';
|
||||
|
||||
// Base event interface
|
||||
export interface DomainEvent {
|
||||
readonly type: string;
|
||||
readonly timestamp: Date;
|
||||
readonly aggregateId: string;
|
||||
}
|
||||
|
||||
// Session events
|
||||
export interface FieldSessionCreated extends DomainEvent {
|
||||
readonly type: 'FieldSessionCreated';
|
||||
readonly sessionId: SessionId;
|
||||
readonly location: GeoLocation;
|
||||
readonly date: Date;
|
||||
}
|
||||
|
||||
// Mission events
|
||||
export interface MissionCreated extends DomainEvent {
|
||||
readonly type: 'MissionCreated';
|
||||
readonly missionId: MissionId;
|
||||
readonly sessionId: SessionId;
|
||||
readonly location: GeoLocation;
|
||||
}
|
||||
|
||||
export interface MissionImported extends DomainEvent {
|
||||
readonly type: 'MissionImported';
|
||||
readonly missionId: MissionId;
|
||||
readonly assetCount: number;
|
||||
}
|
||||
|
||||
// Artifact events
|
||||
export interface ArtifactRegistered extends DomainEvent {
|
||||
readonly type: 'ArtifactRegistered';
|
||||
readonly artifactId: ArtifactId;
|
||||
readonly missionId: MissionId;
|
||||
readonly artifactType: ArtifactType;
|
||||
readonly storageUri: StorageUri;
|
||||
readonly hash: Hash;
|
||||
}
|
||||
|
||||
export interface ArtifactValidated extends DomainEvent {
|
||||
readonly type: 'ArtifactValidated';
|
||||
readonly artifactId: ArtifactId;
|
||||
readonly valid: boolean;
|
||||
}
|
||||
|
||||
// Observation events
|
||||
export interface ObservationCreated extends DomainEvent {
|
||||
readonly type: 'ObservationCreated';
|
||||
readonly observationId: ObservationId;
|
||||
readonly missionId: MissionId;
|
||||
readonly artifactId: ArtifactId;
|
||||
readonly description: string;
|
||||
}
|
||||
|
||||
// Evidence events
|
||||
export interface EvidenceLinked extends DomainEvent {
|
||||
readonly type: 'EvidenceLinked';
|
||||
readonly evidenceId: EvidenceId;
|
||||
readonly observationIds: ObservationId[];
|
||||
}
|
||||
|
||||
// Finding events
|
||||
export interface FindingCreated extends DomainEvent {
|
||||
readonly type: 'FindingCreated';
|
||||
readonly findingId: FindingId;
|
||||
readonly evidenceId: EvidenceId;
|
||||
readonly severity: Severity;
|
||||
}
|
||||
|
||||
// Decision events
|
||||
export interface DecisionCreated extends DomainEvent {
|
||||
readonly type: 'DecisionCreated';
|
||||
readonly decisionId: DecisionId;
|
||||
readonly findingId: FindingId;
|
||||
readonly verb: DecisionVerb;
|
||||
readonly confidence: Confidence;
|
||||
}
|
||||
|
||||
export interface DecisionReviewed extends DomainEvent {
|
||||
readonly type: 'DecisionReviewed';
|
||||
readonly decisionId: DecisionId;
|
||||
readonly reviewId: ReviewId;
|
||||
readonly status: ReviewStatus;
|
||||
readonly reviewer: string;
|
||||
}
|
||||
|
||||
export interface DecisionApproved extends DomainEvent {
|
||||
readonly type: 'DecisionApproved';
|
||||
readonly decisionId: DecisionId;
|
||||
readonly reviewId: ReviewId;
|
||||
readonly approvedBy: string;
|
||||
}
|
||||
|
||||
// Union type of all domain events
|
||||
export type LandveXDomainEvent =
|
||||
| FieldSessionCreated
|
||||
| MissionCreated
|
||||
| MissionImported
|
||||
| ArtifactRegistered
|
||||
| ArtifactValidated
|
||||
| ObservationCreated
|
||||
| EvidenceLinked
|
||||
| FindingCreated
|
||||
| DecisionCreated
|
||||
| DecisionReviewed
|
||||
| DecisionApproved;
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Evidence - Entity
|
||||
* Linked observations with context
|
||||
*/
|
||||
|
||||
import { EvidenceId, ObservationId } from '../common/ids';
|
||||
import { EvidenceContext } from '../common/value-objects';
|
||||
|
||||
export interface Evidence {
|
||||
readonly id: EvidenceId;
|
||||
readonly observationIds: ObservationId[];
|
||||
readonly description: string;
|
||||
readonly context: EvidenceContext;
|
||||
readonly createdAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateEvidenceParams {
|
||||
readonly id: EvidenceId;
|
||||
readonly observationIds: ObservationId[];
|
||||
readonly description: string;
|
||||
readonly context: EvidenceContext;
|
||||
}
|
||||
|
||||
export class EvidenceFactory {
|
||||
static create(params: CreateEvidenceParams): Evidence {
|
||||
if (params.observationIds.length === 0) {
|
||||
throw new Error('Evidence must have at least one observation');
|
||||
}
|
||||
|
||||
return {
|
||||
...params,
|
||||
createdAt: new Date()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Finding - Entity
|
||||
* Pattern or conclusion derived from evidence
|
||||
*/
|
||||
|
||||
import { FindingId, EvidenceId } from '../common/ids';
|
||||
import { Severity, Confidence } from '../common/value-objects';
|
||||
|
||||
export interface Finding {
|
||||
readonly id: FindingId;
|
||||
readonly evidenceId: EvidenceId;
|
||||
readonly description: string;
|
||||
readonly severity: Severity;
|
||||
readonly pattern: string;
|
||||
readonly confidence: Confidence;
|
||||
readonly createdAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateFindingParams {
|
||||
readonly id: FindingId;
|
||||
readonly evidenceId: EvidenceId;
|
||||
readonly description: string;
|
||||
readonly severity: Severity;
|
||||
readonly pattern: string;
|
||||
readonly confidence: Confidence;
|
||||
}
|
||||
|
||||
export class FindingFactory {
|
||||
static create(params: CreateFindingParams): Finding {
|
||||
return {
|
||||
...params,
|
||||
createdAt: new Date()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @landvex/domain
|
||||
*
|
||||
* LandveX Domain Model - Control Intelligence Platform
|
||||
*
|
||||
* Three Rules:
|
||||
* 1. Domain knows nothing about AI
|
||||
* 2. Everything is an Artifact
|
||||
* 3. All decisions are reproducible
|
||||
*
|
||||
* This package has zero dependencies on Express, PostgreSQL, S3, or AI frameworks.
|
||||
*/
|
||||
|
||||
// Common
|
||||
export * from './common/ids';
|
||||
export * from './common/value-objects';
|
||||
export * from './common/enums';
|
||||
export * from './common/errors';
|
||||
|
||||
// Artifacts
|
||||
export * from './artifacts/artifact';
|
||||
|
||||
// Aggregate Roots
|
||||
export * from './session/session';
|
||||
export * from './mission/mission';
|
||||
export * from './decision-case/decision-case';
|
||||
|
||||
// Entities
|
||||
export * from './observation/observation';
|
||||
export * from './evidence/evidence';
|
||||
export * from './finding/finding';
|
||||
export * from './decision/decision';
|
||||
export * from './review/review';
|
||||
export * from './action/action';
|
||||
export * from './outcome/outcome';
|
||||
|
||||
// Events
|
||||
export * from './events/domain-events';
|
||||
|
||||
// Invariants
|
||||
export * from './invariants/invariants';
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Domain Invariants
|
||||
* Explicit rules that must always hold
|
||||
*/
|
||||
|
||||
import { FieldSession, Mission, DecisionCase } from '../index';
|
||||
import { InvariantViolationError } from '../common/errors';
|
||||
|
||||
export class DomainInvariants {
|
||||
static assertSessionValid(session: FieldSession): void {
|
||||
if (!session.location) {
|
||||
throw new InvariantViolationError('Session must have a location');
|
||||
}
|
||||
if (!session.date) {
|
||||
throw new InvariantViolationError('Session must have a date');
|
||||
}
|
||||
}
|
||||
|
||||
static assertMissionValid(mission: Mission): void {
|
||||
if (!mission.sessionId) {
|
||||
throw new InvariantViolationError('Mission must belong to a session');
|
||||
}
|
||||
if (mission.artifactIds.length === 0) {
|
||||
throw new InvariantViolationError('Mission must have at least one artifact');
|
||||
}
|
||||
}
|
||||
|
||||
static assertDecisionCaseValid(decisionCase: DecisionCase): void {
|
||||
if (decisionCase.observationIds.length === 0) {
|
||||
throw new InvariantViolationError('DecisionCase must have at least one observation');
|
||||
}
|
||||
if (decisionCase.evidenceIds.length === 0) {
|
||||
throw new InvariantViolationError('DecisionCase must have at least one evidence');
|
||||
}
|
||||
if (!decisionCase.decisionId) {
|
||||
throw new InvariantViolationError('DecisionCase must have exactly one decision');
|
||||
}
|
||||
if (decisionCase.status === 'approved' && !decisionCase.reviewId) {
|
||||
throw new InvariantViolationError('DecisionCase cannot be approved without review');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Mission - Aggregate Root
|
||||
* Single data collection task. Belongs to exactly one Session.
|
||||
*
|
||||
* Invariants:
|
||||
* - Must belong to exactly one Session
|
||||
* - Must have at least one Artifact
|
||||
* - Cannot be completed before import is finished
|
||||
* - Status is a projection of event history, not mutable state
|
||||
*/
|
||||
|
||||
import { MissionId, SessionId, ArtifactId, ObservationId } from '../common/ids';
|
||||
import { MissionStatus } from '../common/enums';
|
||||
import { GeoLocation, DeviceInfo } from '../common/value-objects';
|
||||
import { InvariantViolationError } from '../common/errors';
|
||||
|
||||
export interface Mission {
|
||||
readonly id: MissionId;
|
||||
readonly sessionId: SessionId;
|
||||
readonly location: GeoLocation;
|
||||
readonly status: MissionStatus;
|
||||
readonly device: DeviceInfo;
|
||||
readonly artifactIds: ArtifactId[];
|
||||
readonly observationIds: ObservationId[];
|
||||
readonly createdAt: Date;
|
||||
readonly updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateMissionParams {
|
||||
readonly id: MissionId;
|
||||
readonly sessionId: SessionId;
|
||||
readonly location: GeoLocation;
|
||||
readonly device: DeviceInfo;
|
||||
}
|
||||
|
||||
export class MissionFactory {
|
||||
static create(params: CreateMissionParams): Mission {
|
||||
if (!params.sessionId) {
|
||||
throw new InvariantViolationError('Mission must belong to a session');
|
||||
}
|
||||
if (!params.location) {
|
||||
throw new InvariantViolationError('Mission must have a location');
|
||||
}
|
||||
|
||||
return {
|
||||
id: params.id,
|
||||
sessionId: params.sessionId,
|
||||
location: params.location,
|
||||
status: MissionStatus.CREATED,
|
||||
device: params.device,
|
||||
artifactIds: [],
|
||||
observationIds: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
};
|
||||
}
|
||||
|
||||
static addArtifact(mission: Mission, artifactId: ArtifactId): Mission {
|
||||
return {
|
||||
...mission,
|
||||
artifactIds: [...mission.artifactIds, artifactId],
|
||||
status: MissionStatus.UPLOADING,
|
||||
updatedAt: new Date()
|
||||
};
|
||||
}
|
||||
|
||||
static addObservation(mission: Mission, observationId: ObservationId): Mission {
|
||||
return {
|
||||
...mission,
|
||||
observationIds: [...mission.observationIds, observationId],
|
||||
status: MissionStatus.PROCESSING,
|
||||
updatedAt: new Date()
|
||||
};
|
||||
}
|
||||
|
||||
static complete(mission: Mission): Mission {
|
||||
if (mission.artifactIds.length === 0) {
|
||||
throw new InvariantViolationError('Mission cannot be completed without artifacts');
|
||||
}
|
||||
if (mission.status === MissionStatus.UPLOADING) {
|
||||
throw new InvariantViolationError('Mission cannot be completed while uploading');
|
||||
}
|
||||
|
||||
return {
|
||||
...mission,
|
||||
status: MissionStatus.COMPLETED,
|
||||
updatedAt: new Date()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Observation - Entity
|
||||
* Recorded fact from reality. Not AI-specific.
|
||||
*
|
||||
* Rule 1: Domain knows nothing about AI
|
||||
* This is just an observation, not AIObservation or YOLODetection
|
||||
*/
|
||||
|
||||
import { ObservationId, MissionId, ArtifactId } from '../common/ids';
|
||||
import { GeoLocation, Confidence, BoundingBox } from '../common/value-objects';
|
||||
|
||||
export interface Observation {
|
||||
readonly id: ObservationId;
|
||||
readonly missionId: MissionId;
|
||||
readonly artifactId: ArtifactId;
|
||||
readonly type: string;
|
||||
readonly description: string;
|
||||
readonly location: GeoLocation;
|
||||
readonly confidence: Confidence;
|
||||
readonly boundingBox?: BoundingBox;
|
||||
readonly createdAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateObservationParams {
|
||||
readonly id: ObservationId;
|
||||
readonly missionId: MissionId;
|
||||
readonly artifactId: ArtifactId;
|
||||
readonly type: string;
|
||||
readonly description: string;
|
||||
readonly location: GeoLocation;
|
||||
readonly confidence: Confidence;
|
||||
readonly boundingBox?: BoundingBox;
|
||||
}
|
||||
|
||||
export class ObservationFactory {
|
||||
static create(params: CreateObservationParams): Observation {
|
||||
return {
|
||||
...params,
|
||||
createdAt: new Date()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface ObservationCapabilities {
|
||||
readonly detection: boolean;
|
||||
readonly vision: boolean;
|
||||
readonly gps: boolean;
|
||||
readonly image: boolean;
|
||||
}
|
||||
|
||||
export const DefaultObservationCapabilities: ObservationCapabilities = {
|
||||
detection: true,
|
||||
vision: true,
|
||||
gps: true,
|
||||
image: true
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Outcome - Entity
|
||||
* Measured result of an action
|
||||
*/
|
||||
|
||||
import { OutcomeId, ActionId } from '../common/ids';
|
||||
import { BusinessImpact } from '../common/value-objects';
|
||||
|
||||
export interface Outcome {
|
||||
readonly id: OutcomeId;
|
||||
readonly actionId: ActionId;
|
||||
readonly description: string;
|
||||
readonly measuredImpact: BusinessImpact;
|
||||
readonly verifiedBy: string;
|
||||
readonly createdAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateOutcomeParams {
|
||||
readonly id: OutcomeId;
|
||||
readonly actionId: ActionId;
|
||||
readonly description: string;
|
||||
readonly measuredImpact: BusinessImpact;
|
||||
readonly verifiedBy: string;
|
||||
}
|
||||
|
||||
export class OutcomeFactory {
|
||||
static create(params: CreateOutcomeParams): Outcome {
|
||||
return {
|
||||
...params,
|
||||
createdAt: new Date()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Review - Entity
|
||||
* Human quality assessment of a decision
|
||||
*
|
||||
* Invariants:
|
||||
* - Must have a reviewer
|
||||
* - Must have a status
|
||||
* - Cannot be approved without review
|
||||
*/
|
||||
|
||||
import { ReviewId, DecisionId } from '../common/ids';
|
||||
import { ReviewStatus } from '../common/enums';
|
||||
import { InvariantViolationError } from '../common/errors';
|
||||
|
||||
export interface Review {
|
||||
readonly id: ReviewId;
|
||||
readonly decisionId: DecisionId;
|
||||
readonly status: ReviewStatus;
|
||||
readonly reviewer: string;
|
||||
readonly comments: string;
|
||||
readonly createdAt: Date;
|
||||
readonly updatedAt?: Date;
|
||||
readonly completedAt?: Date;
|
||||
}
|
||||
|
||||
export interface CreateReviewParams {
|
||||
readonly id: ReviewId;
|
||||
readonly decisionId: DecisionId;
|
||||
readonly reviewer: string;
|
||||
}
|
||||
|
||||
export class ReviewFactory {
|
||||
static create(params: CreateReviewParams): Review {
|
||||
if (!params.reviewer) {
|
||||
throw new InvariantViolationError('Review must have a reviewer');
|
||||
}
|
||||
|
||||
return {
|
||||
...params,
|
||||
status: ReviewStatus.ASSIGNED,
|
||||
comments: '',
|
||||
createdAt: new Date()
|
||||
};
|
||||
}
|
||||
|
||||
static startReview(review: Review): Review {
|
||||
if (review.status !== ReviewStatus.ASSIGNED) {
|
||||
throw new InvariantViolationError('Cannot start review that is not assigned');
|
||||
}
|
||||
|
||||
return {
|
||||
...review,
|
||||
status: ReviewStatus.IN_REVIEW,
|
||||
updatedAt: new Date()
|
||||
};
|
||||
}
|
||||
|
||||
static approve(review: Review, comments: string): Review {
|
||||
if (review.status !== ReviewStatus.IN_REVIEW) {
|
||||
throw new InvariantViolationError('Cannot approve review that is not in review');
|
||||
}
|
||||
|
||||
return {
|
||||
...review,
|
||||
status: ReviewStatus.APPROVED,
|
||||
comments,
|
||||
completedAt: new Date()
|
||||
};
|
||||
}
|
||||
|
||||
static reject(review: Review, comments: string): Review {
|
||||
if (review.status !== ReviewStatus.IN_REVIEW) {
|
||||
throw new InvariantViolationError('Cannot reject review that is not in review');
|
||||
}
|
||||
|
||||
return {
|
||||
...review,
|
||||
status: ReviewStatus.REJECTED,
|
||||
comments,
|
||||
completedAt: new Date()
|
||||
};
|
||||
}
|
||||
|
||||
static requestMoreEvidence(review: Review, comments: string): Review {
|
||||
if (review.status !== ReviewStatus.IN_REVIEW) {
|
||||
throw new InvariantViolationError('Cannot request evidence for review that is not in review');
|
||||
}
|
||||
|
||||
return {
|
||||
...review,
|
||||
status: ReviewStatus.NEEDS_MORE_EVIDENCE,
|
||||
comments,
|
||||
completedAt: new Date()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Tests for FieldSession aggregate root
|
||||
*/
|
||||
|
||||
import { FieldSessionFactory } from './session';
|
||||
import { IdFactory } from '../common/ids';
|
||||
import { SessionStatus } from '../common/enums';
|
||||
import { InvariantViolationError } from '../common/errors';
|
||||
|
||||
describe('FieldSession', () => {
|
||||
const testDate = new Date('2026-08-14');
|
||||
const testLocation = { lat: 59.2371, lng: 18.1456 };
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a session with correct defaults', () => {
|
||||
const session = FieldSessionFactory.create({
|
||||
id: IdFactory.session(testDate, 1),
|
||||
location: testLocation,
|
||||
date: testDate
|
||||
});
|
||||
|
||||
expect(session.status).toBe(SessionStatus.PLANNED);
|
||||
expect(session.missionIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('should throw if location is missing', () => {
|
||||
expect(() => {
|
||||
FieldSessionFactory.create({
|
||||
id: IdFactory.session(testDate, 1),
|
||||
location: null as any,
|
||||
date: testDate
|
||||
});
|
||||
}).toThrow(InvariantViolationError);
|
||||
});
|
||||
|
||||
it('should throw if date is missing', () => {
|
||||
expect(() => {
|
||||
FieldSessionFactory.create({
|
||||
id: IdFactory.session(testDate, 1),
|
||||
location: testLocation,
|
||||
date: null as any
|
||||
});
|
||||
}).toThrow(InvariantViolationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addMission', () => {
|
||||
it('should add mission and set status to active', () => {
|
||||
const session = FieldSessionFactory.create({
|
||||
id: IdFactory.session(testDate, 1),
|
||||
location: testLocation,
|
||||
date: testDate
|
||||
});
|
||||
|
||||
const missionId = IdFactory.mission(testDate, 1);
|
||||
const updated = FieldSessionFactory.addMission(session, missionId);
|
||||
|
||||
expect(updated.missionIds).toContain(missionId);
|
||||
expect(updated.status).toBe(SessionStatus.ACTIVE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('complete', () => {
|
||||
it('should complete active session', () => {
|
||||
const session = FieldSessionFactory.create({
|
||||
id: IdFactory.session(testDate, 1),
|
||||
location: testLocation,
|
||||
date: testDate
|
||||
});
|
||||
|
||||
const withMission = FieldSessionFactory.addMission(session, IdFactory.mission(testDate, 1));
|
||||
const completed = FieldSessionFactory.complete(withMission);
|
||||
|
||||
expect(completed.status).toBe(SessionStatus.COMPLETED);
|
||||
});
|
||||
|
||||
it('should throw if session is not active', () => {
|
||||
const session = FieldSessionFactory.create({
|
||||
id: IdFactory.session(testDate, 1),
|
||||
location: testLocation,
|
||||
date: testDate
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
FieldSessionFactory.complete(session);
|
||||
}).toThrow(InvariantViolationError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* FieldSession - Aggregate Root
|
||||
* Organizes field work. A pilot day produces many missions.
|
||||
*
|
||||
* Invariants:
|
||||
* - Must have exactly one location
|
||||
* - Must have a date
|
||||
* - Can have zero or more missions
|
||||
* - Cannot be completed if any mission is still uploading
|
||||
*/
|
||||
|
||||
import { SessionId, MissionId } from '../common/ids';
|
||||
import { SessionStatus } from '../common/enums';
|
||||
import { GeoLocation } from '../common/value-objects';
|
||||
import { InvariantViolationError } from '../common/errors';
|
||||
|
||||
export interface FieldSession {
|
||||
readonly id: SessionId;
|
||||
readonly location: GeoLocation;
|
||||
readonly date: Date;
|
||||
readonly status: SessionStatus;
|
||||
readonly missionIds: MissionId[];
|
||||
readonly createdAt: Date;
|
||||
readonly updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateSessionParams {
|
||||
readonly id: SessionId;
|
||||
readonly location: GeoLocation;
|
||||
readonly date: Date;
|
||||
}
|
||||
|
||||
export class FieldSessionFactory {
|
||||
static create(params: CreateSessionParams): FieldSession {
|
||||
if (!params.location) {
|
||||
throw new InvariantViolationError('Session must have a location');
|
||||
}
|
||||
if (!params.date) {
|
||||
throw new InvariantViolationError('Session must have a date');
|
||||
}
|
||||
|
||||
return {
|
||||
id: params.id,
|
||||
location: params.location,
|
||||
date: params.date,
|
||||
status: SessionStatus.PLANNED,
|
||||
missionIds: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
};
|
||||
}
|
||||
|
||||
static addMission(session: FieldSession, missionId: MissionId): FieldSession {
|
||||
return {
|
||||
...session,
|
||||
missionIds: [...session.missionIds, missionId],
|
||||
status: SessionStatus.ACTIVE,
|
||||
updatedAt: new Date()
|
||||
};
|
||||
}
|
||||
|
||||
static complete(session: FieldSession): FieldSession {
|
||||
if (session.status !== SessionStatus.ACTIVE) {
|
||||
throw new InvariantViolationError('Cannot complete session that is not active');
|
||||
}
|
||||
|
||||
return {
|
||||
...session,
|
||||
status: SessionStatus.COMPLETED,
|
||||
updatedAt: new Date()
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user