1<?php 2// (c) Copyright by authors of the Tiki Wiki CMS Groupware Project 3// 4// All Rights Reserved. See copyright.txt for details and a complete list of authors. 5// Licensed under the GNU LESSER GENERAL PUBLIC LICENSE. See license.txt for details. 6// $Id$ 7 8class GoalLib 9{ 10 static $runner; 11 12 function listGoals() 13 { 14 $table = $this->table(); 15 16 $list = $table->fetchAll(['goalId', 'enabled', 'name', 'description', 'type', 'eligible'], [], -1, -1, [ 17 'name' => 'ASC', 18 ]); 19 20 return array_map(function ($goal) { 21 $goal['eligible'] = json_decode($goal['eligible'], true); 22 return $goal; 23 }, $list); 24 } 25 26 function listConditions() 27 { 28 $table = $this->table(); 29 $table->useExceptions(); 30 31 $list = $table->fetchAll(['goalId', 'conditions'], [], -1, -1, [ 32 ]); 33 34 return array_map(function ($goal) { 35 $goal['conditions'] = json_decode($goal['conditions'], true); 36 return $goal; 37 }, $list); 38 } 39 40 function removeGoal($goalId) 41 { 42 $this->table()->delete(['goalId' => $goalId]); 43 44 TikiLib::lib('goalevent')->touch(); 45 } 46 47 function preserveGoals(array $ids) 48 { 49 $table = $this->table(); 50 return $table->deleteMultiple( 51 [ 52 'goalId' => $table->notIn($ids), 53 ] 54 ); 55 } 56 57 function replaceGoal($goalId, array $data) 58 { 59 $base = null; 60 61 if ($goalId) { 62 $base = $this->fetchGoal($goalId); 63 } 64 65 if (! $base) { 66 $base = [ 67 'name' => 'No name', 68 'description' => '', 69 'type' => 'user', 70 'enabled' => 0, 71 'daySpan' => 14, 72 'from' => null, 73 'to' => null, 74 'eligible' => [], 75 'conditions' => [ 76 [ 77 'label' => tr('Goal achieved'), 78 'operator' => 'atMost', 79 'count' => 0, 80 'metric' => 'goal-count-unbounded', 81 'hidden' => 1, 82 ], 83 ], 84 'rewards' => [], 85 ]; 86 } 87 88 $data = array_merge($base, $data); 89 90 $data['eligible'] = json_encode((array) $data['eligible']); 91 $data['conditions'] = json_encode((array) $data['conditions']); 92 $data['rewards'] = json_encode((array) $data['rewards']); 93 94 if ($goalId) { 95 $this->table()->update($data, ['goalId' => $goalId]); 96 } else { 97 $goalId = $this->table()->insert($data); 98 } 99 100 TikiLib::lib('goalevent')->touch(); 101 102 return $goalId; 103 } 104 105 function fetchGoal($goalId) 106 { 107 $goal = $this->table()->fetchFullRow(['goalId' => $goalId]); 108 109 if ($goal) { 110 $goal['eligible'] = json_decode($goal['eligible'], true) ?: []; 111 $goal['conditions'] = json_decode($goal['conditions'], true) ?: []; 112 $goal['rewards'] = json_decode($goal['rewards'], true) ?: []; 113 114 return $goal; 115 } 116 } 117 118 function isEligible(array $goal, array $context) 119 { 120 if ($goal['type'] == 'user') { 121 return count(array_intersect($context['groups'], $goal['eligible'])) > 0; 122 } elseif ($context['group']) { 123 return in_array($context['group'], $goal['eligible']); 124 } else { 125 return false; 126 } 127 } 128 129 function evaluateConditions(array $goal, array $context) 130 { 131 $this->prepareConditions($goal); 132 $runner = $this->getRunner(); 133 134 $goal['complete'] = true; 135 136 foreach ($goal['conditions'] as & $cond) { 137 $arguments = []; 138 foreach (['eventType', 'trackerItemBadge'] as $arg) { 139 if (isset($cond[$arg])) { 140 $arguments[$arg] = $cond[$arg]; 141 } 142 } 143 144 $runner->setFormula($cond['metric']); 145 $runner->setVariables(array_merge($goal, $context, $arguments)); 146 $cond['metric'] = $runner->evaluate(); 147 148 if ($cond['operator'] == 'atLeast') { 149 $cond['complete'] = $cond['metric'] >= $cond['count']; 150 $cond['metric'] = min($cond['count'], $cond['metric']); 151 } else { 152 $cond['complete'] = $cond['metric'] <= $cond['count']; 153 } 154 155 $goal['complete'] = $goal['complete'] && $cond['complete']; 156 } 157 158 if ($goal['complete']) { 159 $tx = TikiDb::get()->begin(); 160 161 TikiLib::events()->trigger('tiki.goal.reached', [ 162 'type' => 'goal', 163 'object' => $goal['goalId'], 164 'name' => $goal['name'], 165 'goalType' => $goal['type'], 166 'user' => $context['user'], 167 'group' => $context['group'], 168 ]); 169 170 $rewardlib = TikiLib::lib('goalreward'); 171 if ($goal['type'] == 'group') { 172 $rewardlib->giveRewardsToMembers($context['group'], $goal['rewards']); 173 } else { 174 $rewardlib->giveRewardsToUser($context['user'], $goal['rewards']); 175 } 176 177 $tx->commit(); 178 } 179 180 return $goal; 181 } 182 183 function unevaluateConditions($goal) 184 { 185 $goal['complete'] = false; 186 187 foreach ($goal['conditions'] as & $cond) { 188 $cond['metric'] = 0; 189 $cond['complete'] = false; 190 } 191 192 return $goal; 193 } 194 195 function evaluateAllGoals() 196 { 197 $tx = TikiDb::get()->begin(); 198 199 foreach ($this->listGoals() as $goal) { 200 if ($goal['enabled']) { 201 $this->prepareConditions($goal); 202 203 foreach ($this->enumerateContexts($goal) as $context) { 204 $this->evaluateConditions($goal, $context); 205 } 206 } 207 } 208 209 $tx->commit(); 210 } 211 212 private function prepareConditions(array & $goal) 213 { 214 if (isset($goal['prepared'])) { 215 return; 216 } 217 218 // listGoals does not extract all information, so when conditions are missing, reload 219 if (! isset($goal['conditions'])) { 220 $goal = $this->fetchGoal($goal['goalId']); 221 } 222 223 $runner = $this->getRunner(); 224 225 foreach ($goal['conditions'] as & $cond) { 226 $metric = $this->prepareMetric($cond['metric'], $goal); 227 $cond['metric'] = $runner->setFormula($metric); 228 } 229 230 $goal['prepared'] = true; 231 } 232 233 private function enumerateContexts($goal) 234 { 235 if ($goal['type'] == 'group') { 236 foreach ($goal['eligible'] as $groupName) { 237 yield ['user' => null, 'group' => $groupName]; 238 } 239 } else { 240 $userlib = TikiLib::lib('user'); 241 242 $done = []; 243 244 foreach ($goal['eligible'] as $groupName) { 245 foreach ($userlib->get_group_users($groupName) as $user) { 246 if (! isset($done[$user])) { 247 yield ['user' => $user, 'group' => null]; 248 $done[$user] = true; 249 } 250 } 251 } 252 } 253 } 254 255 public static function getRunner() 256 { 257 if (! self::$runner) { 258 self::$runner = new Math_Formula_Runner( 259 [ 260 'Math_Formula_Function_' => '', 261 'Tiki_Formula_Function_' => '', 262 ] 263 ); 264 } 265 266 return self::$runner; 267 } 268 269 private function prepareMetric($metric, $goal) 270 { 271 switch ($metric) { 272 case 'event-count': 273 $metric = '(result-count 274 (filter-date) 275 (filter-target) 276 (filter (content eventType) (field "event_type")) 277 (filter (type "goalevent")) 278 )'; 279 break; 280 case 'event-count-unbounded': 281 $metric = '(result-count 282 (filter-target) 283 (filter (content eventType) (field "event_type")) 284 (filter (type "goalevent")) 285 )'; 286 break; 287 case 'goal-count': 288 $metric = '(result-count 289 (filter-date) 290 (filter-target) 291 (filter (content "tiki.goal.reached") (field "event_type")) 292 (filter (type "goalevent")) 293 (filter (content (concat "goal:" goalId)) (field "target")) 294 )'; 295 break; 296 case 'goal-count-unbounded': 297 $metric = '(result-count 298 (filter-target) 299 (filter (content "tiki.goal.reached") (field "event_type")) 300 (filter (type "goalevent")) 301 (filter (content (concat "goal:" goalId)) (field "target")) 302 )'; 303 break; 304 case 'has-badge': 305 $metric = '(relation-present 306 (qualifier "tiki.badge.received") 307 (from type (if (equals type "user") user group)) 308 (to "trackeritem" trackerItemBadge) 309 )'; 310 break; 311 } 312 313 if ($goal['daySpan']) { 314 $metric = str_replace('(filter-date)', '(filter (range "modification_date") (from (concat daySpan " days ago")) (to "now"))', $metric); 315 } else { 316 $metric = str_replace('(filter-date)', '(filter (range "modification_date") (from from) (to to))', $metric); 317 } 318 319 if ($goal['type'] == 'user') { 320 $metric = str_replace('(filter-target)', '(filter (content user) (field "user"))', $metric); 321 } else { 322 $metric = str_replace('(filter-target)', '(filter (multivalue group) (field "goal_groups"))', $metric); 323 } 324 325 return $metric; 326 } 327 328 function getMetricList() 329 { 330 return [ 331 'event-count' => ['label' => tr('Event Count'), 'arguments' => ['eventType']], 332 'event-count-unbounded' => ['label' => tr('Event Count (Forever)'), 'arguments' => ['eventType']], 333 'goal-count' => ['label' => tr('Goal Reached (Periodic)'), 'arguments' => []], 334 'goal-count-unbounded' => ['label' => tr('Goal Reached (Forever)'), 'arguments' => []], 335 'has-badge' => ['label' => tr('Has Badge'), 'arguments' => ['trackerItemBadge']], 336 ]; 337 } 338 339 function listEligibleGroups() 340 { 341 global $prefs; 342 $groups = TikiLib::lib('user')->list_all_groups(); 343 return array_diff($groups, $prefs['goal_group_blacklist']); 344 } 345 346 private function table() 347 { 348 return TikiDb::get()->table('tiki_goals'); 349 } 350} 351