From fa0dbf51274e6b38bbc9a604585a634d06318b17 Mon Sep 17 00:00:00 2001 From: Bernt Date: Thu, 2 Jul 2026 14:26:29 +0000 Subject: [PATCH] =?UTF-8?q?PR-001:=20Domain=20Model=20v1.0=20=E2=80=94=20c?= =?UTF-8?q?ompile-only,=20zero=20dependencies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @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 --- packages/domain/.gitignore | 4 + packages/domain/README.md | 147 ++++++++++++++++++ packages/domain/jest.config.js | 22 +++ packages/domain/package.json | 22 +++ packages/domain/src/action/action.ts | 34 ++++ packages/domain/src/artifacts/artifact.ts | 42 +++++ packages/domain/src/common/enums.ts | 58 +++++++ packages/domain/src/common/errors.ts | 45 ++++++ packages/domain/src/common/ids.test.ts | 49 ++++++ packages/domain/src/common/ids.ts | 63 ++++++++ packages/domain/src/common/value-objects.ts | 104 +++++++++++++ .../src/decision-case/decision-case.test.ts | 126 +++++++++++++++ .../domain/src/decision-case/decision-case.ts | 79 ++++++++++ packages/domain/src/decision/decision.ts | 58 +++++++ packages/domain/src/events/domain-events.ts | 127 +++++++++++++++ packages/domain/src/evidence/evidence.ts | 35 +++++ packages/domain/src/finding/finding.ts | 35 +++++ packages/domain/src/index.ts | 41 +++++ packages/domain/src/invariants/invariants.ts | 42 +++++ packages/domain/src/mission/mission.ts | 90 +++++++++++ .../domain/src/observation/observation.ts | 56 +++++++ packages/domain/src/outcome/outcome.ts | 33 ++++ packages/domain/src/review/review.ts | 96 ++++++++++++ packages/domain/src/session/session.test.ts | 89 +++++++++++ packages/domain/src/session/session.ts | 73 +++++++++ packages/domain/tsconfig.json | 29 ++++ 26 files changed, 1599 insertions(+) create mode 100644 packages/domain/.gitignore create mode 100644 packages/domain/README.md create mode 100644 packages/domain/jest.config.js create mode 100644 packages/domain/package.json create mode 100644 packages/domain/src/action/action.ts create mode 100644 packages/domain/src/artifacts/artifact.ts create mode 100644 packages/domain/src/common/enums.ts create mode 100644 packages/domain/src/common/errors.ts create mode 100644 packages/domain/src/common/ids.test.ts create mode 100644 packages/domain/src/common/ids.ts create mode 100644 packages/domain/src/common/value-objects.ts create mode 100644 packages/domain/src/decision-case/decision-case.test.ts create mode 100644 packages/domain/src/decision-case/decision-case.ts create mode 100644 packages/domain/src/decision/decision.ts create mode 100644 packages/domain/src/events/domain-events.ts create mode 100644 packages/domain/src/evidence/evidence.ts create mode 100644 packages/domain/src/finding/finding.ts create mode 100644 packages/domain/src/index.ts create mode 100644 packages/domain/src/invariants/invariants.ts create mode 100644 packages/domain/src/mission/mission.ts create mode 100644 packages/domain/src/observation/observation.ts create mode 100644 packages/domain/src/outcome/outcome.ts create mode 100644 packages/domain/src/review/review.ts create mode 100644 packages/domain/src/session/session.test.ts create mode 100644 packages/domain/src/session/session.ts create mode 100644 packages/domain/tsconfig.json diff --git a/packages/domain/.gitignore b/packages/domain/.gitignore new file mode 100644 index 000000000..e6d5efa18 --- /dev/null +++ b/packages/domain/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +coverage/ +*.log diff --git a/packages/domain/README.md b/packages/domain/README.md new file mode 100644 index 000000000..c1ce9f6bd --- /dev/null +++ b/packages/domain/README.md @@ -0,0 +1,147 @@ +# @landvex/domain + +**LandveX Domain Model - Control Intelligence Platform** + +This package describes the LandveX domain model. It contains no dependencies to database, HTTP, cloud storage, or AI frameworks. It defines only the language, objects, and rules that the rest of the platform builds upon. + +## Three Rules + +### Rule 1: Domain knows nothing about AI + +**No AI-specific objects in domain:** +- ❌ `AIObservation` +- ❌ `YOLODetection` +- ❌ `GeminiResult` +- ❌ `ClaudeAnalysis` + +**Domain only knows:** +- ✅ `Observation` +- ✅ `Evidence` +- ✅ `Finding` +- ✅ `Decision` +- ✅ `Review` +- ✅ `Outcome` + +If we switch from YOLO to a custom model in five years, the domain does not change. + +### Rule 2: Everything is an Artifact + +`Artifact` is a common contract for everything produced: + +``` +Artifact +├── Image +├── Video +├── Dataset +├── Annotation +├── Model +├── Evaluation +├── DecisionCase +├── Report +└── ExperienceInsight +``` + +All artifacts share: +- `id` +- `version` +- `hash` +- `lineage` +- `createdAt` +- `createdBy` +- `storageUri` + +### Rule 3: All decisions are reproducible + +Every Decision Case must answer: +- Which observations were used? +- Which evidence was used? +- Which model? +- Which model version? +- Which rules? +- Who reviewed? +- When? +- Which version was approved? + +## Package Structure + +``` +packages/domain/ +├── common/ +│ ├── value-objects/ +│ ├── ids/ +│ └── errors/ +├── artifacts/ +├── session/ +├── mission/ +├── observation/ +├── evidence/ +├── finding/ +├── decision/ +├── review/ +├── action/ +├── outcome/ +├── events/ +├── invariants/ +├── index.ts +└── README.md +``` + +## Aggregate Roots + +- `FieldSession` - Organizes field work +- `Mission` - Single data collection task +- `DecisionCase` - Complete decision chain + +## Entities + +- `Artifact` - Any produced artifact (versioned, immutable) +- `Observation` - Recorded fact from reality +- `Evidence` - Linked observations with context +- `Finding` - Pattern or conclusion +- `Decision` - Recommended action +- `Review` - Human quality assessment +- `Action` - Executed task +- `Outcome` - Measured result + +## Value Objects + +- `GeoLocation` - Latitude and longitude +- `Confidence` - Model confidence 0-1 +- `Severity` - Issue severity (low/medium/high/critical) +- `Priority` - Action priority (low/medium/high/urgent) +- `Hash` - SHA-256 checksum +- `StorageUri` - Storage location +- `Version` - Semantic version +- `BusinessImpact` - Risk, cost, time, opportunity + +## Enums + +- `ArtifactType` - image, video, dataset, model, etc. +- `ReviewStatus` - assigned, in_review, approved, rejected +- `DecisionVerb` - inspect, repair, monitor, wait, collect, escalate, ignore, prioritize +- `MissionStatus` - created, uploading, processing, completed, failed +- `SessionStatus` - planned, active, completed + +## Domain Events + +- `FieldSessionCreated` +- `MissionCreated` +- `ArtifactRegistered` +- `ObservationCreated` +- `EvidenceLinked` +- `FindingCreated` +- `DecisionCreated` +- `DecisionReviewed` +- `DecisionApproved` + +## Invariants + +- **FieldSession**: Must have location and date +- **Mission**: Must belong to one Session, must have at least one Artifact +- **DecisionCase**: Must have at least one Observation, one Evidence, exactly one Decision. Cannot be Approved without Review. + +## Strict Rule + +**No new feature may introduce new domain concepts without an approved change to the domain model.** + +The concepts `Session`, `Mission`, `Artifact`, `Observation`, `Evidence`, `Finding`, `Decision`, `Review`, and `Outcome` become the shared language for the entire organization — code, documentation, APIs, tests, and product discussions use the same terms. diff --git a/packages/domain/jest.config.js b/packages/domain/jest.config.js new file mode 100644 index 000000000..d8325a0b3 --- /dev/null +++ b/packages/domain/jest.config.js @@ -0,0 +1,22 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: ['**/*.test.ts'], + transform: { + '^.+\\.ts$': 'ts-jest', + }, + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.test.ts', + '!src/index.ts' + ], + coverageThreshold: { + global: { + branches: 80, + functions: 80, + lines: 80, + statements: 80 + } + } +}; diff --git a/packages/domain/package.json b/packages/domain/package.json new file mode 100644 index 000000000..49a16d135 --- /dev/null +++ b/packages/domain/package.json @@ -0,0 +1,22 @@ +{ + "name": "@landvex/domain", + "version": "0.1.0", + "description": "LandveX domain model - Control Intelligence Platform", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "test": "jest", + "lint": "eslint src/**/*.ts" + }, + "devDependencies": { + "typescript": "^5.0.0", + "jest": "^29.0.0", + "@types/jest": "^29.0.0", + "ts-jest": "^29.0.0", + "eslint": "^8.0.0", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0" + }, + "dependencies": {} +} diff --git a/packages/domain/src/action/action.ts b/packages/domain/src/action/action.ts new file mode 100644 index 000000000..8770b82f8 --- /dev/null +++ b/packages/domain/src/action/action.ts @@ -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() + }; + } +} diff --git a/packages/domain/src/artifacts/artifact.ts b/packages/domain/src/artifacts/artifact.ts new file mode 100644 index 000000000..5492743b3 --- /dev/null +++ b/packages/domain/src/artifacts/artifact.ts @@ -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.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 } +}; diff --git a/packages/domain/src/common/enums.ts b/packages/domain/src/common/enums.ts new file mode 100644 index 000000000..b77f3f369 --- /dev/null +++ b/packages/domain/src/common/enums.ts @@ -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' +} diff --git a/packages/domain/src/common/errors.ts b/packages/domain/src/common/errors.ts new file mode 100644 index 000000000..b64574b10 --- /dev/null +++ b/packages/domain/src/common/errors.ts @@ -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}`); + } +} diff --git a/packages/domain/src/common/ids.test.ts b/packages/domain/src/common/ids.test.ts new file mode 100644 index 000000000..4c7f5e558 --- /dev/null +++ b/packages/domain/src/common/ids.test.ts @@ -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'); + }); + }); +}); diff --git a/packages/domain/src/common/ids.ts b/packages/domain/src/common/ids.ts new file mode 100644 index 000000000..b1a58956d --- /dev/null +++ b/packages/domain/src/common/ids.ts @@ -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; + } +}; diff --git a/packages/domain/src/common/value-objects.ts b/packages/domain/src/common/value-objects.ts new file mode 100644 index 000000000..3778052e4 --- /dev/null +++ b/packages/domain/src/common/value-objects.ts @@ -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 +}; diff --git a/packages/domain/src/decision-case/decision-case.test.ts b/packages/domain/src/decision-case/decision-case.test.ts new file mode 100644 index 000000000..de227ae73 --- /dev/null +++ b/packages/domain/src/decision-case/decision-case.test.ts @@ -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); + }); + }); +}); diff --git a/packages/domain/src/decision-case/decision-case.ts b/packages/domain/src/decision-case/decision-case.ts new file mode 100644 index 000000000..664094a1e --- /dev/null +++ b/packages/domain/src/decision-case/decision-case.ts @@ -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): DecisionCase { + return { + ...decisionCase, + ...params, + version: decisionCase.version + 1, + status: DecisionStatus.PENDING + }; + } +} diff --git a/packages/domain/src/decision/decision.ts b/packages/domain/src/decision/decision.ts new file mode 100644 index 000000000..7f54df6e3 --- /dev/null +++ b/packages/domain/src/decision/decision.ts @@ -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 +}; diff --git a/packages/domain/src/events/domain-events.ts b/packages/domain/src/events/domain-events.ts new file mode 100644 index 000000000..dc9d5a9d2 --- /dev/null +++ b/packages/domain/src/events/domain-events.ts @@ -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; diff --git a/packages/domain/src/evidence/evidence.ts b/packages/domain/src/evidence/evidence.ts new file mode 100644 index 000000000..70034c1c1 --- /dev/null +++ b/packages/domain/src/evidence/evidence.ts @@ -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() + }; + } +} diff --git a/packages/domain/src/finding/finding.ts b/packages/domain/src/finding/finding.ts new file mode 100644 index 000000000..28cd5fb41 --- /dev/null +++ b/packages/domain/src/finding/finding.ts @@ -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() + }; + } +} diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts new file mode 100644 index 000000000..6eceb475e --- /dev/null +++ b/packages/domain/src/index.ts @@ -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'; diff --git a/packages/domain/src/invariants/invariants.ts b/packages/domain/src/invariants/invariants.ts new file mode 100644 index 000000000..1de916037 --- /dev/null +++ b/packages/domain/src/invariants/invariants.ts @@ -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'); + } + } +} diff --git a/packages/domain/src/mission/mission.ts b/packages/domain/src/mission/mission.ts new file mode 100644 index 000000000..52d4b0d6c --- /dev/null +++ b/packages/domain/src/mission/mission.ts @@ -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() + }; + } +} diff --git a/packages/domain/src/observation/observation.ts b/packages/domain/src/observation/observation.ts new file mode 100644 index 000000000..9100ede4b --- /dev/null +++ b/packages/domain/src/observation/observation.ts @@ -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 +}; diff --git a/packages/domain/src/outcome/outcome.ts b/packages/domain/src/outcome/outcome.ts new file mode 100644 index 000000000..71836abb4 --- /dev/null +++ b/packages/domain/src/outcome/outcome.ts @@ -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() + }; + } +} diff --git a/packages/domain/src/review/review.ts b/packages/domain/src/review/review.ts new file mode 100644 index 000000000..514b88dcf --- /dev/null +++ b/packages/domain/src/review/review.ts @@ -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() + }; + } +} diff --git a/packages/domain/src/session/session.test.ts b/packages/domain/src/session/session.test.ts new file mode 100644 index 000000000..04a00fbb7 --- /dev/null +++ b/packages/domain/src/session/session.test.ts @@ -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); + }); + }); +}); diff --git a/packages/domain/src/session/session.ts b/packages/domain/src/session/session.ts new file mode 100644 index 000000000..3f5d46315 --- /dev/null +++ b/packages/domain/src/session/session.ts @@ -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() + }; + } +} diff --git a/packages/domain/tsconfig.json b/packages/domain/tsconfig.json new file mode 100644 index 000000000..025683564 --- /dev/null +++ b/packages/domain/tsconfig.json @@ -0,0 +1,29 @@ +{ + "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, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +}