From b8dd3ef651892bdedafa64fe61d8b6d2b0eb378c Mon Sep 17 00:00:00 2001 From: Jacob Schmidt Date: Thu, 2 Apr 2026 15:35:39 -0500 Subject: [PATCH] Add task and request payload plumbing to CAD dispatcher - Thread request data through UI bridge and dispatcher events - Add task models, repositories, services, and extension wiring - Include submitted request fields in converted order notes --- .../cad/functions/fnc_handleUIEvents.sqf | 4 +- .../addons/cad/functions/fnc_initUIBridge.sqf | 5 +- .../addons/cad/ui/_site/cad-dispatcher.js | 2 +- .../cad/ui/src/dispatcher/formatters.js | 21 +- .../addons/cad/ui/src/dispatcher/index.js | 19 + .../addons/cad/ui/src/dispatcher/modals.js | 3 +- arma/server/addons/cad/XEH_preInit.sqf | 5 +- .../fnc_initAssignmentRepository.sqf | 8 +- .../task/functions/fnc_handleTaskRewards.sqf | 23 +- .../task/functions/fnc_initTaskStore.sqf | 332 +++++++-------- arma/server/extension/src/lib.rs | 2 + arma/server/extension/src/task.rs | 123 ++++++ lib/models/Cargo.toml | 3 +- lib/models/src/cad.rs | 10 + lib/models/src/lib.rs | 4 + lib/models/src/task.rs | 57 +++ lib/repositories/src/lib.rs | 2 + lib/repositories/src/task.rs | 204 ++++++++++ lib/services/src/cad.rs | 80 +++- lib/services/src/lib.rs | 2 + lib/services/src/task.rs | 379 ++++++++++++++++++ 21 files changed, 1102 insertions(+), 186 deletions(-) create mode 100644 arma/server/extension/src/task.rs create mode 100644 lib/models/src/task.rs create mode 100644 lib/repositories/src/task.rs create mode 100644 lib/services/src/task.rs 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``}).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(""):'

No submitted fields.

';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=>``).join(""),document.getElementById("dispatcherModalStatusSelect").innerHTML=this.statuses.map(e=>``).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='

No open contracts.

');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.title||t}\n ${this.formatTypeLabel(e)}\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 \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.title||t}\n ${e.assignmentState||"assigned"}\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='

No assigned contracts.

'},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 ${e.callsign||e.groupId}\n ${e.role||"group"}\n ${t?'Danger':""}\n
\n
\n ${this.buildGroupEditorButton(e.groupId)}\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.title||e.requestId||"Support Request"}\n ${(e.priority||"priority").replaceAll("_"," ")}\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.type||"activity"}\n ${Math.round(e.timestamp||0)}s\n
\n

${e.message||""}

\n
\n `).join(""):'

No recent activity.

';e.innerHTML=`\n
\n
Support Requests
\n ${t}\n
\n
\n
Recent Activity
\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``}).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(""):'

No submitted fields.

';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=>``).join(""),document.getElementById("dispatcherModalStatusSelect").innerHTML=this.statuses.map(e=>``).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='

No open contracts.

');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.title||t}\n ${this.formatTypeLabel(e)}\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 \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.title||t}\n ${e.assignmentState||"assigned"}\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='

No assigned contracts.

'},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 ${e.callsign||e.groupId}\n ${e.role||"group"}\n ${t?'Danger':""}\n
\n
\n ${this.buildGroupEditorButton(e.groupId)}\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.title||e.requestId||"Support Request"}\n ${(e.priority||"priority").replaceAll("_"," ")}\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.type||"activity"}\n ${Math.round(e.timestamp||0)}s\n
\n

${e.message||""}

\n
\n `).join(""):'

No recent activity.

';e.innerHTML=`\n
\n
Support Requests
\n ${t}\n
\n
\n
Recent Activity
\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") + ); + } +}