Implement org credit line debt and bank repayment flow #2

Merged
J.Schmidt92 merged 14 commits from development into master 2026-04-02 16:50:38 -05:00
21 changed files with 1102 additions and 186 deletions
Showing only changes of commit b8dd3ef651 - Show all commits

View File

@ -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 = "";

View File

@ -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 {

File diff suppressed because one or more lines are too long

View File

@ -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}.`;
},
};

View File

@ -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();

View File

@ -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();

View File

@ -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
]];

View File

@ -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]

View File

@ -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)];
};
};
};

View File

@ -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);
};
};
};

View File

@ -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(

View File

@ -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<TaskStateService<InMemoryTaskRepository>> =
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<T: Serialize>(result: Result<T, String>) -> 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}"),
}
}

View File

@ -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"]

View File

@ -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,

View File

@ -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};

57
lib/models/src/task.rs Normal file
View File

@ -0,0 +1,57 @@
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
pub type TaskJsonMap = Map<String, Value>;
#[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,
}

View File

@ -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,
};

View File

@ -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<HashMap<String, TaskRecord>, String>;
fn get_catalog_entry(&self, id: &str) -> Result<Option<TaskRecord>, 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<Option<TaskOwnershipContext>, 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<HashMap<String, String>, String>;
fn get_active_status(&self, id: &str) -> Result<Option<String>, 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<Option<String>, 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<u64, String>;
fn get_defuse_count(&self, id: &str) -> Result<u64, String>;
fn clear_defuse_count(&self, id: &str) -> Result<(), String>;
}
#[derive(Debug, Default)]
struct TaskState {
catalog: HashMap<String, TaskRecord>,
ownership: HashMap<String, TaskOwnershipContext>,
active_statuses: HashMap<String, String>,
completed_statuses: HashMap<String, String>,
defuse_counts: HashMap<String, u64>,
}
#[derive(Clone, Debug, Default)]
pub struct InMemoryTaskRepository {
state: Arc<RwLock<TaskState>>,
}
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<HashMap<String, TaskRecord>, 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<Option<TaskRecord>, 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<Option<TaskOwnershipContext>, 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<HashMap<String, String>, 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<Option<String>, 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<Option<String>, 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<u64, String> {
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<u64, String> {
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(())
}
}

View File

@ -253,6 +253,26 @@ impl<R: CadRepository> CadStateService<R> {
"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<R: CadRepository> CadStateService<R> {
.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();

View File

@ -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};

379
lib/services/src/task.rs Normal file
View File

@ -0,0 +1,379 @@
use forge_models::{
TaskOwnershipContext, TaskOwnershipMutationResult, TaskRecord, TaskRewardContext,
};
use forge_repositories::TaskRepository;
use serde_json::Value;
pub struct TaskStateService<R: TaskRepository> {
repository: R,
}
impl<R: TaskRepository> TaskStateService<R> {
pub fn new(repository: R) -> Self {
Self { repository }
}
pub fn reset(&self) -> Result<bool, String> {
self.repository.reset()?;
Ok(true)
}
pub fn upsert_catalog_entry(
&self,
entry_id: String,
json_data: String,
) -> Result<TaskRecord, String> {
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<Option<Value>, 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<Vec<Value>, 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<TaskOwnershipMutationResult, String> {
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<TaskOwnershipMutationResult, String> {
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<TaskOwnershipMutationResult, String> {
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<bool, String> {
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<String, String> {
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<bool, String> {
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<TaskRewardContext, String> {
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<u64, String> {
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<u64, String> {
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<bool, String> {
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<Value, String> {
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<String, String> {
if entry_id.trim().is_empty() {
return Err("Task ID is required.".to_string());
}
Ok(entry_id)
}
fn validate_status(status: String) -> Result<String, String> {
if status.trim().is_empty() {
return Err("Task status is required.".to_string());
}
Ok(status)
}
fn parse_record(json_data: &str) -> Result<TaskRecord, String> {
serde_json::from_str::<TaskRecord>(json_data)
.map_err(|error| format!("Invalid task JSON: {error}"))
}
fn parse_ownership_context(json_data: &str) -> Result<TaskOwnershipContext, String> {
serde_json::from_str::<TaskOwnershipContext>(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")
);
}
}