use forge_models::{ CadActivityEntry, CadAssignmentMutationResult, CadDispatchOrderContextSeed, CadDispatchOrderCreateSeed, CadDispatchOrderMutationResult, CadGroupBuildSeed, CadGroupProfileMutationResult, CadGroupProfileUpdateSeed, CadHydratePayload, CadHydrateSeed, CadRecord, CadRequestMutationResult, CadSession, CadSupportRequestSubmitSeed, }; use forge_repositories::CadRepository; use serde_json::{Map, Value}; use std::collections::HashMap; const CAD_ACTIVITY_LIMIT: usize = 200; const CAD_RECENT_ACTIVITY_LIMIT: usize = 50; pub struct CadStateService { repository: R, } impl CadStateService { pub fn new(repository: R) -> Self { Self { repository } } pub fn append_activity(&self, json_data: String) -> Result<(), String> { let entry = Self::parse_value(&json_data)?; self.repository.append_activity(entry) } pub fn recent_activity(&self, limit: String) -> Result, String> { let parsed_limit = limit .trim() .parse::() .ok() .filter(|value| *value > 0) .unwrap_or(CAD_RECENT_ACTIVITY_LIMIT) .min(CAD_ACTIVITY_LIMIT); self.repository.recent_activity(parsed_limit) } pub fn list_assignments(&self) -> Result, String> { Ok(Self::records_to_values(self.repository.list_assignments()?)) } pub fn assign_assignment( &self, entry_id: String, json_data: String, ) -> Result { let entry_id = Self::validate_entry_id(entry_id)?; let mut assignment = Self::parse_record(&json_data)?; Self::set_task_id(&mut assignment, &entry_id); self.repository .save_assignment(entry_id.clone(), assignment.clone())?; let assignee = Self::display_group_name(&assignment.fields); let assigned_by = Self::string_field(&assignment.fields, "assignedByName") .unwrap_or_else(|| "Dispatcher".to_string()); let group_id = Self::string_field(&assignment.fields, "groupId").unwrap_or_default(); let actor_uid = Self::string_field(&assignment.fields, "assignedByUid").unwrap_or_default(); Ok(CadAssignmentMutationResult { assignment: assignment.into_value(), message: "Task assigned.".to_string(), activity: Self::build_activity( "task_assigned", format!("{assigned_by} assigned {entry_id} to {assignee}."), entry_id, group_id, actor_uid, ), }) } pub fn acknowledge_assignment( &self, entry_id: String, json_data: String, ) -> Result { let entry_id = Self::validate_entry_id(entry_id)?; let patch = Self::parse_record(&json_data)?; let existing = self .repository .get_assignment(&entry_id)? .ok_or_else(|| "CAD assignment could not be resolved.".to_string())?; let merged = existing.merge(patch); self.repository.save_assignment(entry_id, merged.clone())?; Ok(CadAssignmentMutationResult { assignment: merged.to_value(), message: "Task acknowledged.".to_string(), activity: Self::build_activity( "task_acknowledged", format!( "{} acknowledged {}.", Self::string_field(&merged.fields, "acknowledgedByUid").unwrap_or_default(), Self::string_field(&merged.fields, "taskId").unwrap_or_default() ), Self::string_field(&merged.fields, "taskId").unwrap_or_default(), Self::string_field(&merged.fields, "groupId").unwrap_or_default(), Self::string_field(&merged.fields, "acknowledgedByUid").unwrap_or_default(), ), }) } pub fn decline_assignment( &self, entry_id: String, json_data: String, ) -> Result { let entry_id = Self::validate_entry_id(entry_id)?; let patch = Self::parse_record(&json_data)?; let existing = self .repository .get_assignment(&entry_id)? .ok_or_else(|| "CAD assignment could not be resolved.".to_string())?; let merged = existing.merge(patch); self.repository.delete_assignment(&entry_id)?; Ok(CadAssignmentMutationResult { assignment: merged.to_value(), message: "Task declined and returned to the contract board.".to_string(), activity: Self::build_activity( "task_declined", format!( "{} declined {}.", Self::string_field(&merged.fields, "declinedByUid").unwrap_or_default(), Self::string_field(&merged.fields, "taskId").unwrap_or_default() ), Self::string_field(&merged.fields, "taskId").unwrap_or_default(), Self::string_field(&merged.fields, "groupId").unwrap_or_default(), Self::string_field(&merged.fields, "declinedByUid").unwrap_or_default(), ), }) } pub fn upsert_assignment(&self, entry_id: String, json_data: String) -> Result<(), String> { let entry_id = Self::validate_entry_id(entry_id)?; let entry = Self::parse_record(&json_data)?; self.repository.save_assignment(entry_id, entry) } pub fn delete_assignment(&self, entry_id: String) -> Result<(), String> { let entry_id = Self::validate_entry_id(entry_id)?; self.repository.delete_assignment(&entry_id) } pub fn list_orders(&self) -> Result, String> { Ok(Self::records_to_values(self.repository.list_orders()?)) } pub fn create_order( &self, json_data: String, ) -> Result { let payload = serde_json::from_str::(&json_data) .map_err(|error| format!("Invalid CAD order payload: {error}"))?; if payload.order.is_empty() { return Err("Order payload is required.".to_string()); } if payload.assignment.is_empty() { return Err("Assignment payload is required.".to_string()); } let task_id = self.repository.next_order_id()?; let mut order = payload.order; let mut assignment = payload.assignment; Self::set_task_id(&mut order, &task_id); order .fields .insert("isDispatchOrder".to_string(), Value::Bool(true)); Self::set_task_id(&mut assignment, &task_id); self.repository.save_order(task_id.clone(), order.clone())?; self.repository .save_assignment(task_id.clone(), assignment.clone())?; Ok(CadDispatchOrderMutationResult { task_id: task_id.clone(), order: order.to_value(), assignment: assignment.to_value(), message: "Dispatch order created.".to_string(), activity: Self::build_activity( "dispatch_order_created", format!( "{} created backup order {task_id} for {} to support {}.", Self::string_field(&order.fields, "createdByName") .unwrap_or_else(|| "Dispatcher".to_string()), Self::display_group_name(&assignment.fields), Self::string_field(&order.fields, "targetGroupCallsign") .unwrap_or_else(|| Self::string_field(&order.fields, "targetGroupId") .unwrap_or_else(|| "target group".to_string())) ), task_id, Self::string_field(&assignment.fields, "groupId").unwrap_or_default(), Self::string_field(&order.fields, "createdByUid").unwrap_or_default(), ), }) } pub fn create_order_from_context( &self, json_data: String, ) -> Result { let seed = serde_json::from_str::(&json_data) .map_err(|error| format!("Invalid CAD order context: {error}"))?; if seed.assignee_group_id.trim().is_empty() || seed.target_group_id.trim().is_empty() { return Err("Assignee and target groups are required.".to_string()); } let final_priority = Self::normalize_priority(&seed.priority); let target_callsign = Self::fallback_string(&seed.target_group_callsign, &seed.target_group_id); let created_by_name = Self::fallback_string(&seed.created_by_name, "Dispatcher"); let assignee_callsign = Self::fallback_string(&seed.assignee_group_callsign, &seed.assignee_group_id); let order = CadRecord { fields: Map::from_iter([ ( "title".to_string(), Value::String(format!("Backup {target_callsign}")), ), ( "description".to_string(), Value::String(if seed.note.trim().is_empty() { format!( "Dispatch order to back up {target_callsign} at its current position." ) } else { seed.note.clone() }), ), ( "type".to_string(), Value::String("dispatch_order".to_string()), ), ("priority".to_string(), Value::String(final_priority)), ("position".to_string(), seed.target_position.clone()), ( "targetGroupId".to_string(), Value::String(seed.target_group_id.clone()), ), ( "targetGroupCallsign".to_string(), Value::String(target_callsign.clone()), ), ( "createdByUid".to_string(), Value::String(seed.created_by_uid.clone()), ), ( "createdByName".to_string(), Value::String(created_by_name.clone()), ), ( "sourceRequestId".to_string(), Value::String(seed.request_id.clone()), ), ( "sourceRequestType".to_string(), Value::String(seed.request_type.clone()), ), ( "sourceRequestTitle".to_string(), Value::String(seed.request_title.clone()), ), ( "sourceRequestSummary".to_string(), Value::String(seed.request_summary.clone()), ), ( "sourceRequestFields".to_string(), seed.request_fields.to_value(), ), ("createdAt".to_string(), Value::from(seed.created_at)), ("note".to_string(), Value::String(seed.note.clone())), ("isDispatchOrder".to_string(), Value::Bool(true)), ]), }; let assignment = CadRecord { fields: Map::from_iter([ ( "groupId".to_string(), Value::String(seed.assignee_group_id.clone()), ), ( "assigneeGroupCallsign".to_string(), Value::String(assignee_callsign.clone()), ), ( "assignedByUid".to_string(), Value::String(seed.created_by_uid.clone()), ), ( "assignedByName".to_string(), Value::String(created_by_name.clone()), ), ("assignedAt".to_string(), Value::from(seed.created_at)), ("state".to_string(), Value::String("assigned".to_string())), ("note".to_string(), Value::String(seed.note)), ]), }; let payload = CadDispatchOrderCreateSeed { order, assignment }; self.create_order( serde_json::to_string(&payload) .map_err(|error| format!("Failed to serialize CAD order payload: {error}"))?, ) } pub fn close_order(&self, entry_id: String) -> Result { let entry_id = Self::validate_entry_id(entry_id)?; let order = self .repository .get_order(&entry_id)? .ok_or_else(|| "CAD order could not be resolved.".to_string())?; let assignment = self.repository.get_assignment(&entry_id)?; self.repository.delete_order(&entry_id)?; self.repository.delete_assignment(&entry_id)?; Ok(CadDispatchOrderMutationResult { task_id: entry_id.clone(), order: order.to_value(), assignment: assignment.map_or(Value::Null, CadRecord::into_value), message: "Dispatch order closed.".to_string(), activity: Self::build_activity( "dispatch_order_closed", format!("{entry_id} was closed."), entry_id, Self::string_field(&order.fields, "groupId").unwrap_or_default(), String::new(), ), }) } pub fn upsert_order(&self, entry_id: String, json_data: String) -> Result<(), String> { let entry_id = Self::validate_entry_id(entry_id)?; let entry = Self::parse_record(&json_data)?; self.repository.save_order(entry_id, entry) } pub fn delete_order(&self, entry_id: String) -> Result<(), String> { let entry_id = Self::validate_entry_id(entry_id)?; self.repository.delete_order(&entry_id) } pub fn list_requests(&self) -> Result, String> { Ok(Self::records_to_values(self.repository.list_requests()?)) } pub fn submit_request(&self, json_data: String) -> Result { let mut request = Self::parse_record(&json_data)?; let request_id = self.repository.next_request_id()?; request .fields .insert("requestId".to_string(), Value::String(request_id.clone())); self.repository.save_request(request_id, request.clone())?; Ok(CadRequestMutationResult { request: request.to_value(), message: "Support request submitted.".to_string(), activity: Self::build_activity( "support_request_submitted", format!( "{} submitted {}.", Self::string_field(&request.fields, "groupCallsign") .unwrap_or_else(|| "Unknown Group".to_string()), Self::string_field(&request.fields, "title") .unwrap_or_else(|| "support request".to_string()) ), Self::string_field(&request.fields, "requestId").unwrap_or_default(), Self::string_field(&request.fields, "groupId").unwrap_or_default(), Self::string_field(&request.fields, "submittedByUid").unwrap_or_default(), ), }) } pub fn submit_request_from_context( &self, json_data: String, ) -> Result { let seed = serde_json::from_str::(&json_data) .map_err(|error| format!("Invalid CAD support request context: {error}"))?; if seed.request_type.trim().is_empty() { return Err("Support request type is required.".to_string()); } if seed.group_id.trim().is_empty() { return Err("Group ID is required.".to_string()); } let request_type = seed.request_type.to_lowercase(); let group_callsign = Self::fallback_string(&seed.group_callsign, &seed.group_id); let request = CadRecord { fields: Map::from_iter([ ("type".to_string(), Value::String(request_type.clone())), ( "title".to_string(), Value::String(Self::build_request_title(&request_type, &group_callsign)), ), ( "summary".to_string(), Value::String(Self::build_request_summary( &request_type, &seed.fields.fields, &group_callsign, )), ), ("groupId".to_string(), Value::String(seed.group_id)), ( "groupCallsign".to_string(), Value::String(group_callsign.clone()), ), ( "submittedByUid".to_string(), Value::String(seed.submitted_by_uid), ), ( "submittedByName".to_string(), Value::String(Self::fallback_string( &seed.submitted_by_name, &group_callsign, )), ), ("fields".to_string(), seed.fields.into_value()), ( "priority".to_string(), Value::String(Self::normalize_priority(&seed.priority)), ), ("position".to_string(), seed.position), ("createdAt".to_string(), Value::from(seed.created_at)), ]), }; self.submit_request( serde_json::to_string(&request) .map_err(|error| format!("Failed to serialize CAD request payload: {error}"))?, ) } pub fn close_request(&self, entry_id: String) -> Result { let entry_id = Self::validate_entry_id(entry_id)?; let request = self .repository .get_request(&entry_id)? .ok_or_else(|| "CAD request could not be resolved.".to_string())?; self.repository.delete_request(&entry_id)?; Ok(CadRequestMutationResult { request: request.to_value(), message: "Support request closed.".to_string(), activity: Self::build_activity( "support_request_closed", format!( "{} was closed.", Self::string_field(&request.fields, "title").unwrap_or(entry_id.clone()) ), entry_id, Self::string_field(&request.fields, "groupId").unwrap_or_default(), String::new(), ), }) } pub fn upsert_request(&self, entry_id: String, json_data: String) -> Result<(), String> { let entry_id = Self::validate_entry_id(entry_id)?; let entry = Self::parse_record(&json_data)?; self.repository.save_request(entry_id, entry) } pub fn delete_request(&self, entry_id: String) -> Result<(), String> { let entry_id = Self::validate_entry_id(entry_id)?; self.repository.delete_request(&entry_id) } pub fn list_profiles(&self) -> Result, String> { Ok(Self::records_to_values(self.repository.list_profiles()?)) } pub fn update_profile_from_context( &self, json_data: String, ) -> Result { let seed = serde_json::from_str::(&json_data) .map_err(|error| format!("Invalid CAD group profile context: {error}"))?; let group_id = Self::validate_entry_id(seed.group_id)?; let mode = if seed.mode.trim().is_empty() { "profile".to_string() } else { seed.mode.to_lowercase() }; let current_role = Self::fallback_string(&seed.current_role, "infantry"); let current_status = Self::fallback_string(&seed.current_status, "available"); let final_role = if seed.role.trim().is_empty() { current_role.clone() } else { seed.role.to_lowercase() }; let final_status = if seed.status.trim().is_empty() { current_status.clone() } else { seed.status.to_lowercase() }; let changed = current_role != final_role || current_status != final_status; let callsign = Self::fallback_string(&seed.group_callsign, &group_id); let profile = if changed { let patch = CadRecord { fields: Map::from_iter([ ("groupId".to_string(), Value::String(group_id.clone())), ("role".to_string(), Value::String(final_role.clone())), ("status".to_string(), Value::String(final_status.clone())), ]), }; let existing = self.repository.get_profile(&group_id)?.unwrap_or_default(); let merged = existing.merge(patch); self.repository .save_profile(group_id.clone(), merged.clone())?; merged } else { CadRecord { fields: Map::from_iter([ ("groupId".to_string(), Value::String(group_id.clone())), ("role".to_string(), Value::String(current_role.clone())), ("status".to_string(), Value::String(current_status.clone())), ]), } }; let message = if changed { match mode.as_str() { "status" => "Group status updated.".to_string(), "role" => "Group role updated.".to_string(), _ => "Group profile updated.".to_string(), } } else { match mode.as_str() { "status" => "Group status already up to date.".to_string(), "role" => "Group role already up to date.".to_string(), _ => "Group profile already up to date.".to_string(), } }; let activity = if changed { match mode.as_str() { "status" => Self::build_activity( "group_status", format!( "{} updated {} to {}.", seed.requester_uid, callsign, final_status ), String::new(), group_id.clone(), seed.requester_uid.clone(), ), "role" => Self::build_activity( "group_role", format!( "{} updated {} role to {}.", seed.requester_uid, callsign, final_role ), String::new(), group_id.clone(), seed.requester_uid.clone(), ), _ => { let mut parts = Vec::new(); if current_role != final_role { parts.push(format!("role to {}", final_role)); } if current_status != final_status { parts.push(format!("status to {}", final_status)); } Self::build_activity( "group_profile", format!( "{} updated {} {}.", seed.requester_uid, callsign, parts.join(" and ") ), String::new(), group_id.clone(), seed.requester_uid.clone(), ) } } } else { CadActivityEntry::default() }; Ok(CadGroupProfileMutationResult { profile: profile.into_value(), message, activity, changed, }) } pub fn build_groups(&self, json_data: String) -> Result, String> { let seed: CadGroupBuildSeed = serde_json::from_str(&json_data) .map_err(|error| format!("Invalid CAD group seed: {error}"))?; let profiles = self.repository.list_profiles()?; let mut groups = Vec::with_capacity(seed.live_groups.len()); for group in seed.live_groups { let Some(mut entry) = Self::as_object_clone(&group) else { continue; }; let group_id = Self::string_field(&entry, "groupId").unwrap_or_default(); if group_id.is_empty() { continue; } if let Some(profile) = profiles.get(&group_id) { if let Some(role) = Self::string_field(&profile.fields, "role") { entry.insert("role".to_string(), Value::String(role)); } if let Some(status) = Self::string_field(&profile.fields, "status") { entry.insert("status".to_string(), Value::String(status)); } } groups.push(Value::Object(entry)); } Ok(groups) } pub fn upsert_profile(&self, entry_id: String, json_data: String) -> Result<(), String> { let entry_id = Self::validate_entry_id(entry_id)?; let entry = Self::parse_record(&json_data)?; self.repository.save_profile(entry_id, entry) } pub fn delete_profile(&self, entry_id: String) -> Result<(), String> { let entry_id = Self::validate_entry_id(entry_id)?; self.repository.delete_profile(&entry_id) } pub fn build_hydrate_payload(&self, json_data: String) -> Result { let seed: CadHydrateSeed = serde_json::from_str(&json_data) .map_err(|error| format!("Invalid CAD hydrate seed: {error}"))?; let assignments = self.repository.list_assignments()?; let dispatch_orders = self.repository.list_orders()?; let requests = self.repository.list_requests()?; let activity = self.repository.snapshot_activity()?; Ok(CadViewService::build_hydrate_payload( seed, assignments, dispatch_orders, requests, activity, )) } fn validate_entry_id(entry_id: String) -> Result { if entry_id.trim().is_empty() { return Err("Entry ID is required.".to_string()); } Ok(entry_id) } fn parse_value(json_data: &str) -> Result { serde_json::from_str::(json_data).map_err(|error| format!("Invalid JSON: {error}")) } fn parse_record(json_data: &str) -> Result { serde_json::from_str::(json_data) .map_err(|error| format!("Invalid CAD JSON: {error}")) } fn records_to_values(records: HashMap) -> Vec { records.into_values().map(CadRecord::into_value).collect() } fn set_task_id(record: &mut CadRecord, task_id: &str) { let task_id_value = Value::String(task_id.to_string()); record .fields .insert("taskId".to_string(), task_id_value.clone()); record.fields.insert("taskID".to_string(), task_id_value); } fn build_activity( entry_type: &str, message: String, task_id: String, group_id: String, actor_uid: String, ) -> CadActivityEntry { CadActivityEntry { entry_type: entry_type.to_string(), message, task_id, group_id, actor_uid, } } fn display_group_name(record: &Map) -> String { Self::string_field(record, "groupCallsign") .or_else(|| Self::string_field(record, "assigneeGroupCallsign")) .or_else(|| Self::string_field(record, "groupId")) .unwrap_or_else(|| "assigned group".to_string()) } fn normalize_priority(priority: &str) -> String { let normalized = priority.to_lowercase(); if ["routine", "priority", "emergency"].contains(&normalized.as_str()) { normalized } else { "priority".to_string() } } fn fallback_string(value: &str, fallback: &str) -> String { if value.trim().is_empty() { fallback.to_string() } else { value.to_string() } } fn build_request_title(request_type: &str, group_callsign: &str) -> String { format!( "{} | {}", Self::format_request_type(request_type), group_callsign ) } fn build_request_summary( request_type: &str, fields: &Map, group_callsign: &str, ) -> String { match request_type { "medevac_9line" => format!( "Pickup {} | Precedence {} | Security {}", Self::string_field(fields, "pickup_location") .unwrap_or_else(|| "Unknown".to_string()), Self::string_field(fields, "precedence").unwrap_or_else(|| "unknown".to_string()), Self::string_field(fields, "security").unwrap_or_else(|| "unknown".to_string()) ), "ace_lace" => format!( "Ammo {} | Casualties {} | Equipment {}", Self::string_field(fields, "ammo").unwrap_or_else(|| "unknown".to_string()), Self::string_field(fields, "casualties").unwrap_or_else(|| "unknown".to_string()), Self::string_field(fields, "equipment").unwrap_or_else(|| "unknown".to_string()) ), "fire_support" => format!( "Target {} | Effect {} | Danger Close {}", Self::string_field(fields, "target_location") .unwrap_or_else(|| "Unknown".to_string()), Self::string_field(fields, "requested_effect") .unwrap_or_else(|| "unknown".to_string()), Self::string_field(fields, "danger_close").unwrap_or_else(|| "no".to_string()) ), "air_support" => format!( "Target {} | Marking {} | Effect {}", Self::string_field(fields, "target_location") .unwrap_or_else(|| "Unknown".to_string()), Self::string_field(fields, "target_marking") .unwrap_or_else(|| "unknown".to_string()), Self::string_field(fields, "requested_effect") .unwrap_or_else(|| "unknown".to_string()) ), "logreq" => format!( "Category {} | Requested {} | Quantity {} | Delivery {} | Location {}", Self::string_field(fields, "category").unwrap_or_else(|| "mixed".to_string()), Self::string_field(fields, "requested_items") .unwrap_or_else(|| "unspecified".to_string()), Self::string_field(fields, "quantity").unwrap_or_else(|| "unspecified".to_string()), Self::string_field(fields, "delivery_method") .unwrap_or_else(|| "dispatch discretion".to_string()), Self::string_field(fields, "delivery_location") .unwrap_or_else(|| "Unknown".to_string()) ), _ => format!( "{} request from {}.", Self::format_request_type(request_type), group_callsign ), } } fn format_request_type(request_type: &str) -> String { match request_type { "medevac_9line" => "9-Line MEDEVAC".to_string(), "ace_lace" => "ACE/LACE".to_string(), "fire_support" => "Fire Support".to_string(), "air_support" => "Air Support".to_string(), "logreq" => "LOGREQ".to_string(), _ => request_type.to_string(), } } fn as_object_clone(value: &Value) -> Option> { value.as_object().cloned() } fn string_field(object: &Map, key: &str) -> Option { object.get(key)?.as_str().map(ToString::to_string) } } pub struct CadViewService; impl CadViewService { pub fn build_hydrate_payload( seed: CadHydrateSeed, assignments: HashMap, dispatch_orders: HashMap, requests: HashMap, activity: Vec, ) -> CadHydratePayload { let groups = seed.groups.clone(); let contracts = Self::build_contracts( &seed.active_tasks, &groups, &seed.session, &assignments, &dispatch_orders, ); let requests = Self::build_requests(&seed.session, &requests); let assignments = assignments .into_values() .map(CadRecord::into_value) .collect(); let activity = Self::build_activity(activity); CadHydratePayload { groups, contracts, requests, assignments, activity, session: seed.session, } } fn build_contracts( active_tasks: &[Value], groups: &[Value], session: &CadSession, assignments: &HashMap, dispatch_orders: &HashMap, ) -> Vec { let mut contracts = Vec::new(); for task in active_tasks { let Some(mut entry) = Self::as_object_clone(task) else { continue; }; let task_id = Self::string_field(&entry, "taskID") .or_else(|| Self::string_field(&entry, "taskId")) .unwrap_or_default(); if task_id.is_empty() { continue; } let assignment = assignments.get(&task_id).map(|value| &value.fields); let assigned_group_id = assignment .and_then(|value| Self::string_field(value, "groupId")) .unwrap_or_default(); let assignment_state = assignment .and_then(|value| Self::string_field(value, "state")) .unwrap_or_else(|| "unassigned".to_string()); if !session.is_dispatcher && (assigned_group_id.is_empty() || assigned_group_id != session.group_id) { continue; } entry.insert("taskId".to_string(), Value::String(task_id)); entry.insert( "assignedGroupId".to_string(), Value::String(assigned_group_id), ); entry.insert( "assignmentState".to_string(), Value::String(assignment_state), ); contracts.push(Value::Object(entry)); } for (task_id, order) in dispatch_orders { let assignment = assignments.get(task_id).map(|value| &value.fields); let assigned_group_id = assignment .and_then(|value| Self::string_field(value, "groupId")) .unwrap_or_default(); let assignment_state = assignment .and_then(|value| Self::string_field(value, "state")) .unwrap_or_else(|| "unassigned".to_string()); if !session.is_dispatcher && (assigned_group_id.is_empty() || assigned_group_id != session.group_id) { continue; } let mut entry = order.fields.clone(); if let Some(target_group_id) = Self::string_field(&entry, "targetGroupId") && let Some(target_group) = groups.iter().find_map(|group| { let object = Self::as_object_ref(group)?; (Self::string_field(object, "groupId").unwrap_or_default() == target_group_id) .then_some(object) }) { if let Some(callsign) = Self::string_field(target_group, "callsign") { entry.insert( "targetGroupCallsign".to_string(), Value::String(callsign.clone()), ); entry.insert( "title".to_string(), Value::String(format!("Backup {callsign}")), ); } if let Some(position) = target_group.get("position") { entry.insert("position".to_string(), position.clone()); } if Self::string_field(&entry, "note") .unwrap_or_default() .is_empty() && let Some(callsign) = Self::string_field(&entry, "targetGroupCallsign") { entry.insert( "description".to_string(), Value::String(format!( "Dispatch order to back up {callsign} at its current position." )), ); } } entry.insert("taskId".to_string(), Value::String(task_id.clone())); entry.insert("taskID".to_string(), Value::String(task_id.clone())); entry.insert("isDispatchOrder".to_string(), Value::Bool(true)); entry.insert( "assignedGroupId".to_string(), Value::String(assigned_group_id), ); entry.insert( "assignmentState".to_string(), Value::String(assignment_state), ); contracts.push(Value::Object(entry)); } contracts } fn build_requests(session: &CadSession, requests: &HashMap) -> Vec { let mut filtered: Vec<(f64, Value)> = requests .values() .filter_map(|request| { let object = &request.fields; let group_id = Self::string_field(object, "groupId").unwrap_or_default(); if !session.is_dispatcher && group_id != session.group_id { return None; } let created_at = Self::number_field(object, "createdAt").unwrap_or_default(); Some((created_at, request.to_value())) }) .collect(); filtered.sort_by(|(left, _), (right, _)| { right.partial_cmp(left).unwrap_or(std::cmp::Ordering::Equal) }); filtered.into_iter().map(|(_, value)| value).collect() } fn build_activity(mut activity: Vec) -> Vec { if activity.len() > CAD_RECENT_ACTIVITY_LIMIT { let drain_count = activity.len() - CAD_RECENT_ACTIVITY_LIMIT; activity.drain(0..drain_count); } activity } fn as_object_ref(value: &Value) -> Option<&Map> { value.as_object() } fn as_object_clone(value: &Value) -> Option> { value.as_object().cloned() } fn string_field(object: &Map, key: &str) -> Option { object.get(key)?.as_str().map(ToString::to_string) } fn number_field(object: &Map, key: &str) -> Option { object.get(key)?.as_f64() } } #[cfg(test)] mod tests { use super::CadStateService; use forge_repositories::{CadRepository, InMemoryCadRepository}; use serde_json::Value; #[test] fn create_order_assigns_shared_task_id() { let repository = InMemoryCadRepository::new(); let service = CadStateService::new(repository.clone()); let result = service .create_order( r#"{ "order": {"type":"dispatch_order","targetGroupId":"alpha"}, "assignment": {"groupId":"bravo","state":"assigned"} }"# .to_string(), ) .expect("create order should succeed"); assert_eq!(result.task_id, "cad-order:1"); let stored_order = repository .get_order(&result.task_id) .expect("get order should succeed") .expect("order should exist"); let stored_assignment = repository .get_assignment(&result.task_id) .expect("get assignment should succeed") .expect("assignment should exist"); assert_eq!( stored_order.fields.get("taskId"), Some(&Value::String(result.task_id.clone())) ); assert_eq!( stored_assignment.fields.get("taskId"), Some(&Value::String(result.task_id)) ); } #[test] fn create_order_from_context_persists_source_request_metadata() { let repository = InMemoryCadRepository::new(); let service = CadStateService::new(repository.clone()); let result = service .create_order_from_context( r#"{ "assigneeGroupId": "bravo", "assigneeGroupCallsign": "Bravo 1-1", "targetGroupId": "alpha", "targetGroupCallsign": "Alpha 1-1", "targetPosition": [1000, 2000, 0], "createdByUid": "dispatcher-1", "createdByName": "Dispatch", "requestId": "cad-request:7", "requestType": "logreq", "requestTitle": "LOGREQ | Alpha 1-1", "requestSummary": "Category ammo | Requested MX rifle ammo", "requestFields": { "category": "ammo", "requested_items": "MX rifle ammo", "quantity": "4 crates" }, "note": "LOGREQ requested by Alpha 1-1. Requested Items MX rifle ammo | Quantity 4 crates", "priority": "priority", "createdAt": 123.45 }"# .to_string(), ) .expect("create order from context should succeed"); let stored_order = repository .get_order(&result.task_id) .expect("get order should succeed") .expect("order should exist"); assert_eq!( stored_order.fields.get("sourceRequestId"), Some(&Value::String("cad-request:7".to_string())) ); assert_eq!( stored_order.fields.get("sourceRequestType"), Some(&Value::String("logreq".to_string())) ); assert_eq!( stored_order.fields.get("sourceRequestFields"), Some(&serde_json::json!({ "category": "ammo", "requested_items": "MX rifle ammo", "quantity": "4 crates" })) ); } #[test] fn decline_assignment_returns_record_and_removes_state() { let repository = InMemoryCadRepository::new(); let service = CadStateService::new(repository.clone()); service .assign_assignment( "task-1".to_string(), r#"{"groupId":"alpha","state":"assigned"}"#.to_string(), ) .expect("assign should succeed"); let declined = service .decline_assignment( "task-1".to_string(), r#"{"state":"declined","declinedAt":123}"#.to_string(), ) .expect("decline should succeed"); assert_eq!( declined.assignment.get("state").and_then(Value::as_str), Some("declined") ); assert!( repository .get_assignment("task-1") .expect("get assignment should succeed") .is_none() ); } #[test] fn submit_request_from_context_accepts_scalar_created_at() { let repository = InMemoryCadRepository::new(); let service = CadStateService::new(repository); let result = service .submit_request_from_context( r#"{ "type": "medevac_9line", "fields": {"pickup_location":"1000 2000"}, "groupId": "alpha", "groupCallsign": "Alpha 1-1", "submittedByUid": "uid-1", "submittedByName": "Leader", "priority": "emergency", "position": [1000, 2000, 0], "createdAt": 123.45 }"# .to_string(), ) .expect("submit request should accept scalar createdAt"); assert_eq!( result.request.get("createdAt").and_then(Value::as_f64), Some(123.45) ); } }