"""Business rules and orchestration for repair workflows.""" from uuid import UUID from app.repairs.exceptions import ( ActiveRepairConflict, IncompatibleMinerLifecycle, InconsistentRepairState, RepairActorRequired, RepairMinerNotFound, RepairNotFound, ) from app.repairs.models import ( BeginRepairRequest, BeginRepairResult, InitialRepairNotePayload, RepairActor, RepairDetail, RepairTimeline, RepairTimelineEvent, RepairStartedPayload, ) from app.repairs.repository import RepairRepository ELIGIBLE_LIFECYCLE_STATUSES = frozenset({"active", "spare"}) REPAIR_LIFECYCLE_STATUS = "in_repair" class RepairService: """Explicit operational repair workflows independent of FastAPI.""" def __init__(self, repository: RepairRepository): self.repository = repository @staticmethod def normalize_note(note: str | None) -> str | None: if note is None: return None normalized = str(note).strip() return normalized or None def begin_repair( self, request: BeginRepairRequest, *, actor_user_id: UUID | None, ) -> BeginRepairResult: if actor_user_id is None: raise RepairActorRequired() note = self.normalize_note(request.notes) with self.repository.transaction() as repository: miner = repository.lock_miner_for_update(request.miner_id) if not miner: raise RepairMinerNotFound(request.miner_id) lifecycle_status = str(miner["lifecycle_status"]) active_repair = repository.get_active_repair(request.miner_id) if active_repair: raise ActiveRepairConflict(request.miner_id) if lifecycle_status == REPAIR_LIFECYCLE_STATUS: raise InconsistentRepairState(request.miner_id) if lifecycle_status not in ELIGIBLE_LIFECYCLE_STATUSES: raise IncompatibleMinerLifecycle( request.miner_id, lifecycle_status, ) repair = repository.create_repair( request.miner_id, request.reason, actor_user_id, ) started_payload = RepairStartedPayload( reason=request.reason, from_lifecycle=lifecycle_status, ) repository.create_event( repair["id"], sequence=1, event_type="REPAIR_STARTED", created_by=actor_user_id, payload=started_payload.model_dump(mode="json"), ) if note is not None: note_payload = InitialRepairNotePayload(note=note) repository.create_event( repair["id"], sequence=2, event_type="INITIAL_NOTE", created_by=actor_user_id, payload=note_payload.model_dump(mode="json"), ) repository.set_miner_lifecycle( request.miner_id, REPAIR_LIFECYCLE_STATUS, ) return BeginRepairResult( repair_id=repair["id"], miner_id=repair["miner_id"], status=repair["status"], reason=repair["reason"], opened_at=repair["opened_at"], opened_by=repair["opened_by"], ) def get_repair(self, repair_id: UUID) -> RepairDetail: repair = self.repository.get_repair_detail(repair_id) if repair is None: raise RepairNotFound(repair_id) closed_by = None if repair["closed_by_user_id"] is not None: closed_by = RepairActor( user_id=repair["closed_by_user_id"], display_name=repair["closed_by_display_name"], ) return RepairDetail( repair_id=repair["repair_id"], miner_id=repair["miner_id"], miner_asset_id=repair["miner_asset_id"], status=repair["status"], reason=repair["reason"], opened_at=repair["opened_at"], opened_by=RepairActor( user_id=repair["opened_by_user_id"], display_name=repair["opened_by_display_name"], ), closed_at=repair["closed_at"], closed_by=closed_by, miner_lifecycle=repair["miner_lifecycle"], latest_event_sequence=repair["latest_event_sequence"], ) def get_repair_timeline(self, repair_id: UUID) -> RepairTimeline: events = self.repository.get_repair_events(repair_id) if events is None: raise RepairNotFound(repair_id) return RepairTimeline( repair_id=repair_id, events=[ RepairTimelineEvent( event_id=event["event_id"], sequence=event["sequence"], event_type=event["event_type"], actor=RepairActor( user_id=event["actor_user_id"], display_name=event["actor_display_name"], ), occurred_at=event["occurred_at"], payload=event["payload"], ) for event in events ], )