diff --git a/arma/client/addons/cad/functions/fnc_handleUIEvents.sqf b/arma/client/addons/cad/functions/fnc_handleUIEvents.sqf
index af2abdf..6357e9d 100644
--- a/arma/client/addons/cad/functions/fnc_handleUIEvents.sqf
+++ b/arma/client/addons/cad/functions/fnc_handleUIEvents.sqf
@@ -77,14 +77,16 @@ switch (_event) do {
private _targetGroupID = "";
private _note = "";
private _priority = "priority";
+ private _request = createHashMap;
if (_data isEqualType createHashMap) then {
_assigneeGroupID = _data getOrDefault ["assigneeGroupID", ""];
_targetGroupID = _data getOrDefault ["targetGroupID", ""];
_note = _data getOrDefault ["note", ""];
_priority = _data getOrDefault ["priority", "priority"];
+ _request = _data getOrDefault ["request", createHashMap];
};
- GVAR(CADUIBridge) call ["requestCreateDispatchOrder", [_assigneeGroupID, _targetGroupID, _note, _priority]];
+ GVAR(CADUIBridge) call ["requestCreateDispatchOrder", [_assigneeGroupID, _targetGroupID, _note, _priority, _request]];
};
case "cad::supportRequest::submit": {
private _type = "";
diff --git a/arma/client/addons/cad/functions/fnc_initUIBridge.sqf b/arma/client/addons/cad/functions/fnc_initUIBridge.sqf
index 0394bb5..8e27f70 100644
--- a/arma/client/addons/cad/functions/fnc_initUIBridge.sqf
+++ b/arma/client/addons/cad/functions/fnc_initUIBridge.sqf
@@ -218,12 +218,13 @@ GVAR(CADUIBridgeBaseClass) = compileFinal createHashMapFromArray [
["_assigneeGroupID", "", [""]],
["_targetGroupID", "", [""]],
["_note", "", [""]],
- ["_priority", "priority", [""]]
+ ["_priority", "priority", [""]],
+ ["_request", createHashMap, [createHashMap]]
];
if (_assigneeGroupID isEqualTo "" || { _targetGroupID isEqualTo "" }) exitWith { false };
- [SRPC(cad,requestCreateCadDispatchOrder), [getPlayerUID player, _assigneeGroupID, _targetGroupID, _note, _priority]] call CFUNC(serverEvent);
+ [SRPC(cad,requestCreateCadDispatchOrder), [getPlayerUID player, _assigneeGroupID, _targetGroupID, _note, _priority, _request]] call CFUNC(serverEvent);
true
}],
["requestSubmitSupportRequest", compileFinal {
diff --git a/arma/client/addons/cad/ui/_site/cad-dispatcher.js b/arma/client/addons/cad/ui/_site/cad-dispatcher.js
index 6f86b75..d9c1de4 100644
--- a/arma/client/addons/cad/ui/_site/cad-dispatcher.js
+++ b/arma/client/addons/cad/ui/_site/cad-dispatcher.js
@@ -1 +1 @@
-window.cadDispatcherFormatters={getDangerGroups(){return this.groups.filter(e=>"danger"===(e.status||""))},getSupportAlertRequests(){return this.requests.filter(e=>["medevac_9line","fire_support","air_support"].includes(e.type||""))},buildSupportAlertMessage(){const e=this.getSupportAlertRequests();if(!e.length)return"";return`Support request alert: ${e.map(e=>`${e.groupCallsign||e.groupId||"Unknown Group"} ${this.getRequestTypeLabel(e.type||"request")}`).join(", ")}`},getSortedGroups(){return this.groups.slice().sort((e,t)=>{const s="danger"===(e.status||"")?0:1,n="danger"===(t.status||"")?0:1;if(s!==n)return s-n;const r=e.callsign||e.groupId||"",i=t.callsign||t.groupId||"";return r.localeCompare(i)})},isDispatchOrder:e=>!!e.isDispatchOrder||"dispatch_order"===(e.type||""),formatTypeLabel(e){const t=(e.type||"task").replaceAll("_"," ");return this.isDispatchOrder(e)?"dispatch order":t},getRequestTypeLabel(e){switch(e){case"medevac_9line":return"9-Line MEDEVAC";case"ace_lace":return"ACE/LACE";case"fire_support":return"Fire Support";case"air_support":return"Air Support";case"logreq":return"LOGREQ";default:return(e||"request").replaceAll("_"," ")}},buildGroupOptions(e){return this.getSortedGroups().map(t=>{const s=t.groupId||"";return`${t.callsign||s} `}).join("")},formatRequestFieldLabel:e=>(e||"field").replaceAll("_"," ").replace(/\b\w/g,e=>e.toUpperCase()),formatRequestFieldValue(e){if(Array.isArray(e))return e.join(", ");if(e&&"object"==typeof e)return JSON.stringify(e);return String(e??"").trim()||"Not provided"},buildRequestOrderNote(e){const t=this.getRequestTypeLabel(e.type||"request"),s=e.groupCallsign||e.groupId||"Unknown Group",n=(e.summary||"").trim();return n?`${t} requested by ${s}. ${n}`:`${t} requested by ${s}.`}},window.cadDispatcherModals={openOrderModal(){this.convertingRequestId="",this.populateOrderModal(),document.getElementById("dispatcherOrderModalTitle").textContent="Create Support Order",document.getElementById("dispatcherOrderModal").classList.remove("is-hidden")},closeOrderModal(){this.convertingRequestId="",document.getElementById("dispatcherOrderNoteInput").value="",document.getElementById("dispatcherOrderPrioritySelect").value="priority",document.getElementById("dispatcherOrderModalTitle").textContent="Create Support Order",document.getElementById("dispatcherOrderModal").classList.add("is-hidden")},openRequestModal(e){const t=this.requests.find(t=>t.requestId===e);t&&(this.viewingRequestId=e,this.populateRequestModal(t),document.getElementById("dispatcherRequestModal").classList.remove("is-hidden"))},closeRequestModal(){this.viewingRequestId="",document.getElementById("dispatcherRequestModal").classList.add("is-hidden")},syncRequestModal(){if(!this.viewingRequestId)return;const e=this.requests.find(e=>e.requestId===this.viewingRequestId);e?this.populateRequestModal(e):this.closeRequestModal()},populateRequestModal(e){const t=e.fields&&"object"==typeof e.fields?Object.entries(e.fields):[],s=t.length?t.map(([e,t])=>`\n
\n ${this.formatRequestFieldLabel(e)} \n ${this.formatRequestFieldValue(t)} \n
\n `).join(""):'';document.getElementById("dispatcherRequestTitle").textContent=e.title||e.requestId||"Support Request",document.getElementById("dispatcherRequestPriority").textContent=(e.priority||"priority").replaceAll("_"," "),document.getElementById("dispatcherRequestGroup").textContent=e.groupCallsign||e.groupId||"Unknown",document.getElementById("dispatcherRequestType").textContent=this.getRequestTypeLabel(e.type||"request"),document.getElementById("dispatcherRequestSummary").textContent=e.summary||"No summary provided.",document.getElementById("dispatcherRequestFields").innerHTML=s},convertRequestToOrder(e){const t=this.requests.find(t=>(t.requestId||"")===e);if(!t)return void this.setStatus("Selected request is no longer available.","error");const s=t.groupId||"";if(!s)return void this.setStatus("Selected request has no owning group to target.","error");this.groups.find(e=>(e.groupId||"")===s)?(this.convertingRequestId=e,this.populateOrderModal({selectedAssigneeID:this.getSortedGroups().find(e=>(e.groupId||"")!==s)?.groupId||"",selectedTargetID:s,note:this.buildRequestOrderNote(t),priority:t.priority||"priority"}),document.getElementById("dispatcherOrderModalTitle").textContent="Create Order From Request",document.getElementById("dispatcherOrderModal").classList.remove("is-hidden"),this.setStatus("Preparing dispatch order from request...","info")):this.setStatus("Selected request group is no longer available.","error")},convertViewedRequestToOrder(){this.viewingRequestId&&(this.closeRequestModal(),this.convertRequestToOrder(this.viewingRequestId))},populateOrderModal(e={}){const t=this.getSortedGroups(),s=document.getElementById("dispatcherOrderAssigneeSelect"),n=document.getElementById("dispatcherOrderTargetSelect"),r=document.getElementById("dispatcherOrderNoteInput"),i=document.getElementById("dispatcherOrderPrioritySelect");if(!s||!n)return;const d=e.selectedAssigneeID||"",a=e.selectedTargetID||"",o=d||t.find(e=>(e.groupId||"")!==a)?.groupId||t[0]?.groupId||"",c=a||t.find(e=>(e.groupId||"")!==o)?.groupId||t[0]?.groupId||"";s.innerHTML=this.buildGroupOptions(o),n.innerHTML=this.buildGroupOptions(c),r&&(r.value=e.note||""),i&&(i.value=e.priority||"priority")},syncOrderModal(){const e=document.getElementById("dispatcherOrderModal");e&&!e.classList.contains("is-hidden")&&this.populateOrderModal({selectedAssigneeID:document.getElementById("dispatcherOrderAssigneeSelect")?.value||"",selectedTargetID:document.getElementById("dispatcherOrderTargetSelect")?.value||"",note:document.getElementById("dispatcherOrderNoteInput")?.value||"",priority:document.getElementById("dispatcherOrderPrioritySelect")?.value||"priority"})},openGroupModal(e){const t=this.groups.find(t=>t.groupId===e);t&&(this.editingGroupId=e,document.getElementById("dispatcherModalGroupCallsign").textContent=t.callsign||t.groupId||"Unknown",document.getElementById("dispatcherModalGroupLeader").textContent=t.leaderName||"Unknown",document.getElementById("dispatcherModalGroupTask").textContent=t.currentTaskId||"None",document.getElementById("dispatcherModalGroupOrg").textContent=t.orgId||"default",document.getElementById("dispatcherModalRoleSelect").innerHTML=this.roles.map(e=>`${e.replaceAll("_"," ")} `).join(""),document.getElementById("dispatcherModalStatusSelect").innerHTML=this.statuses.map(e=>`${e.replaceAll("_"," ")} `).join(""),document.getElementById("dispatcherGroupModal").classList.remove("is-hidden"))},closeGroupModal(){this.editingGroupId="",document.getElementById("dispatcherGroupModal").classList.add("is-hidden")},syncOpenModal(){if(!this.editingGroupId)return;const e=this.groups.find(e=>e.groupId===this.editingGroupId);e?(document.getElementById("dispatcherModalGroupCallsign").textContent=e.callsign||e.groupId||"Unknown",document.getElementById("dispatcherModalGroupLeader").textContent=e.leaderName||"Unknown",document.getElementById("dispatcherModalGroupTask").textContent=e.currentTaskId||"None",document.getElementById("dispatcherModalGroupOrg").textContent=e.orgId||"default"):this.closeGroupModal()}},window.cadDispatcherRender={updateDangerAlert(){const e=document.getElementById("dispatcherDangerAlert");if(!e)return;const t=this.getDangerGroups();if(!t.length)return e.textContent="",void e.classList.add("is-hidden");const s=t.map(e=>e.callsign||e.groupId||"Unknown Group");e.textContent=`Danger alert active: ${s.join(", ")}`,e.classList.remove("is-hidden")},updateRequestAlert(){const e=document.getElementById("dispatcherRequestAlert");if(!e)return;const t=this.buildSupportAlertMessage();if(!t)return e.textContent="",void e.classList.add("is-hidden");e.textContent=t,e.classList.remove("is-hidden")},buildGroupEditorButton:e=>`\n \n ⚙\n \n `,buildCloseOrderButton:e=>`\n \n Close\n \n `,buildCloseRequestButton:e=>`\n \n Close\n \n `,buildConvertRequestButton:e=>`\n \n Convert to Order\n \n `,renderMetrics(){const e=this.contracts.filter(e=>"unassigned"!==(e.assignmentState||"unassigned")),t=this.contracts.filter(e=>"unassigned"===(e.assignmentState||"unassigned")),s=this.requests.length,n=this.getSupportAlertRequests(),r=this.groups.filter(e=>"danger"===(e.status||""));document.getElementById("metricOpenContracts").textContent=t.length,document.getElementById("metricAssignedContracts").textContent=e.length,document.getElementById("metricActiveGroups").textContent=this.groups.length,document.getElementById("metricOpenRequests").textContent=s,document.getElementById("metricDangerGroups").textContent=r.length;const i=document.getElementById("metricDangerGroupsCard");i&&i.classList.toggle("is-danger",r.length>0);const d=document.getElementById("metricOpenRequestsCard");d&&d.classList.toggle("is-warning",n.length>0)},renderOpenContracts(){const e=document.getElementById("dispatcherOpenContracts"),t=this.contracts.filter(e=>"unassigned"===(e.assignmentState||"unassigned"));if(!t.length)return void(e.innerHTML='');const s=this.buildGroupOptions("");e.innerHTML=t.map(e=>{const t=e.taskId||e.taskID||"",n=Array.isArray(e.position)?e.position:[0,0,0],r=this.groups.find(t=>t.groupId===(e.targetGroupId||""));return`\n \n \n ${e.description||""}
\n \n Unassigned \n ${window.mapUI.formatPosition(n)} \n
\n \n Target: ${r?r.callsign:e.targetGroupCallsign||"None"} \n Priority: ${(e.priority||"priority").replaceAll("_"," ")} \n
\n \n \n Assign to group \n ${s}\n \n Assign \n
\n \n `}).join("")},renderAssignedContracts(){const e=document.getElementById("dispatcherAssignedContracts"),t=this.contracts.filter(e=>"unassigned"!==(e.assignmentState||"unassigned"));t.length?e.innerHTML=t.map(e=>{const t=e.taskId||e.taskID||"",s=this.groups.find(t=>t.groupId===(e.assignedGroupId||"")),n=this.groups.find(t=>t.groupId===(e.targetGroupId||"")),r=this.isDispatchOrder(e);return`\n \n \n ${e.description||""}
\n \n Group: ${s?s.callsign:e.assignedGroupId||"Unknown"} \n Type: ${this.formatTypeLabel(e)} \n
\n \n Target: ${n?n.callsign:e.targetGroupCallsign||"None"} \n Priority: ${(e.priority||"priority").replaceAll("_"," ")} \n
\n ${r?`${this.buildCloseOrderButton(t)}
`:""}\n \n `}).join(""):e.innerHTML=''},renderGroups(){const e=document.getElementById("dispatcherGroups");this.groups.length?e.innerHTML=this.getSortedGroups().map(e=>{const t="danger"===(e.status||"");return`\n \n \n \n Leader: ${e.leaderName||"Unknown"} \n Status: ${e.status||"unknown"} \n
\n \n Org: ${e.orgId||"default"} \n Task: ${e.currentTaskId||"None"} \n
\n \n `}).join(""):e.innerHTML='No active groups available.
'},renderActivity(){const e=document.getElementById("dispatcherActivity"),t=this.requests.length?this.requests.map(e=>`\n \n \n ${e.summary||""}
\n \n Group: ${e.groupCallsign||e.groupId||"Unknown"} \n ${this.getRequestTypeLabel(e.type||"request")} \n
\n \n ${this.buildConvertRequestButton(e.requestId||"")}\n ${this.buildCloseRequestButton(e.requestId||"")}\n
\n \n `).join(""):'No active support requests.
',s=this.activity.length?this.activity.slice().reverse().slice(0,8).map(e=>`\n \n \n ${e.message||""}
\n \n `).join(""):'';e.innerHTML=`\n \n \n ${t}\n
\n \n \n ${s}\n
\n `},render(){this.updateDangerAlert(),this.updateRequestAlert(),this.renderMetrics(),this.renderOpenContracts(),this.renderAssignedContracts(),this.renderGroups(),this.renderActivity()}};const dispatcherFormatters=window.cadDispatcherFormatters||{},dispatcherModals=window.cadDispatcherModals||{},dispatcherRender=window.cadDispatcherRender||{};window.cadDispatcher={contracts:[],requests:[],groups:[],activity:[],session:{},editingGroupId:"",viewingRequestId:"",convertingRequestId:"",statuses:["available","en_route","on_task","holding","danger","unavailable"],roles:["infantry","recon","armor","air","logistics","support"],...dispatcherFormatters,...dispatcherModals,...dispatcherRender,init(){document.getElementById("dispatcherCreateOrderBtn").addEventListener("click",()=>{this.openOrderModal()}),document.getElementById("dispatcherGroupModalCloseBtn").addEventListener("click",()=>{this.closeGroupModal()}),document.getElementById("dispatcherGroupModalSaveBtn").addEventListener("click",()=>{this.applyGroupUpdates()}),document.querySelector("#dispatcherGroupModal .dispatch-modal-backdrop").addEventListener("click",()=>{this.closeGroupModal()}),document.getElementById("dispatcherOrderModalCloseBtn").addEventListener("click",()=>{this.closeOrderModal()}),document.getElementById("dispatcherOrderModalSaveBtn").addEventListener("click",()=>{this.createDispatchOrder()}),document.querySelector("#dispatcherOrderModal .dispatch-modal-backdrop").addEventListener("click",()=>{this.closeOrderModal()}),document.getElementById("dispatcherRequestModalCloseBtn").addEventListener("click",()=>{this.closeRequestModal()}),document.getElementById("dispatcherRequestModalDoneBtn").addEventListener("click",()=>{this.closeRequestModal()}),document.getElementById("dispatcherRequestConvertBtn").addEventListener("click",()=>{this.convertViewedRequestToOrder()}),document.querySelector("#dispatcherRequestModal .dispatch-modal-backdrop").addEventListener("click",()=>{this.closeRequestModal()}),window.mapUI.sendEvent("cad::dispatcher::ready",{})},receiveHydrate(e){this.contracts=Array.isArray(e.contracts)?e.contracts:[],this.requests=Array.isArray(e.requests)?e.requests:[],this.groups=Array.isArray(e.groups)?e.groups:[],this.activity=Array.isArray(e.activity)?e.activity:[],this.session=e.session&&"object"==typeof e.session?e.session:{};const t=document.getElementById("dispatcherStatusMessage");!t||t.dataset.type&&"info"!==t.dataset.type||this.setStatus("",""),this.syncOpenModal(),this.syncOrderModal(),this.syncRequestModal(),this.render()},setStatus(e,t){const s=document.getElementById("dispatcherStatusMessage");s&&(s.textContent=e||"",s.dataset.type=t||"")},createDispatchOrder(){const e=document.getElementById("dispatcherOrderAssigneeSelect").value,t=document.getElementById("dispatcherOrderTargetSelect").value,s=document.getElementById("dispatcherOrderPrioritySelect").value,n=document.getElementById("dispatcherOrderNoteInput").value;e&&t?e!==t?(this.setStatus(this.convertingRequestId?"Creating dispatch order from request...":"Creating dispatch order...","info"),window.mapUI.sendEvent("cad::dispatchOrder::create",{assigneeGroupID:e,targetGroupID:t,note:n.trim(),priority:s}),this.closeOrderModal()):this.setStatus("Assignee and target groups must be different.","error"):this.setStatus("Select both an assignee and a target group.","error")},assignTask(e){const t=document.getElementById(`dispatcher-assign-group-${e}`);t&&t.value?(this.setStatus("Submitting assignment...","info"),window.mapUI.sendEvent("cad::tasks::assign",{taskID:e,groupID:t.value,note:""})):this.setStatus("Select a group before assigning a contract.","error")},applyGroupUpdates(){if(!this.editingGroupId)return;const e=this.groups.find(e=>e.groupId===this.editingGroupId);if(!e)return void this.closeGroupModal();const t=document.getElementById("dispatcherModalRoleSelect").value,s=document.getElementById("dispatcherModalStatusSelect").value,n=t&&t!==(e.role||"")?t:"",r=s&&s!==(e.status||"")?s:"";if(!(n||r))return this.setStatus("No group changes to save.","info"),void this.closeGroupModal();this.setStatus("Updating group profile...","info"),window.mapUI.sendEvent("cad::groups::profile",{groupID:this.editingGroupId,role:n,status:r}),this.closeGroupModal()},closeDispatchOrder(e){e&&(this.setStatus("Closing dispatch order...","info"),window.mapUI.sendEvent("cad::dispatchOrder::close",{taskID:e}))},closeSupportRequest(e){e&&(this.setStatus("Closing support request...","info"),window.mapUI.sendEvent("cad::supportRequest::close",{requestID:e}))}},window.cadDispatcher.init();
\ No newline at end of file
+window.cadDispatcherFormatters={getDangerGroups(){return this.groups.filter(e=>"danger"===(e.status||""))},getSupportAlertRequests(){return this.requests.filter(e=>["medevac_9line","fire_support","air_support"].includes(e.type||""))},buildSupportAlertMessage(){const e=this.getSupportAlertRequests();if(!e.length)return"";return`Support request alert: ${e.map(e=>`${e.groupCallsign||e.groupId||"Unknown Group"} ${this.getRequestTypeLabel(e.type||"request")}`).join(", ")}`},getSortedGroups(){return this.groups.slice().sort((e,t)=>{const s="danger"===(e.status||"")?0:1,n="danger"===(t.status||"")?0:1;if(s!==n)return s-n;const r=e.callsign||e.groupId||"",i=t.callsign||t.groupId||"";return r.localeCompare(i)})},isDispatchOrder:e=>!!e.isDispatchOrder||"dispatch_order"===(e.type||""),formatTypeLabel(e){const t=(e.type||"task").replaceAll("_"," ");return this.isDispatchOrder(e)?"dispatch order":t},getRequestTypeLabel(e){switch(e){case"medevac_9line":return"9-Line MEDEVAC";case"ace_lace":return"ACE/LACE";case"fire_support":return"Fire Support";case"air_support":return"Air Support";case"logreq":return"LOGREQ";default:return(e||"request").replaceAll("_"," ")}},buildGroupOptions(e){return this.getSortedGroups().map(t=>{const s=t.groupId||"";return`${t.callsign||s} `}).join("")},formatRequestFieldLabel:e=>(e||"field").replaceAll("_"," ").replace(/\b\w/g,e=>e.toUpperCase()),formatRequestFieldValue(e){if(Array.isArray(e))return e.join(", ");if(e&&"object"==typeof e)return JSON.stringify(e);return String(e??"").trim()||"Not provided"},buildRequestOrderNote(e){const t=this.getRequestTypeLabel(e.type||"request"),s=e.groupCallsign||e.groupId||"Unknown Group",n=(e.summary||"").trim(),r=e.fields&&"object"==typeof e.fields?Object.entries(e.fields).map(([e,t])=>{const s=this.formatRequestFieldValue(t);return"Not provided"===s?"":`${this.formatRequestFieldLabel(e)} ${s}`}).filter(Boolean):[],i=r.length?r:[n].filter(Boolean);return i.length?`${t} requested by ${s}. ${i.join(" | ")}`:`${t} requested by ${s}.`}},window.cadDispatcherModals={openOrderModal(){this.convertingRequestId="",this.populateOrderModal(),document.getElementById("dispatcherOrderModalTitle").textContent="Create Support Order",document.getElementById("dispatcherOrderModal").classList.remove("is-hidden")},closeOrderModal(){this.convertingRequestId="",document.getElementById("dispatcherOrderNoteInput").value="",document.getElementById("dispatcherOrderPrioritySelect").value="priority",document.getElementById("dispatcherOrderModalTitle").textContent="Create Support Order",document.getElementById("dispatcherOrderModal").classList.add("is-hidden")},openRequestModal(e){const t=this.requests.find(t=>t.requestId===e);t&&(this.viewingRequestId=e,this.populateRequestModal(t),document.getElementById("dispatcherRequestModal").classList.remove("is-hidden"))},closeRequestModal(){this.viewingRequestId="",document.getElementById("dispatcherRequestModal").classList.add("is-hidden")},syncRequestModal(){if(!this.viewingRequestId)return;const e=this.requests.find(e=>e.requestId===this.viewingRequestId);e?this.populateRequestModal(e):this.closeRequestModal()},populateRequestModal(e){const t=e.fields&&"object"==typeof e.fields?Object.entries(e.fields):[],s=t.length?t.map(([e,t])=>`\n \n ${this.formatRequestFieldLabel(e)} \n ${this.formatRequestFieldValue(t)} \n
\n `).join(""):'';document.getElementById("dispatcherRequestTitle").textContent=e.title||e.requestId||"Support Request",document.getElementById("dispatcherRequestPriority").textContent=(e.priority||"priority").replaceAll("_"," "),document.getElementById("dispatcherRequestGroup").textContent=e.groupCallsign||e.groupId||"Unknown",document.getElementById("dispatcherRequestType").textContent=this.getRequestTypeLabel(e.type||"request"),document.getElementById("dispatcherRequestSummary").textContent=e.summary||"No summary provided.",document.getElementById("dispatcherRequestFields").innerHTML=s},convertRequestToOrder(e){const t=this.requests.find(t=>(t.requestId||"")===e);if(!t)return void this.setStatus("Selected request is no longer available.","error");const s=t.groupId||"";if(!s)return void this.setStatus("Selected request has no owning group to target.","error");this.groups.find(e=>(e.groupId||"")===s)?(this.convertingRequestId=e,this.populateOrderModal({selectedAssigneeID:this.getSortedGroups().find(e=>(e.groupId||"")!==s)?.groupId||"",selectedTargetID:s,note:this.buildRequestOrderNote(t),priority:t.priority||"priority"}),document.getElementById("dispatcherOrderModalTitle").textContent="Create Order From Request",document.getElementById("dispatcherOrderModal").classList.remove("is-hidden"),this.setStatus("Preparing dispatch order from request...","info")):this.setStatus("Selected request group is no longer available.","error")},convertViewedRequestToOrder(){if(!this.viewingRequestId)return;const e=this.viewingRequestId;this.closeRequestModal(),this.convertRequestToOrder(e)},populateOrderModal(e={}){const t=this.getSortedGroups(),s=document.getElementById("dispatcherOrderAssigneeSelect"),n=document.getElementById("dispatcherOrderTargetSelect"),r=document.getElementById("dispatcherOrderNoteInput"),i=document.getElementById("dispatcherOrderPrioritySelect");if(!s||!n)return;const d=e.selectedAssigneeID||"",a=e.selectedTargetID||"",o=d||t.find(e=>(e.groupId||"")!==a)?.groupId||t[0]?.groupId||"",c=a||t.find(e=>(e.groupId||"")!==o)?.groupId||t[0]?.groupId||"";s.innerHTML=this.buildGroupOptions(o),n.innerHTML=this.buildGroupOptions(c),r&&(r.value=e.note||""),i&&(i.value=e.priority||"priority")},syncOrderModal(){const e=document.getElementById("dispatcherOrderModal");e&&!e.classList.contains("is-hidden")&&this.populateOrderModal({selectedAssigneeID:document.getElementById("dispatcherOrderAssigneeSelect")?.value||"",selectedTargetID:document.getElementById("dispatcherOrderTargetSelect")?.value||"",note:document.getElementById("dispatcherOrderNoteInput")?.value||"",priority:document.getElementById("dispatcherOrderPrioritySelect")?.value||"priority"})},openGroupModal(e){const t=this.groups.find(t=>t.groupId===e);t&&(this.editingGroupId=e,document.getElementById("dispatcherModalGroupCallsign").textContent=t.callsign||t.groupId||"Unknown",document.getElementById("dispatcherModalGroupLeader").textContent=t.leaderName||"Unknown",document.getElementById("dispatcherModalGroupTask").textContent=t.currentTaskId||"None",document.getElementById("dispatcherModalGroupOrg").textContent=t.orgId||"default",document.getElementById("dispatcherModalRoleSelect").innerHTML=this.roles.map(e=>`${e.replaceAll("_"," ")} `).join(""),document.getElementById("dispatcherModalStatusSelect").innerHTML=this.statuses.map(e=>`${e.replaceAll("_"," ")} `).join(""),document.getElementById("dispatcherGroupModal").classList.remove("is-hidden"))},closeGroupModal(){this.editingGroupId="",document.getElementById("dispatcherGroupModal").classList.add("is-hidden")},syncOpenModal(){if(!this.editingGroupId)return;const e=this.groups.find(e=>e.groupId===this.editingGroupId);e?(document.getElementById("dispatcherModalGroupCallsign").textContent=e.callsign||e.groupId||"Unknown",document.getElementById("dispatcherModalGroupLeader").textContent=e.leaderName||"Unknown",document.getElementById("dispatcherModalGroupTask").textContent=e.currentTaskId||"None",document.getElementById("dispatcherModalGroupOrg").textContent=e.orgId||"default"):this.closeGroupModal()}},window.cadDispatcherRender={updateDangerAlert(){const e=document.getElementById("dispatcherDangerAlert");if(!e)return;const t=this.getDangerGroups();if(!t.length)return e.textContent="",void e.classList.add("is-hidden");const s=t.map(e=>e.callsign||e.groupId||"Unknown Group");e.textContent=`Danger alert active: ${s.join(", ")}`,e.classList.remove("is-hidden")},updateRequestAlert(){const e=document.getElementById("dispatcherRequestAlert");if(!e)return;const t=this.buildSupportAlertMessage();if(!t)return e.textContent="",void e.classList.add("is-hidden");e.textContent=t,e.classList.remove("is-hidden")},buildGroupEditorButton:e=>`\n \n ⚙\n \n `,buildCloseOrderButton:e=>`\n \n Close\n \n `,buildCloseRequestButton:e=>`\n \n Close\n \n `,buildConvertRequestButton:e=>`\n \n Convert to Order\n \n `,renderMetrics(){const e=this.contracts.filter(e=>"unassigned"!==(e.assignmentState||"unassigned")),t=this.contracts.filter(e=>"unassigned"===(e.assignmentState||"unassigned")),s=this.requests.length,n=this.getSupportAlertRequests(),r=this.groups.filter(e=>"danger"===(e.status||""));document.getElementById("metricOpenContracts").textContent=t.length,document.getElementById("metricAssignedContracts").textContent=e.length,document.getElementById("metricActiveGroups").textContent=this.groups.length,document.getElementById("metricOpenRequests").textContent=s,document.getElementById("metricDangerGroups").textContent=r.length;const i=document.getElementById("metricDangerGroupsCard");i&&i.classList.toggle("is-danger",r.length>0);const d=document.getElementById("metricOpenRequestsCard");d&&d.classList.toggle("is-warning",n.length>0)},renderOpenContracts(){const e=document.getElementById("dispatcherOpenContracts"),t=this.contracts.filter(e=>"unassigned"===(e.assignmentState||"unassigned"));if(!t.length)return void(e.innerHTML='');const s=this.buildGroupOptions("");e.innerHTML=t.map(e=>{const t=e.taskId||e.taskID||"",n=Array.isArray(e.position)?e.position:[0,0,0],r=this.groups.find(t=>t.groupId===(e.targetGroupId||""));return`\n \n \n ${e.description||""}
\n \n Unassigned \n ${window.mapUI.formatPosition(n)} \n
\n \n Target: ${r?r.callsign:e.targetGroupCallsign||"None"} \n Priority: ${(e.priority||"priority").replaceAll("_"," ")} \n
\n \n \n Assign to group \n ${s}\n \n Assign \n
\n \n `}).join("")},renderAssignedContracts(){const e=document.getElementById("dispatcherAssignedContracts"),t=this.contracts.filter(e=>"unassigned"!==(e.assignmentState||"unassigned"));t.length?e.innerHTML=t.map(e=>{const t=e.taskId||e.taskID||"",s=this.groups.find(t=>t.groupId===(e.assignedGroupId||"")),n=this.groups.find(t=>t.groupId===(e.targetGroupId||"")),r=this.isDispatchOrder(e);return`\n \n \n ${e.description||""}
\n \n Group: ${s?s.callsign:e.assignedGroupId||"Unknown"} \n Type: ${this.formatTypeLabel(e)} \n
\n \n Target: ${n?n.callsign:e.targetGroupCallsign||"None"} \n Priority: ${(e.priority||"priority").replaceAll("_"," ")} \n
\n ${r?`${this.buildCloseOrderButton(t)}
`:""}\n \n `}).join(""):e.innerHTML=''},renderGroups(){const e=document.getElementById("dispatcherGroups");this.groups.length?e.innerHTML=this.getSortedGroups().map(e=>{const t="danger"===(e.status||"");return`\n \n \n \n Leader: ${e.leaderName||"Unknown"} \n Status: ${e.status||"unknown"} \n
\n \n Org: ${e.orgId||"default"} \n Task: ${e.currentTaskId||"None"} \n
\n \n `}).join(""):e.innerHTML='No active groups available.
'},renderActivity(){const e=document.getElementById("dispatcherActivity"),t=this.requests.length?this.requests.map(e=>`\n \n \n ${e.summary||""}
\n \n Group: ${e.groupCallsign||e.groupId||"Unknown"} \n ${this.getRequestTypeLabel(e.type||"request")} \n
\n \n ${this.buildConvertRequestButton(e.requestId||"")}\n ${this.buildCloseRequestButton(e.requestId||"")}\n
\n \n `).join(""):'No active support requests.
',s=this.activity.length?this.activity.slice().reverse().slice(0,8).map(e=>`\n \n \n ${e.message||""}
\n \n `).join(""):'';e.innerHTML=`\n \n \n ${t}\n
\n \n \n ${s}\n
\n `},render(){this.updateDangerAlert(),this.updateRequestAlert(),this.renderMetrics(),this.renderOpenContracts(),this.renderAssignedContracts(),this.renderGroups(),this.renderActivity()}};const dispatcherFormatters=window.cadDispatcherFormatters||{},dispatcherModals=window.cadDispatcherModals||{},dispatcherRender=window.cadDispatcherRender||{};window.cadDispatcher={contracts:[],requests:[],groups:[],activity:[],session:{},editingGroupId:"",viewingRequestId:"",convertingRequestId:"",statuses:["available","en_route","on_task","holding","danger","unavailable"],roles:["infantry","recon","armor","air","logistics","support"],...dispatcherFormatters,...dispatcherModals,...dispatcherRender,init(){document.getElementById("dispatcherCreateOrderBtn").addEventListener("click",()=>{this.openOrderModal()}),document.getElementById("dispatcherGroupModalCloseBtn").addEventListener("click",()=>{this.closeGroupModal()}),document.getElementById("dispatcherGroupModalSaveBtn").addEventListener("click",()=>{this.applyGroupUpdates()}),document.querySelector("#dispatcherGroupModal .dispatch-modal-backdrop").addEventListener("click",()=>{this.closeGroupModal()}),document.getElementById("dispatcherOrderModalCloseBtn").addEventListener("click",()=>{this.closeOrderModal()}),document.getElementById("dispatcherOrderModalSaveBtn").addEventListener("click",()=>{this.createDispatchOrder()}),document.querySelector("#dispatcherOrderModal .dispatch-modal-backdrop").addEventListener("click",()=>{this.closeOrderModal()}),document.getElementById("dispatcherRequestModalCloseBtn").addEventListener("click",()=>{this.closeRequestModal()}),document.getElementById("dispatcherRequestModalDoneBtn").addEventListener("click",()=>{this.closeRequestModal()}),document.getElementById("dispatcherRequestConvertBtn").addEventListener("click",()=>{this.convertViewedRequestToOrder()}),document.querySelector("#dispatcherRequestModal .dispatch-modal-backdrop").addEventListener("click",()=>{this.closeRequestModal()}),window.mapUI.sendEvent("cad::dispatcher::ready",{})},receiveHydrate(e){this.contracts=Array.isArray(e.contracts)?e.contracts:[],this.requests=Array.isArray(e.requests)?e.requests:[],this.groups=Array.isArray(e.groups)?e.groups:[],this.activity=Array.isArray(e.activity)?e.activity:[],this.session=e.session&&"object"==typeof e.session?e.session:{};const t=document.getElementById("dispatcherStatusMessage");!t||t.dataset.type&&"info"!==t.dataset.type||this.setStatus("",""),this.syncOpenModal(),this.syncOrderModal(),this.syncRequestModal(),this.render()},setStatus(e,t){const s=document.getElementById("dispatcherStatusMessage");s&&(s.textContent=e||"",s.dataset.type=t||"")},createDispatchOrder(){const e=document.getElementById("dispatcherOrderAssigneeSelect").value,t=document.getElementById("dispatcherOrderTargetSelect").value,s=document.getElementById("dispatcherOrderPrioritySelect").value,n=document.getElementById("dispatcherOrderNoteInput").value,r=this.convertingRequestId&&this.requests.find(e=>(e.requestId||"")===this.convertingRequestId)||null;e&&t?e!==t?(this.setStatus(this.convertingRequestId?"Creating dispatch order from request...":"Creating dispatch order...","info"),window.mapUI.sendEvent("cad::dispatchOrder::create",{assigneeGroupID:e,targetGroupID:t,note:n.trim(),priority:s,request:r?{requestId:r.requestId||"",type:r.type||"",title:r.title||"",summary:r.summary||"",fields:r.fields&&"object"==typeof r.fields?r.fields:{}}:{}}),this.closeOrderModal()):this.setStatus("Assignee and target groups must be different.","error"):this.setStatus("Select both an assignee and a target group.","error")},assignTask(e){const t=document.getElementById(`dispatcher-assign-group-${e}`);t&&t.value?(this.setStatus("Submitting assignment...","info"),window.mapUI.sendEvent("cad::tasks::assign",{taskID:e,groupID:t.value,note:""})):this.setStatus("Select a group before assigning a contract.","error")},applyGroupUpdates(){if(!this.editingGroupId)return;const e=this.groups.find(e=>e.groupId===this.editingGroupId);if(!e)return void this.closeGroupModal();const t=document.getElementById("dispatcherModalRoleSelect").value,s=document.getElementById("dispatcherModalStatusSelect").value,n=t&&t!==(e.role||"")?t:"",r=s&&s!==(e.status||"")?s:"";if(!(n||r))return this.setStatus("No group changes to save.","info"),void this.closeGroupModal();this.setStatus("Updating group profile...","info"),window.mapUI.sendEvent("cad::groups::profile",{groupID:this.editingGroupId,role:n,status:r}),this.closeGroupModal()},closeDispatchOrder(e){e&&(this.setStatus("Closing dispatch order...","info"),window.mapUI.sendEvent("cad::dispatchOrder::close",{taskID:e}))},closeSupportRequest(e){e&&(this.setStatus("Closing support request...","info"),window.mapUI.sendEvent("cad::supportRequest::close",{requestID:e}))}},window.cadDispatcher.init();
\ No newline at end of file
diff --git a/arma/client/addons/cad/ui/src/dispatcher/formatters.js b/arma/client/addons/cad/ui/src/dispatcher/formatters.js
index 5377e45..c78a92b 100644
--- a/arma/client/addons/cad/ui/src/dispatcher/formatters.js
+++ b/arma/client/addons/cad/ui/src/dispatcher/formatters.js
@@ -95,9 +95,26 @@ window.cadDispatcherFormatters = {
const groupLabel =
request.groupCallsign || request.groupId || "Unknown Group";
const summary = (request.summary || "").trim();
+ const fieldDetails =
+ request.fields && typeof request.fields === "object"
+ ? Object.entries(request.fields)
+ .map(([fieldID, value]) => {
+ const fieldValue =
+ this.formatRequestFieldValue(value);
+ if (fieldValue === "Not provided") {
+ return "";
+ }
- return summary
- ? `${typeLabel} requested by ${groupLabel}. ${summary}`
+ return `${this.formatRequestFieldLabel(fieldID)} ${fieldValue}`;
+ })
+ .filter(Boolean)
+ : [];
+ const details = fieldDetails.length
+ ? fieldDetails
+ : [summary].filter(Boolean);
+
+ return details.length
+ ? `${typeLabel} requested by ${groupLabel}. ${details.join(" | ")}`
: `${typeLabel} requested by ${groupLabel}.`;
},
};
diff --git a/arma/client/addons/cad/ui/src/dispatcher/index.js b/arma/client/addons/cad/ui/src/dispatcher/index.js
index 6b6cc5a..a41293c 100644
--- a/arma/client/addons/cad/ui/src/dispatcher/index.js
+++ b/arma/client/addons/cad/ui/src/dispatcher/index.js
@@ -137,6 +137,12 @@ window.cadDispatcher = {
"dispatcherOrderPrioritySelect",
).value;
const note = document.getElementById("dispatcherOrderNoteInput").value;
+ const sourceRequest = this.convertingRequestId
+ ? this.requests.find(
+ (entry) =>
+ (entry.requestId || "") === this.convertingRequestId,
+ ) || null
+ : null;
if (!assigneeGroupID || !targetGroupID) {
this.setStatus(
@@ -165,6 +171,19 @@ window.cadDispatcher = {
targetGroupID: targetGroupID,
note: note.trim(),
priority: priority,
+ request: sourceRequest
+ ? {
+ requestId: sourceRequest.requestId || "",
+ type: sourceRequest.type || "",
+ title: sourceRequest.title || "",
+ summary: sourceRequest.summary || "",
+ fields:
+ sourceRequest.fields &&
+ typeof sourceRequest.fields === "object"
+ ? sourceRequest.fields
+ : {},
+ }
+ : {},
});
this.closeOrderModal();
diff --git a/arma/client/addons/cad/ui/src/dispatcher/modals.js b/arma/client/addons/cad/ui/src/dispatcher/modals.js
index 6d641f0..e053169 100644
--- a/arma/client/addons/cad/ui/src/dispatcher/modals.js
+++ b/arma/client/addons/cad/ui/src/dispatcher/modals.js
@@ -137,8 +137,9 @@ window.cadDispatcherModals = {
return;
}
+ const requestID = this.viewingRequestId;
this.closeRequestModal();
- this.convertRequestToOrder(this.viewingRequestId);
+ this.convertRequestToOrder(requestID);
},
populateOrderModal(options = {}) {
const sortedGroups = this.getSortedGroups();
diff --git a/arma/server/addons/cad/XEH_preInit.sqf b/arma/server/addons/cad/XEH_preInit.sqf
index eef8a47..187b250 100644
--- a/arma/server/addons/cad/XEH_preInit.sqf
+++ b/arma/server/addons/cad/XEH_preInit.sqf
@@ -45,7 +45,8 @@ call FUNC(initCadStore);
["_assigneeGroupID", "", [""]],
["_targetGroupID", "", [""]],
["_note", "", [""]],
- ["_priority", "priority", [""]]
+ ["_priority", "priority", [""]],
+ ["_request", createHashMap, [createHashMap]]
];
if (_assigneeGroupID isEqualTo "" || { _targetGroupID isEqualTo "" }) exitWith {
@@ -57,7 +58,7 @@ call FUNC(initCadStore);
"Invalid CAD dispatch order payload.",
CRPC(cad,responseCadAssignment),
"createDispatchOrder",
- [_uid, _assigneeGroupID, _targetGroupID, _note, _priority],
+ [_uid, _assigneeGroupID, _targetGroupID, _note, _priority, _request],
true,
false
]];
diff --git a/arma/server/addons/cad/functions/fnc_initAssignmentRepository.sqf b/arma/server/addons/cad/functions/fnc_initAssignmentRepository.sqf
index d48591a..cb3d5be 100644
--- a/arma/server/addons/cad/functions/fnc_initAssignmentRepository.sqf
+++ b/arma/server/addons/cad/functions/fnc_initAssignmentRepository.sqf
@@ -243,7 +243,8 @@ GVAR(AssignmentRepositoryBaseClass) = compileFinal createHashMapFromArray [
["_assigneeGroupID", "", [""]],
["_targetGroupID", "", [""]],
["_note", "", [""]],
- ["_priority", "priority", [""]]
+ ["_priority", "priority", [""]],
+ ["_request", createHashMap, [createHashMap]]
];
private _result = createHashMapFromArray [
@@ -305,6 +306,11 @@ GVAR(AssignmentRepositoryBaseClass) = compileFinal createHashMapFromArray [
["targetPosition", +(_targetGroup getOrDefault ["position", []])],
["createdByUid", _requesterUid],
["createdByName", ["Dispatcher", name _requesterPlayer] select (_requesterPlayer isNotEqualTo objNull)],
+ ["requestId", _request getOrDefault ["requestId", ""]],
+ ["requestType", _request getOrDefault ["type", ""]],
+ ["requestTitle", _request getOrDefault ["title", ""]],
+ ["requestSummary", _request getOrDefault ["summary", ""]],
+ ["requestFields", +(_request getOrDefault ["fields", createHashMap])],
["note", _note],
["priority", _finalPriority],
["createdAt", serverTime]
diff --git a/arma/server/addons/task/functions/fnc_handleTaskRewards.sqf b/arma/server/addons/task/functions/fnc_handleTaskRewards.sqf
index 51f8b14..e0e83bf 100644
--- a/arma/server/addons/task/functions/fnc_handleTaskRewards.sqf
+++ b/arma/server/addons/task/functions/fnc_handleTaskRewards.sqf
@@ -102,18 +102,25 @@ if (_funds > 0) then {
["ERROR", format ["Failed to load organization %1 for task %2 funds reward.", _orgID, _taskID]] call EFUNC(common,log);
_success = false;
} else {
- private _patch = EGVAR(org,OrgStore) call [
- "set",
+ private _nextFunds = (_org getOrDefault ["funds", 0]) + _funds;
+ _org set ["funds", _nextFunds];
+ private _updatedOrg = EGVAR(org,OrgStore) call [
+ "callHotOrg",
[
- _orgID,
- "funds",
- ((_org getOrDefault ["funds", 0]) + _funds),
- false
+ "org:hot:override",
+ [_orgID, toJSON _org]
]
];
- [_patch] call _syncOrgPatch;
- _rewardMessages pushBack format ["$%1 org funds", [_funds] call EFUNC(common,formatNumber)];
+ if (_updatedOrg isEqualTo createHashMap) then {
+ ["ERROR", format ["Failed to update organization %1 funds for task %2.", _orgID, _taskID]] call EFUNC(common,log);
+ _success = false;
+ } else {
+ private _patch = createHashMapFromArray [["funds", _nextFunds]];
+
+ [_patch] call _syncOrgPatch;
+ _rewardMessages pushBack format ["$%1 org funds", [_funds] call EFUNC(common,formatNumber)];
+ };
};
};
diff --git a/arma/server/addons/task/functions/fnc_initTaskStore.sqf b/arma/server/addons/task/functions/fnc_initTaskStore.sqf
index e102283..f0410f9 100644
--- a/arma/server/addons/task/functions/fnc_initTaskStore.sqf
+++ b/arma/server/addons/task/functions/fnc_initTaskStore.sqf
@@ -22,11 +22,6 @@ GVAR(TaskStore) = createHashMapObject [[
["#type", "TaskStore"],
["#create", compileFinal {
_self set ["participantRegistry", createHashMap];
- _self set ["defuseRegistry", createHashMap];
- _self set ["taskOwnershipRegistry", createHashMap];
- _self set ["taskStatusRegistry", createHashMap];
- _self set ["completedTaskStatusRegistry", createHashMap];
- _self set ["taskCatalogRegistry", createHashMap];
_self set ["taskEntityRegistries", createHashMapFromArray [
["cargo", createHashMap],
["hostages", createHashMap],
@@ -36,6 +31,55 @@ GVAR(TaskStore) = createHashMapObject [[
["shooters", createHashMap],
["targets", createHashMap]
]];
+
+ ["task:reset", []] call EFUNC(extension,extCall) params ["_result", "_isSuccess"];
+ if (
+ !_isSuccess
+ || { !(_result isEqualType "") }
+ || { (_result find "Error:") == 0 }
+ ) then {
+ ["WARNING", "Failed to reset task backend state during task store initialization."] call EFUNC(common,log);
+ };
+ }],
+ ["callTaskStateEnvelope", compileFinal {
+ params [["_function", "", [""]], ["_arguments", [], [[]]]];
+
+ private _envelope = createHashMapFromArray [
+ ["success", false],
+ ["error", ""]
+ ];
+
+ if (_function isEqualTo "") exitWith { _envelope };
+
+ [_function, _arguments] call EFUNC(extension,extCall) params ["_result", "_isSuccess"];
+ if !_isSuccess exitWith {
+ _envelope set ["error", format ["Task backend call '%1' failed.", _function]];
+ _envelope
+ };
+ if !(_result isEqualType "") exitWith {
+ _envelope set ["error", format ["Task backend call '%1' returned an invalid response.", _function]];
+ _envelope
+ };
+ if ((_result find "Error:") == 0) exitWith {
+ ["ERROR", format ["Task extension call '%1' failed: %2", _function, _result]] call EFUNC(common,log);
+ _envelope set ["error", _result select [7]];
+ _envelope
+ };
+
+ _envelope set ["success", true];
+ if (_result isNotEqualTo "") then {
+ _envelope set ["data", fromJSON _result];
+ };
+
+ _envelope
+ }],
+ ["callTaskState", compileFinal {
+ params [["_function", "", [""]], ["_arguments", [], [[]]], ["_fallback", nil]];
+
+ private _envelope = _self call ["callTaskStateEnvelope", [_function, _arguments]];
+ if !(_envelope getOrDefault ["success", false]) exitWith { _fallback };
+
+ _envelope getOrDefault ["data", _fallback]
}],
["bindTaskOwnership", compileFinal {
params [["_taskID", "", [""]], ["_requesterUid", "", [""]]];
@@ -52,51 +96,46 @@ GVAR(TaskStore) = createHashMapObject [[
_result
};
- if (_requesterUid isEqualTo "") exitWith {
- private _taskOwnershipRegistry = _self getOrDefault ["taskOwnershipRegistry", createHashMap];
- _taskOwnershipRegistry set [_taskID, createHashMapFromArray [
- ["requesterUid", ""],
- ["orgID", "default"]
- ]];
- _self set ["taskOwnershipRegistry", _taskOwnershipRegistry];
+ private _orgID = "default";
- _result set ["success", true];
- _result set ["message", "No requester UID provided. Bound task to default organization."];
- _result
+ if (_requesterUid isNotEqualTo "") then {
+ private _actor = EGVAR(actor,Registry) getOrDefault [_requesterUid, createHashMap];
+ if (_actor isEqualTo createHashMap) then {
+ _actor = EGVAR(actor,ActorStore) call ["init", [_requesterUid]];
+ };
+
+ if (_actor isEqualTo createHashMap) exitWith {
+ _result set ["message", format ["Failed to load actor for %1.", _requesterUid]];
+ _result
+ };
+
+ _orgID = _actor getOrDefault ["organization", ""];
+ if (_orgID isEqualTo "") then { _orgID = "default"; };
};
- private _actor = EGVAR(actor,Registry) getOrDefault [_requesterUid, createHashMap];
- if (_actor isEqualTo createHashMap) then {
- _actor = EGVAR(actor,ActorStore) call ["init", [_requesterUid]];
- };
-
- if (_actor isEqualTo createHashMap) exitWith {
- _result set ["message", format ["Failed to load actor for %1.", _requesterUid]];
- _result
- };
-
- private _orgID = _actor getOrDefault ["organization", ""];
- if (_orgID isEqualTo "") then { _orgID = "default"; };
-
- private _taskOwnershipRegistry = _self getOrDefault ["taskOwnershipRegistry", createHashMap];
- _taskOwnershipRegistry set [_taskID, createHashMapFromArray [
+ private _context = createHashMapFromArray [
["requesterUid", _requesterUid],
- ["orgID", _orgID]
- ]];
- _self set ["taskOwnershipRegistry", _taskOwnershipRegistry];
-
- private _taskCatalogRegistry = _self getOrDefault ["taskCatalogRegistry", createHashMap];
- private _catalogEntry = +(_taskCatalogRegistry getOrDefault [_taskID, createHashMap]);
- if (_catalogEntry isNotEqualTo createHashMap) then {
- _catalogEntry set ["requesterUid", _requesterUid];
- _catalogEntry set ["orgID", _orgID];
- _catalogEntry set ["accepted", true];
- _taskCatalogRegistry set [_taskID, _catalogEntry];
- _self set ["taskCatalogRegistry", _taskCatalogRegistry];
+ ["orgId", _orgID]
+ ];
+ private _envelope = _self call [
+ "callTaskStateEnvelope",
+ [
+ "task:ownership:bind",
+ [_taskID, toJSON _context]
+ ]
+ ];
+ if !(_envelope getOrDefault ["success", false]) exitWith {
+ _result set ["message", _envelope getOrDefault ["error", "Failed to bind task ownership."]];
+ _result
};
+ private _bindResult = _envelope getOrDefault ["data", createHashMap];
_result set ["success", true];
- _result set ["orgID", _orgID];
+ _result set ["message", _bindResult getOrDefault [
+ "message",
+ ["No requester UID provided. Bound task to default organization.", "Task ownership updated."] select (_requesterUid isNotEqualTo "")
+ ]];
+ _result set ["orgID", _bindResult getOrDefault ["orgId", _orgID]];
_result
}],
["releaseTaskOwnership", compileFinal {
@@ -104,45 +143,26 @@ GVAR(TaskStore) = createHashMapObject [[
if (_taskID isEqualTo "") exitWith { false };
- private _taskOwnershipRegistry = _self getOrDefault ["taskOwnershipRegistry", createHashMap];
- _taskOwnershipRegistry deleteAt _taskID;
- _self set ["taskOwnershipRegistry", _taskOwnershipRegistry];
-
- private _taskCatalogRegistry = _self getOrDefault ["taskCatalogRegistry", createHashMap];
- private _catalogEntry = +(_taskCatalogRegistry getOrDefault [_taskID, createHashMap]);
- if (_catalogEntry isNotEqualTo createHashMap) then {
- _catalogEntry set ["requesterUid", ""];
- _catalogEntry set ["orgID", "default"];
- _catalogEntry set ["accepted", false];
- _taskCatalogRegistry set [_taskID, _catalogEntry];
- _self set ["taskCatalogRegistry", _taskCatalogRegistry];
- };
-
- true
+ private _envelope = _self call ["callTaskStateEnvelope", ["task:ownership:release", [_taskID]]];
+ _envelope getOrDefault ["success", false]
}],
["registerTaskCatalogEntry", compileFinal {
params [["_taskID", "", [""]], ["_entry", createHashMap, [createHashMap]]];
if (_taskID isEqualTo "" || { _entry isEqualTo createHashMap }) exitWith { false };
- private _taskCatalogRegistry = _self getOrDefault ["taskCatalogRegistry", createHashMap];
- _taskCatalogRegistry set [_taskID, +_entry];
- _self set ["taskCatalogRegistry", _taskCatalogRegistry];
- true
+ private _envelope = _self call [
+ "callTaskStateEnvelope",
+ [
+ "task:catalog:upsert",
+ [_taskID, toJSON _entry]
+ ]
+ ];
+ _envelope getOrDefault ["success", false]
}],
["getActiveTaskCatalog", compileFinal {
- private _taskCatalogRegistry = _self getOrDefault ["taskCatalogRegistry", createHashMap];
- private _taskStatusRegistry = _self getOrDefault ["taskStatusRegistry", createHashMap];
- private _entries = [];
-
- {
- if ((_taskStatusRegistry getOrDefault [_x, ""]) isNotEqualTo "active") then { continue; };
-
- private _entry = +_y;
- _entry set ["taskID", _x];
- _entry set ["status", "active"];
- _entries pushBack _entry;
- } forEach _taskCatalogRegistry;
+ private _entries = _self call ["callTaskState", ["task:catalog:active", [], []]];
+ if !(_entries isEqualType []) exitWith { [] };
_entries
}],
@@ -160,45 +180,43 @@ GVAR(TaskStore) = createHashMapObject [[
_result
};
- if ((_self call ["getTaskStatus", [_taskID]]) isNotEqualTo "active") exitWith {
- _result set ["message", "Task is no longer active."];
+ private _actor = EGVAR(actor,Registry) getOrDefault [_requesterUid, createHashMap];
+ if (_actor isEqualTo createHashMap) then {
+ _actor = EGVAR(actor,ActorStore) call ["init", [_requesterUid]];
+ };
+ if (_actor isEqualTo createHashMap) exitWith {
+ _result set ["message", format ["Failed to load actor for %1.", _requesterUid]];
_result
};
- private _taskCatalogRegistry = _self getOrDefault ["taskCatalogRegistry", createHashMap];
- private _entry = +(_taskCatalogRegistry getOrDefault [_taskID, createHashMap]);
- if (_entry isEqualTo createHashMap) exitWith {
- _result set ["message", "Task does not exist."];
+ private _orgID = _actor getOrDefault ["organization", ""];
+ if (_orgID isEqualTo "") then { _orgID = "default"; };
+
+ private _context = createHashMapFromArray [
+ ["requesterUid", _requesterUid],
+ ["orgId", _orgID]
+ ];
+ private _envelope = _self call [
+ "callTaskStateEnvelope",
+ [
+ "task:ownership:accept",
+ [_taskID, toJSON _context]
+ ]
+ ];
+ if !(_envelope getOrDefault ["success", false]) exitWith {
+ _result set ["message", _envelope getOrDefault ["error", "Unable to accept task."]];
_result
};
- private _taskOwnershipRegistry = _self getOrDefault ["taskOwnershipRegistry", createHashMap];
- private _ownership = _taskOwnershipRegistry getOrDefault [_taskID, createHashMap];
- private _currentRequesterUid = _ownership getOrDefault ["requesterUid", ""];
-
- if (_currentRequesterUid isNotEqualTo "" && { _currentRequesterUid isNotEqualTo _requesterUid }) exitWith {
- _result set ["message", "Task has already been accepted."];
- _result set ["entry", _entry];
- _result
+ private _acceptResult = _envelope getOrDefault ["data", createHashMap];
+ private _entry = _acceptResult getOrDefault ["entry", createHashMap];
+ if !(_entry isEqualType createHashMap) then {
+ _entry = createHashMap;
};
- private _bindResult = _self call ["bindTaskOwnership", [_taskID, _requesterUid]];
- if !(_bindResult getOrDefault ["success", false]) exitWith {
- _result set ["message", _bindResult getOrDefault ["message", "Failed to bind task ownership."]];
- _result
- };
-
- private _updatedTaskCatalogRegistry = _self getOrDefault ["taskCatalogRegistry", createHashMap];
- private _updatedEntry = +(_updatedTaskCatalogRegistry getOrDefault [_taskID, _entry]);
- _updatedEntry set ["accepted", true];
- _updatedEntry set ["requesterUid", _requesterUid];
- _updatedEntry set ["orgID", _bindResult getOrDefault ["orgID", "default"]];
- _updatedTaskCatalogRegistry set [_taskID, _updatedEntry];
- _self set ["taskCatalogRegistry", _updatedTaskCatalogRegistry];
-
_result set ["success", true];
- _result set ["message", "Task accepted."];
- _result set ["entry", _updatedEntry];
+ _result set ["message", _acceptResult getOrDefault ["message", "Task accepted."]];
+ _result set ["entry", _entry];
_result
}],
["setTaskStatus", compileFinal {
@@ -206,42 +224,28 @@ GVAR(TaskStore) = createHashMapObject [[
if (_taskID isEqualTo "" || { _status isEqualTo "" }) exitWith { false };
- private _taskStatusRegistry = _self getOrDefault ["taskStatusRegistry", createHashMap];
- private _completedTaskStatusRegistry = _self getOrDefault ["completedTaskStatusRegistry", createHashMap];
- _taskStatusRegistry set [_taskID, _status];
- if (_status in ["succeeded", "failed"]) then {
- _completedTaskStatusRegistry set [_taskID, _status];
- } else {
- _completedTaskStatusRegistry deleteAt _taskID;
- };
- _self set ["taskStatusRegistry", _taskStatusRegistry];
- _self set ["completedTaskStatusRegistry", _completedTaskStatusRegistry];
- true
+ [(_self call ["callTaskState", ["task:status:set", [_taskID, _status], false]])] params [["_statusResult", false, [false]]];
+
+ _statusResult
}],
["getTaskStatus", compileFinal {
params [["_taskID", "", [""]]];
if (_taskID isEqualTo "") exitWith { "" };
- private _taskStatusRegistry = _self getOrDefault ["taskStatusRegistry", createHashMap];
- private _status = _taskStatusRegistry getOrDefault [_taskID, ""];
- if (_status isNotEqualTo "") exitWith { _status };
+ private _status = _self call ["callTaskState", ["task:status:get", [_taskID], ""]];
+ if !(_status isEqualType "") exitWith { "" };
- private _completedTaskStatusRegistry = _self getOrDefault ["completedTaskStatusRegistry", createHashMap];
- _completedTaskStatusRegistry getOrDefault [_taskID, ""]
+ _status
}],
["clearTaskStatus", compileFinal {
params [["_taskID", "", [""]]];
if (_taskID isEqualTo "") exitWith { false };
- private _taskStatusRegistry = _self getOrDefault ["taskStatusRegistry", createHashMap];
- private _completedTaskStatusRegistry = _self getOrDefault ["completedTaskStatusRegistry", createHashMap];
- _taskStatusRegistry deleteAt _taskID;
- _completedTaskStatusRegistry deleteAt _taskID;
- _self set ["taskStatusRegistry", _taskStatusRegistry];
- _self set ["completedTaskStatusRegistry", _completedTaskStatusRegistry];
- true
+ [(_self call ["callTaskState", ["task:status:clear", [_taskID], false]])] params [["_statusResult", false, [false]]];
+
+ _statusResult
}],
["registerTaskEntity", compileFinal {
params [["_registryKey", "", [""]], ["_taskID", "", [""]], ["_entity", objNull, [objNull]]];
@@ -343,18 +347,23 @@ GVAR(TaskStore) = createHashMapObject [[
if (_taskID isEqualTo "") exitWith { _result };
- private _taskOwnershipRegistry = _self getOrDefault ["taskOwnershipRegistry", createHashMap];
- private _ownership = _taskOwnershipRegistry getOrDefault [_taskID, createHashMap];
- if (_ownership isEqualTo createHashMap) exitWith { _result };
+ private _rewardState = _self call ["callTaskState", ["task:ownership:reward_context", [_taskID], createHashMap]];
+ if (_rewardState isEqualTo createHashMap) exitWith { _result };
- private _requesterUid = _ownership getOrDefault ["requesterUid", ""];
- private _resolvedOrgID = _ownership getOrDefault ["orgID", ""];
+ private _requesterUid = _rewardState getOrDefault ["requesterUid", ""];
+ private _resolvedOrgID = _rewardState getOrDefault ["orgId", ""];
if (_resolvedOrgID isEqualTo "") exitWith { _result };
private _org = EGVAR(org,OrgStore) call ["loadById", [_resolvedOrgID]];
private _memberUids = [];
if (_org isNotEqualTo createHashMap) then {
- _memberUids = EGVAR(org,OrgTreasuryService) call ["resolveOrgMemberUids", [_org, _requesterUid]];
+ private _members = _org getOrDefault ["members", createHashMap];
+ if (_members isEqualType createHashMap) then {
+ _memberUids = keys _members;
+ };
+ if (_requesterUid isNotEqualTo "" && { !(_requesterUid in _memberUids) }) then {
+ _memberUids pushBack _requesterUid;
+ };
};
_result set ["requesterUid", _requesterUid];
@@ -367,10 +376,8 @@ GVAR(TaskStore) = createHashMapObject [[
if (_taskID isEqualTo "") exitWith { 0 };
- private _defuseRegistry = _self getOrDefault ["defuseRegistry", createHashMap];
- private _nextCount = 1 + (_defuseRegistry getOrDefault [_taskID, 0]);
- _defuseRegistry set [_taskID, _nextCount];
- _self set ["defuseRegistry", _defuseRegistry];
+ private _nextCount = _self call ["callTaskState", ["task:defuse:increment", [_taskID], 0]];
+ if !(_nextCount isEqualType 0) exitWith { 0 };
_nextCount
}],
@@ -379,8 +386,10 @@ GVAR(TaskStore) = createHashMapObject [[
if (_taskID isEqualTo "") exitWith { 0 };
- private _defuseRegistry = _self getOrDefault ["defuseRegistry", createHashMap];
- _defuseRegistry getOrDefault [_taskID, 0]
+ private _defuseCount = _self call ["callTaskState", ["task:defuse:get", [_taskID], 0]];
+ if !(_defuseCount isEqualType 0) exitWith { 0 };
+
+ _defuseCount
}],
["notifyParticipants", compileFinal {
params [
@@ -410,22 +419,9 @@ GVAR(TaskStore) = createHashMapObject [[
if (_taskID isEqualTo "") exitWith { false };
private _participantRegistry = _self getOrDefault ["participantRegistry", createHashMap];
- private _defuseRegistry = _self getOrDefault ["defuseRegistry", createHashMap];
- private _taskOwnershipRegistry = _self getOrDefault ["taskOwnershipRegistry", createHashMap];
- private _taskStatusRegistry = _self getOrDefault ["taskStatusRegistry", createHashMap];
- private _taskCatalogRegistry = _self getOrDefault ["taskCatalogRegistry", createHashMap];
-
_participantRegistry deleteAt _taskID;
- _defuseRegistry deleteAt _taskID;
- _taskOwnershipRegistry deleteAt _taskID;
- _taskStatusRegistry deleteAt _taskID;
- _taskCatalogRegistry deleteAt _taskID;
-
_self set ["participantRegistry", _participantRegistry];
- _self set ["defuseRegistry", _defuseRegistry];
- _self set ["taskOwnershipRegistry", _taskOwnershipRegistry];
- _self set ["taskStatusRegistry", _taskStatusRegistry];
- _self set ["taskCatalogRegistry", _taskCatalogRegistry];
+ _self call ["callTaskState", ["task:clear", [_taskID], false]];
_self call ["clearTaskEntities", [_taskID]];
true
}],
@@ -532,24 +528,28 @@ GVAR(TaskStore) = createHashMapObject [[
if (_org isNotEqualTo createHashMap) then {
private _reputation = _org getOrDefault ["reputation", 0];
private _nextReputation = round (_reputation + _delta);
- private _patch = EGVAR(org,OrgStore) call [
- "set",
+ _org set ["reputation", _nextReputation];
+ private _updatedOrg = EGVAR(org,OrgStore) call [
+ "callHotOrg",
[
- _ownerOrgID,
- "reputation",
- _nextReputation,
- false
+ "org:hot:override",
+ [_ownerOrgID, toJSON _org]
]
];
- private _memberUids = _rewardContext getOrDefault ["memberUids", []];
- {
- private _player = [_x] call EFUNC(common,getPlayer);
- if (isNull _player) then { continue; };
- [CRPC(org,responseSyncOrg), [_patch], _player] call CFUNC(targetEvent);
- } forEach _memberUids;
+ if (_updatedOrg isNotEqualTo createHashMap) then {
+ private _patch = createHashMapFromArray [["reputation", _nextReputation]];
+ private _memberUids = _rewardContext getOrDefault ["memberUids", []];
+ {
+ private _player = [_x] call EFUNC(common,getPlayer);
+ if (isNull _player) then { continue; };
+ [CRPC(org,responseSyncOrg), [_patch], _player] call CFUNC(targetEvent);
+ } forEach _memberUids;
- _orgIds = [_ownerOrgID];
+ _orgIds = [_ownerOrgID];
+ } else {
+ ["ERROR", format ["Failed to update organization %1 reputation for task %2.", _ownerOrgID, _taskID]] call EFUNC(common,log);
+ };
};
};
diff --git a/arma/server/extension/src/lib.rs b/arma/server/extension/src/lib.rs
index c9a93ef..c6c775c 100644
--- a/arma/server/extension/src/lib.rs
+++ b/arma/server/extension/src/lib.rs
@@ -23,6 +23,7 @@ mod log;
pub mod org;
pub mod redis;
pub mod store;
+pub mod task;
pub mod terrain;
pub mod transport;
pub mod v_garage;
@@ -87,6 +88,7 @@ fn init() -> Extension {
.group("locker", locker::group())
.group("org", org::group())
.group("store", store::group())
+ .group("task", task::group())
.group("terrain", terrain::group())
.group("transport", transport::group())
.group(
diff --git a/arma/server/extension/src/task.rs b/arma/server/extension/src/task.rs
new file mode 100644
index 0000000..233d3dc
--- /dev/null
+++ b/arma/server/extension/src/task.rs
@@ -0,0 +1,123 @@
+//! Task hot-state operations for the Arma 3 server extension.
+//!
+//! The extension owns portable task metadata while SQF keeps Arma-only runtime
+//! state such as entity references and participant tracking.
+
+use arma_rs::Group;
+use forge_repositories::InMemoryTaskRepository;
+use forge_services::TaskStateService;
+use serde::Serialize;
+use std::sync::LazyLock;
+
+static TASK_SERVICE: LazyLock> =
+ LazyLock::new(|| TaskStateService::new(InMemoryTaskRepository::new()));
+
+pub fn group() -> Group {
+ Group::new()
+ .command("reset", reset)
+ .group(
+ "catalog",
+ Group::new()
+ .command("active", list_active_catalog)
+ .command("get", get_catalog_entry)
+ .command("upsert", upsert_catalog_entry)
+ .command("delete", delete_catalog_entry),
+ )
+ .group(
+ "ownership",
+ Group::new()
+ .command("bind", bind_ownership)
+ .command("release", release_ownership)
+ .command("accept", accept_task)
+ .command("reward_context", reward_context),
+ )
+ .group(
+ "status",
+ Group::new()
+ .command("set", set_status)
+ .command("get", get_status)
+ .command("clear", clear_status),
+ )
+ .group(
+ "defuse",
+ Group::new()
+ .command("increment", increment_defuse_count)
+ .command("get", get_defuse_count),
+ )
+ .command("clear", clear_task)
+}
+
+pub(crate) fn list_active_catalog() -> String {
+ serialize_json(TASK_SERVICE.list_active_catalog())
+}
+
+pub(crate) fn reset() -> String {
+ serialize_json(TASK_SERVICE.reset())
+}
+
+pub(crate) fn get_catalog_entry(entry_id: String) -> String {
+ serialize_json(TASK_SERVICE.get_catalog_entry(entry_id))
+}
+
+pub(crate) fn upsert_catalog_entry(entry_id: String, json_data: String) -> String {
+ serialize_json(TASK_SERVICE.upsert_catalog_entry(entry_id, json_data))
+}
+
+pub(crate) fn delete_catalog_entry(entry_id: String) -> String {
+ serialize_ok(TASK_SERVICE.delete_catalog_entry(entry_id))
+}
+
+pub(crate) fn bind_ownership(entry_id: String, json_data: String) -> String {
+ serialize_json(TASK_SERVICE.bind_ownership(entry_id, json_data))
+}
+
+pub(crate) fn release_ownership(entry_id: String) -> String {
+ serialize_json(TASK_SERVICE.release_ownership(entry_id))
+}
+
+pub(crate) fn accept_task(entry_id: String, json_data: String) -> String {
+ serialize_json(TASK_SERVICE.accept_task(entry_id, json_data))
+}
+
+pub(crate) fn reward_context(entry_id: String) -> String {
+ serialize_json(TASK_SERVICE.get_reward_context(entry_id))
+}
+
+pub(crate) fn set_status(entry_id: String, status: String) -> String {
+ serialize_json(TASK_SERVICE.set_status(entry_id, status))
+}
+
+pub(crate) fn get_status(entry_id: String) -> String {
+ serialize_json(TASK_SERVICE.get_status(entry_id))
+}
+
+pub(crate) fn clear_status(entry_id: String) -> String {
+ serialize_json(TASK_SERVICE.clear_status(entry_id))
+}
+
+pub(crate) fn increment_defuse_count(entry_id: String) -> String {
+ serialize_json(TASK_SERVICE.increment_defuse_count(entry_id))
+}
+
+pub(crate) fn get_defuse_count(entry_id: String) -> String {
+ serialize_json(TASK_SERVICE.get_defuse_count(entry_id))
+}
+
+pub(crate) fn clear_task(entry_id: String) -> String {
+ serialize_json(TASK_SERVICE.clear_task(entry_id))
+}
+
+fn serialize_json(result: Result) -> String {
+ match result {
+ Ok(value) => serde_json::to_string(&value)
+ .unwrap_or_else(|error| format!("Error: Failed to serialize task state: {error}")),
+ Err(error) => format!("Error: {error}"),
+ }
+}
+
+fn serialize_ok(result: Result<(), String>) -> String {
+ match result {
+ Ok(()) => "true".to_string(),
+ Err(error) => format!("Error: {error}"),
+ }
+}
diff --git a/lib/models/Cargo.toml b/lib/models/Cargo.toml
index 0e4470c..f0f9a26 100644
--- a/lib/models/Cargo.toml
+++ b/lib/models/Cargo.toml
@@ -12,10 +12,11 @@ serde_json = { workspace = true, optional = true }
forge-shared = { path = "../shared" }
[features]
-default = ["actor", "bank", "member", "org"]
+default = ["actor", "bank", "member", "org", "task"]
actor = ["arma-rs", "serde_json"]
bank = ["arma-rs", "serde_json"]
member = ["arma-rs", "serde_json"]
org = ["arma-rs", "serde_json"]
+task = ["arma-rs", "serde_json"]
arma-rs = ["arma-rs/serde_json"]
diff --git a/lib/models/src/cad.rs b/lib/models/src/cad.rs
index da8f153..ee58e7c 100644
--- a/lib/models/src/cad.rs
+++ b/lib/models/src/cad.rs
@@ -58,6 +58,16 @@ pub struct CadDispatchOrderContextSeed {
#[serde(default)]
pub created_by_name: String,
#[serde(default)]
+ pub request_id: String,
+ #[serde(default)]
+ pub request_type: String,
+ #[serde(default)]
+ pub request_title: String,
+ #[serde(default)]
+ pub request_summary: String,
+ #[serde(default)]
+ pub request_fields: CadRecord,
+ #[serde(default)]
pub note: String,
#[serde(default)]
pub priority: String,
diff --git a/lib/models/src/lib.rs b/lib/models/src/lib.rs
index 0267e91..53d7179 100644
--- a/lib/models/src/lib.rs
+++ b/lib/models/src/lib.rs
@@ -5,6 +5,7 @@ pub mod garage;
pub mod locker;
pub mod org;
pub mod store;
+pub mod task;
pub mod v_garage;
pub mod v_locker;
@@ -31,5 +32,8 @@ pub use store::{
StoreCheckoutContext, StoreCheckoutItemSeed, StoreCheckoutResult, StoreCheckoutVehicleSeed,
StoreGrantedItem, StoreGrantedVehicle,
};
+pub use task::{
+ TaskJsonMap, TaskOwnershipContext, TaskOwnershipMutationResult, TaskRecord, TaskRewardContext,
+};
pub use v_garage::{VGarage, VehicleCategory};
pub use v_locker::{EquipmentCategory, VLocker};
diff --git a/lib/models/src/task.rs b/lib/models/src/task.rs
new file mode 100644
index 0000000..75237ba
--- /dev/null
+++ b/lib/models/src/task.rs
@@ -0,0 +1,57 @@
+use serde::{Deserialize, Serialize};
+use serde_json::{Map, Value};
+
+pub type TaskJsonMap = Map;
+
+#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+#[serde(transparent)]
+pub struct TaskRecord {
+ pub fields: TaskJsonMap,
+}
+
+impl TaskRecord {
+ pub fn into_value(self) -> Value {
+ Value::Object(self.fields)
+ }
+
+ pub fn to_value(&self) -> Value {
+ Value::Object(self.fields.clone())
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.fields.is_empty()
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+#[serde(rename_all = "camelCase")]
+pub struct TaskOwnershipContext {
+ #[serde(default)]
+ pub requester_uid: String,
+ #[serde(default)]
+ pub org_id: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+#[serde(rename_all = "camelCase")]
+pub struct TaskOwnershipMutationResult {
+ #[serde(default)]
+ pub task_id: String,
+ #[serde(default)]
+ pub requester_uid: String,
+ #[serde(default)]
+ pub org_id: String,
+ #[serde(default)]
+ pub entry: Value,
+ #[serde(default)]
+ pub message: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+#[serde(rename_all = "camelCase")]
+pub struct TaskRewardContext {
+ #[serde(default)]
+ pub requester_uid: String,
+ #[serde(default)]
+ pub org_id: String,
+}
diff --git a/lib/repositories/src/lib.rs b/lib/repositories/src/lib.rs
index baeb8ea..2481c2a 100644
--- a/lib/repositories/src/lib.rs
+++ b/lib/repositories/src/lib.rs
@@ -4,6 +4,7 @@ pub mod cad;
pub mod garage;
pub mod locker;
pub mod org;
+pub mod task;
pub mod v_garage;
pub mod v_locker;
@@ -19,6 +20,7 @@ pub use locker::{
InMemoryLockerHotRepository, LockerHotRepository, LockerRepository, RedisLockerRepository,
};
pub use org::{InMemoryOrgHotRepository, OrgHotRepository, OrgRepository, RedisOrgRepository};
+pub use task::{InMemoryTaskRepository, TaskRepository};
pub use v_garage::{
InMemoryVGarageHotRepository, RedisVGarageRepository, VGarageHotRepository, VGarageRepository,
};
diff --git a/lib/repositories/src/task.rs b/lib/repositories/src/task.rs
new file mode 100644
index 0000000..cdd09b3
--- /dev/null
+++ b/lib/repositories/src/task.rs
@@ -0,0 +1,204 @@
+use forge_models::{TaskOwnershipContext, TaskRecord};
+use std::collections::HashMap;
+use std::sync::{Arc, RwLock};
+
+pub trait TaskRepository: Send + Sync {
+ fn reset(&self) -> Result<(), String>;
+
+ fn list_catalog(&self) -> Result, String>;
+ fn get_catalog_entry(&self, id: &str) -> Result, String>;
+ fn save_catalog_entry(&self, id: String, entry: TaskRecord) -> Result<(), String>;
+ fn delete_catalog_entry(&self, id: &str) -> Result<(), String>;
+
+ fn get_ownership(&self, id: &str) -> Result , String>;
+ fn save_ownership(&self, id: String, ownership: TaskOwnershipContext) -> Result<(), String>;
+ fn delete_ownership(&self, id: &str) -> Result<(), String>;
+
+ fn list_active_statuses(&self) -> Result, String>;
+ fn get_active_status(&self, id: &str) -> Result, String>;
+ fn set_active_status(&self, id: String, status: String) -> Result<(), String>;
+ fn delete_active_status(&self, id: &str) -> Result<(), String>;
+
+ fn get_completed_status(&self, id: &str) -> Result , String>;
+ fn set_completed_status(&self, id: String, status: String) -> Result<(), String>;
+ fn delete_completed_status(&self, id: &str) -> Result<(), String>;
+
+ fn increment_defuse_count(&self, id: &str) -> Result;
+ fn get_defuse_count(&self, id: &str) -> Result;
+ fn clear_defuse_count(&self, id: &str) -> Result<(), String>;
+}
+
+#[derive(Debug, Default)]
+struct TaskState {
+ catalog: HashMap,
+ ownership: HashMap,
+ active_statuses: HashMap,
+ completed_statuses: HashMap,
+ defuse_counts: HashMap,
+}
+
+#[derive(Clone, Debug, Default)]
+pub struct InMemoryTaskRepository {
+ state: Arc>,
+}
+
+impl InMemoryTaskRepository {
+ pub fn new() -> Self {
+ Self::default()
+ }
+}
+
+impl TaskRepository for InMemoryTaskRepository {
+ fn reset(&self) -> Result<(), String> {
+ let mut state = self
+ .state
+ .write()
+ .map_err(|_| "Task state lock poisoned.".to_string())?;
+ state.catalog.clear();
+ state.ownership.clear();
+ state.active_statuses.clear();
+ state.completed_statuses.clear();
+ state.defuse_counts.clear();
+ Ok(())
+ }
+
+ fn list_catalog(&self) -> Result, String> {
+ self.state
+ .read()
+ .map(|state| state.catalog.clone())
+ .map_err(|_| "Task catalog state lock poisoned.".to_string())
+ }
+
+ fn get_catalog_entry(&self, id: &str) -> Result, String> {
+ self.state
+ .read()
+ .map(|state| state.catalog.get(id).cloned())
+ .map_err(|_| "Task catalog state lock poisoned.".to_string())
+ }
+
+ fn save_catalog_entry(&self, id: String, entry: TaskRecord) -> Result<(), String> {
+ self.state
+ .write()
+ .map_err(|_| "Task catalog state lock poisoned.".to_string())?
+ .catalog
+ .insert(id, entry);
+ Ok(())
+ }
+
+ fn delete_catalog_entry(&self, id: &str) -> Result<(), String> {
+ self.state
+ .write()
+ .map_err(|_| "Task catalog state lock poisoned.".to_string())?
+ .catalog
+ .remove(id);
+ Ok(())
+ }
+
+ fn get_ownership(&self, id: &str) -> Result , String> {
+ self.state
+ .read()
+ .map(|state| state.ownership.get(id).cloned())
+ .map_err(|_| "Task ownership state lock poisoned.".to_string())
+ }
+
+ fn save_ownership(&self, id: String, ownership: TaskOwnershipContext) -> Result<(), String> {
+ self.state
+ .write()
+ .map_err(|_| "Task ownership state lock poisoned.".to_string())?
+ .ownership
+ .insert(id, ownership);
+ Ok(())
+ }
+
+ fn delete_ownership(&self, id: &str) -> Result<(), String> {
+ self.state
+ .write()
+ .map_err(|_| "Task ownership state lock poisoned.".to_string())?
+ .ownership
+ .remove(id);
+ Ok(())
+ }
+
+ fn list_active_statuses(&self) -> Result, String> {
+ self.state
+ .read()
+ .map(|state| state.active_statuses.clone())
+ .map_err(|_| "Task status state lock poisoned.".to_string())
+ }
+
+ fn get_active_status(&self, id: &str) -> Result, String> {
+ self.state
+ .read()
+ .map(|state| state.active_statuses.get(id).cloned())
+ .map_err(|_| "Task status state lock poisoned.".to_string())
+ }
+
+ fn set_active_status(&self, id: String, status: String) -> Result<(), String> {
+ self.state
+ .write()
+ .map_err(|_| "Task status state lock poisoned.".to_string())?
+ .active_statuses
+ .insert(id, status);
+ Ok(())
+ }
+
+ fn delete_active_status(&self, id: &str) -> Result<(), String> {
+ self.state
+ .write()
+ .map_err(|_| "Task status state lock poisoned.".to_string())?
+ .active_statuses
+ .remove(id);
+ Ok(())
+ }
+
+ fn get_completed_status(&self, id: &str) -> Result , String> {
+ self.state
+ .read()
+ .map(|state| state.completed_statuses.get(id).cloned())
+ .map_err(|_| "Task completed status state lock poisoned.".to_string())
+ }
+
+ fn set_completed_status(&self, id: String, status: String) -> Result<(), String> {
+ self.state
+ .write()
+ .map_err(|_| "Task completed status state lock poisoned.".to_string())?
+ .completed_statuses
+ .insert(id, status);
+ Ok(())
+ }
+
+ fn delete_completed_status(&self, id: &str) -> Result<(), String> {
+ self.state
+ .write()
+ .map_err(|_| "Task completed status state lock poisoned.".to_string())?
+ .completed_statuses
+ .remove(id);
+ Ok(())
+ }
+
+ fn increment_defuse_count(&self, id: &str) -> Result {
+ let mut state = self
+ .state
+ .write()
+ .map_err(|_| "Task defuse state lock poisoned.".to_string())?;
+ let next_count = 1 + state.defuse_counts.get(id).copied().unwrap_or_default();
+ state.defuse_counts.insert(id.to_string(), next_count);
+ Ok(next_count)
+ }
+
+ fn get_defuse_count(&self, id: &str) -> Result {
+ self.state
+ .read()
+ .map(|state| state.defuse_counts.get(id).copied().unwrap_or_default())
+ .map_err(|_| "Task defuse state lock poisoned.".to_string())
+ }
+
+ fn clear_defuse_count(&self, id: &str) -> Result<(), String> {
+ self.state
+ .write()
+ .map_err(|_| "Task defuse state lock poisoned.".to_string())?
+ .defuse_counts
+ .remove(id);
+ Ok(())
+ }
+}
diff --git a/lib/services/src/cad.rs b/lib/services/src/cad.rs
index 181e990..a165af0 100644
--- a/lib/services/src/cad.rs
+++ b/lib/services/src/cad.rs
@@ -253,6 +253,26 @@ impl CadStateService {
"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)),
@@ -755,8 +775,11 @@ impl CadStateService {
.unwrap_or_else(|| "unknown".to_string())
),
"logreq" => format!(
- "Category {} | Delivery {} | Location {}",
+ "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")
@@ -1031,6 +1054,61 @@ mod tests {
);
}
+ #[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();
diff --git a/lib/services/src/lib.rs b/lib/services/src/lib.rs
index 61fa35a..070143d 100644
--- a/lib/services/src/lib.rs
+++ b/lib/services/src/lib.rs
@@ -5,6 +5,7 @@ pub mod garage;
pub mod locker;
pub mod org;
pub mod store;
+pub mod task;
pub mod v_garage;
pub mod v_locker;
@@ -15,5 +16,6 @@ pub use garage::{GarageHotStateService, GarageService};
pub use locker::{LockerHotStateService, LockerService};
pub use org::{OrgHotStateService, OrgService};
pub use store::StoreService;
+pub use task::TaskStateService;
pub use v_garage::{VGarageHotStateService, VGarageService};
pub use v_locker::{VLockerHotStateService, VLockerService};
diff --git a/lib/services/src/task.rs b/lib/services/src/task.rs
new file mode 100644
index 0000000..292367f
--- /dev/null
+++ b/lib/services/src/task.rs
@@ -0,0 +1,379 @@
+use forge_models::{
+ TaskOwnershipContext, TaskOwnershipMutationResult, TaskRecord, TaskRewardContext,
+};
+use forge_repositories::TaskRepository;
+use serde_json::Value;
+
+pub struct TaskStateService {
+ repository: R,
+}
+
+impl TaskStateService {
+ pub fn new(repository: R) -> Self {
+ Self { repository }
+ }
+
+ pub fn reset(&self) -> Result {
+ self.repository.reset()?;
+ Ok(true)
+ }
+
+ pub fn upsert_catalog_entry(
+ &self,
+ entry_id: String,
+ json_data: String,
+ ) -> Result {
+ let entry_id = Self::validate_entry_id(entry_id)?;
+ let mut entry = Self::parse_record(&json_data)?;
+ Self::normalize_catalog_entry(&mut entry, &entry_id);
+ self.repository
+ .save_catalog_entry(entry_id, entry.clone())?;
+ Ok(entry)
+ }
+
+ pub fn get_catalog_entry(&self, entry_id: String) -> Result, String> {
+ let entry_id = Self::validate_entry_id(entry_id)?;
+ self.repository
+ .get_catalog_entry(&entry_id)
+ .map(|entry| entry.map(TaskRecord::into_value))
+ }
+
+ pub fn delete_catalog_entry(&self, entry_id: String) -> Result<(), String> {
+ let entry_id = Self::validate_entry_id(entry_id)?;
+ self.repository.delete_catalog_entry(&entry_id)
+ }
+
+ pub fn list_active_catalog(&self) -> Result, String> {
+ let catalog = self.repository.list_catalog()?;
+ let active_statuses = self.repository.list_active_statuses()?;
+ let mut active_entries = Vec::new();
+
+ for (task_id, status) in active_statuses {
+ if status != "active" {
+ continue;
+ }
+
+ let Some(entry) = catalog.get(&task_id) else {
+ continue;
+ };
+
+ let mut entry = entry.fields.clone();
+ entry.insert("taskId".to_string(), Value::String(task_id.clone()));
+ entry.insert("taskID".to_string(), Value::String(task_id));
+ entry.insert("status".to_string(), Value::String(status));
+ active_entries.push(Value::Object(entry));
+ }
+
+ Ok(active_entries)
+ }
+
+ pub fn bind_ownership(
+ &self,
+ entry_id: String,
+ json_data: String,
+ ) -> Result {
+ let entry_id = Self::validate_entry_id(entry_id)?;
+ let mut ownership = Self::parse_ownership_context(&json_data)?;
+ if ownership.org_id.trim().is_empty() {
+ ownership.org_id = "default".to_string();
+ }
+
+ self.repository
+ .save_ownership(entry_id.clone(), ownership.clone())?;
+ let entry = self.patch_catalog_ownership(
+ &entry_id,
+ true,
+ &ownership.requester_uid,
+ &ownership.org_id,
+ )?;
+
+ Ok(TaskOwnershipMutationResult {
+ task_id: entry_id,
+ requester_uid: ownership.requester_uid,
+ org_id: ownership.org_id,
+ entry,
+ message: "Task ownership updated.".to_string(),
+ })
+ }
+
+ pub fn release_ownership(
+ &self,
+ entry_id: String,
+ ) -> Result {
+ let entry_id = Self::validate_entry_id(entry_id)?;
+ let ownership = self
+ .repository
+ .get_ownership(&entry_id)?
+ .unwrap_or_default();
+ self.repository.delete_ownership(&entry_id)?;
+ let entry = self.patch_catalog_ownership(&entry_id, false, "", "default")?;
+
+ Ok(TaskOwnershipMutationResult {
+ task_id: entry_id,
+ requester_uid: ownership.requester_uid,
+ org_id: ownership.org_id,
+ entry,
+ message: "Task ownership released.".to_string(),
+ })
+ }
+
+ pub fn accept_task(
+ &self,
+ entry_id: String,
+ json_data: String,
+ ) -> Result {
+ let entry_id = Self::validate_entry_id(entry_id)?;
+ let ownership = Self::parse_ownership_context(&json_data)?;
+ if ownership.requester_uid.trim().is_empty() {
+ return Err("Missing task ID or requester UID.".to_string());
+ }
+
+ if self.get_status(entry_id.clone())? != "active" {
+ return Err("Task is no longer active.".to_string());
+ }
+
+ if let Some(existing) = self.repository.get_ownership(&entry_id)?
+ && !existing.requester_uid.trim().is_empty()
+ && existing.requester_uid != ownership.requester_uid
+ {
+ return Err("Task has already been accepted.".to_string());
+ }
+
+ let mut result = self.bind_ownership(
+ entry_id,
+ serde_json::to_string(&ownership)
+ .map_err(|error| format!("Failed to serialize task ownership: {error}"))?,
+ )?;
+ result.message = "Task accepted.".to_string();
+ Ok(result)
+ }
+
+ pub fn set_status(&self, entry_id: String, status: String) -> Result {
+ let entry_id = Self::validate_entry_id(entry_id)?;
+ let final_status = Self::validate_status(status)?;
+ self.repository
+ .set_active_status(entry_id.clone(), final_status.clone())?;
+ if matches!(final_status.as_str(), "succeeded" | "failed") {
+ self.repository
+ .set_completed_status(entry_id, final_status)?;
+ } else {
+ self.repository.delete_completed_status(&entry_id)?;
+ }
+
+ Ok(true)
+ }
+
+ pub fn get_status(&self, entry_id: String) -> Result {
+ let entry_id = Self::validate_entry_id(entry_id)?;
+ if let Some(status) = self.repository.get_active_status(&entry_id)? {
+ return Ok(status);
+ }
+
+ Ok(self
+ .repository
+ .get_completed_status(&entry_id)?
+ .unwrap_or_default())
+ }
+
+ pub fn clear_status(&self, entry_id: String) -> Result {
+ let entry_id = Self::validate_entry_id(entry_id)?;
+ self.repository.delete_active_status(&entry_id)?;
+ self.repository.delete_completed_status(&entry_id)?;
+ Ok(true)
+ }
+
+ pub fn get_reward_context(&self, entry_id: String) -> Result {
+ let entry_id = Self::validate_entry_id(entry_id)?;
+ let ownership = self
+ .repository
+ .get_ownership(&entry_id)?
+ .unwrap_or_default();
+ Ok(TaskRewardContext {
+ requester_uid: ownership.requester_uid,
+ org_id: ownership.org_id,
+ })
+ }
+
+ pub fn increment_defuse_count(&self, entry_id: String) -> Result {
+ let entry_id = Self::validate_entry_id(entry_id)?;
+ self.repository.increment_defuse_count(&entry_id)
+ }
+
+ pub fn get_defuse_count(&self, entry_id: String) -> Result {
+ let entry_id = Self::validate_entry_id(entry_id)?;
+ self.repository.get_defuse_count(&entry_id)
+ }
+
+ pub fn clear_task(&self, entry_id: String) -> Result {
+ let entry_id = Self::validate_entry_id(entry_id)?;
+ self.repository.delete_catalog_entry(&entry_id)?;
+ self.repository.delete_ownership(&entry_id)?;
+ self.repository.delete_active_status(&entry_id)?;
+ self.repository.delete_completed_status(&entry_id)?;
+ self.repository.clear_defuse_count(&entry_id)?;
+ Ok(true)
+ }
+
+ fn patch_catalog_ownership(
+ &self,
+ entry_id: &str,
+ accepted: bool,
+ requester_uid: &str,
+ org_id: &str,
+ ) -> Result {
+ let Some(mut entry) = self.repository.get_catalog_entry(entry_id)? else {
+ return Ok(Value::Null);
+ };
+
+ entry
+ .fields
+ .insert("accepted".to_string(), Value::Bool(accepted));
+ entry.fields.insert(
+ "requesterUid".to_string(),
+ Value::String(requester_uid.to_string()),
+ );
+ entry
+ .fields
+ .insert("orgID".to_string(), Value::String(org_id.to_string()));
+ Self::normalize_catalog_entry(&mut entry, entry_id);
+ self.repository
+ .save_catalog_entry(entry_id.to_string(), entry.clone())?;
+ Ok(entry.into_value())
+ }
+
+ fn normalize_catalog_entry(entry: &mut TaskRecord, entry_id: &str) {
+ let fields = &mut entry.fields;
+ fields
+ .entry("accepted".to_string())
+ .or_insert(Value::Bool(false));
+ fields
+ .entry("requesterUid".to_string())
+ .or_insert(Value::String(String::new()));
+ fields
+ .entry("orgID".to_string())
+ .or_insert(Value::String("default".to_string()));
+ fields
+ .entry("taskId".to_string())
+ .or_insert(Value::String(entry_id.to_string()));
+ fields
+ .entry("taskID".to_string())
+ .or_insert(Value::String(entry_id.to_string()));
+ }
+
+ fn validate_entry_id(entry_id: String) -> Result {
+ if entry_id.trim().is_empty() {
+ return Err("Task ID is required.".to_string());
+ }
+
+ Ok(entry_id)
+ }
+
+ fn validate_status(status: String) -> Result {
+ if status.trim().is_empty() {
+ return Err("Task status is required.".to_string());
+ }
+
+ Ok(status)
+ }
+
+ fn parse_record(json_data: &str) -> Result {
+ serde_json::from_str::(json_data)
+ .map_err(|error| format!("Invalid task JSON: {error}"))
+ }
+
+ fn parse_ownership_context(json_data: &str) -> Result {
+ serde_json::from_str::(json_data)
+ .map_err(|error| format!("Invalid task ownership JSON: {error}"))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::TaskStateService;
+ use forge_repositories::{InMemoryTaskRepository, TaskRepository};
+ use serde_json::Value;
+
+ #[test]
+ fn bind_ownership_updates_catalog_entry() {
+ let repository = InMemoryTaskRepository::new();
+ let service = TaskStateService::new(repository.clone());
+
+ service
+ .upsert_catalog_entry("task-1".to_string(), r#"{"title":"Attack"}"#.to_string())
+ .expect("catalog upsert should succeed");
+
+ let result = service
+ .bind_ownership(
+ "task-1".to_string(),
+ r#"{"requesterUid":"uid-1","orgId":"org-1"}"#.to_string(),
+ )
+ .expect("bind should succeed");
+
+ assert_eq!(result.requester_uid, "uid-1");
+ assert_eq!(result.org_id, "org-1");
+ assert_eq!(
+ result.entry.get("accepted").and_then(Value::as_bool),
+ Some(true)
+ );
+
+ let stored = repository
+ .get_catalog_entry("task-1")
+ .expect("catalog lookup should succeed")
+ .expect("catalog entry should exist");
+ assert_eq!(
+ stored.fields.get("requesterUid").and_then(Value::as_str),
+ Some("uid-1")
+ );
+ }
+
+ #[test]
+ fn get_status_falls_back_to_completed_status() {
+ let repository = InMemoryTaskRepository::new();
+ let service = TaskStateService::new(repository.clone());
+
+ service
+ .set_status("task-1".to_string(), "failed".to_string())
+ .expect("status update should succeed");
+ repository
+ .delete_active_status("task-1")
+ .expect("active status delete should succeed");
+
+ assert_eq!(
+ service
+ .get_status("task-1".to_string())
+ .expect("status lookup should succeed"),
+ "failed"
+ );
+ }
+
+ #[test]
+ fn list_active_catalog_only_returns_active_entries() {
+ let service = TaskStateService::new(InMemoryTaskRepository::new());
+
+ service
+ .upsert_catalog_entry(
+ "task-active".to_string(),
+ r#"{"title":"Active"}"#.to_string(),
+ )
+ .expect("active catalog upsert should succeed");
+ service
+ .upsert_catalog_entry("task-done".to_string(), r#"{"title":"Done"}"#.to_string())
+ .expect("done catalog upsert should succeed");
+ service
+ .set_status("task-active".to_string(), "active".to_string())
+ .expect("active status update should succeed");
+ service
+ .set_status("task-done".to_string(), "succeeded".to_string())
+ .expect("done status update should succeed");
+
+ let active_catalog = service
+ .list_active_catalog()
+ .expect("active catalog should build");
+
+ assert_eq!(active_catalog.len(), 1);
+ assert_eq!(
+ active_catalog[0].get("taskId").and_then(Value::as_str),
+ Some("task-active")
+ );
+ }
+}