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 8// This is for users to earn points in the community 9// It's been implemented before and now it's being coded in v1.9. 10// This code is provided here for you to check this implementation 11// and make comments, please see 12// http://tiki.org/tiki-index.php?page=ScoringSystemIdea 13 14//this script may only be included - so its better to die if called directly. 15if (strpos($_SERVER["SCRIPT_NAME"], basename(__FILE__)) !== false) { 16 header("location: index.php"); 17 exit; 18} 19 20/** 21 * 22 */ 23class ScoreLib extends TikiLib 24{ 25 const CACHE_KEY = 'score_events'; 26 27 function touch() 28 { 29 TikiLib::lib('cache')->invalidate(self::CACHE_KEY); 30 } 31 // User's general classification on site 32 /** 33 * @param $user 34 * @return mixed 35 */ 36 public function user_position($user) 37 { 38 global $prefs; 39 $score_expiry_days = $prefs['feature_score_expday']; 40 41 $score = $this->get_user_score($user); 42 43 if (empty($score_expiry_days)) { 44 // score does not expire 45 $query = "select count(*)+1 from `tiki_object_scores` tos 46 where `recipientObjectType`='user' 47 and `recipientObjectId`<> ? 48 and `pointsBalance` > ? 49 and tos.`id` = (select max(id) from `tiki_object_scores` where `recipientObjectId` = tos.`recipientObjectId` and `recipientObjectType`='user' group by `recipientObjectId`) 50 group by `recipientObjectId`"; 51 52 $position = $this->getOne($query, [$user, $score]); 53 } else { 54 // score expires 55 $query = "select count(*)+1 from `tiki_object_scores` tos 56 where `recipientObjectType`='user' 57 and `recipientObjectId`<> ? 58 and `pointsBalance` - ifnull((select `pointsBalance` from `tiki_object_scores` 59 where `recipientObjectId`=tos.`recipientObjectId` 60 and `recipientObjectType`='user' 61 and `date` < UNIX_TIMESTAMP(DATE_SUB(NOW(), INTERVAL ? DAY)) 62 order by id desc limit 1), 0) > ? 63 and tos.`id` = (select max(id) from `tiki_object_scores` where `recipientObjectId` = tos.`recipientObjectId` and `recipientObjectType`='user' group by `recipientObjectId`) 64 group by `recipientObjectId`"; 65 66 $position = $this->getOne($query, [$user, $score_expiry_days, $score]); 67 } 68 69 return $position; 70 } 71 72 // User's score on site 73 // allows getting score of a single user 74 /** 75 * @param $user 76 * @return mixed 77 */ 78 public function get_user_score($user, $dayLimit = 0) 79 { 80 global $prefs; 81 $score_expiry_days = $prefs['feature_score_expday']; 82 if (! empty($dayLimit) && $dayLimit < $score_expiry_days) { 83 //if the day limit is set, change the expiry to the day limit. 84 $score_expiry_days = $dayLimit; 85 } 86 $query = "select `pointsBalance` from `tiki_object_scores` where `recipientObjectId`=? and `recipientObjectType`='user' order by id desc"; 87 $total_score = $this->getOne($query, [$user]); 88 if (empty($total_score)) { 89 $total_score = 0; 90 } 91 //if points don't expire, return total score; otherwise 92 if (empty($score_expiry_days)) { 93 return $total_score; 94 } else { 95 $query = "select `pointsBalance` from `tiki_object_scores` 96 where `recipientObjectId`=? and `recipientObjectType`='user' and 97 `date` < UNIX_TIMESTAMP(DATE_SUB(NOW(), INTERVAL ? DAY)) 98 order by id desc"; 99 $score_at_expiry = $this->getOne($query, [$user, $score_expiry_days]); 100 if (empty($score_at_expiry)) { 101 $score_at_expiry = 0; 102 } 103 //subtract the score at expiry from the total score to get valid score 104 $score = $total_score - $score_at_expiry; 105 return $score; 106 } 107 } 108 109 // Number of users that go on ranking 110 /** 111 * @return mixed 112 */ 113 public function count_users() 114 { 115 global $prefs; 116 $score_expiry_days = $prefs['feature_score_expday']; 117 118 if (empty($score_expiry_days)) { 119 // score does not expire 120 $query = "select count(*) from `tiki_object_scores` tos 121 where `recipientObjectType`='user' 122 and `pointsBalance` > 0 123 and tos.`id` = (select max(id) from `tiki_object_scores` where `recipientObjectId` = tos.`recipientObjectId` and `recipientObjectType`='user' group by `recipientObjectId`) 124 group by `recipientObjectId`"; 125 126 $count = $this->getOne($query, []); 127 } else { 128 // score expires 129 $query = "select count(*) from `tiki_object_scores` tos 130 where `recipientObjectType`='user' 131 and `pointsBalance` - ifnull((select `pointsBalance` from `tiki_object_scores` 132 where `recipientObjectId`=tos.`recipientObjectId` 133 and `recipientObjectType`='user' 134 and `date` < UNIX_TIMESTAMP(DATE_SUB(NOW(), INTERVAL ? DAY)) 135 order by id desc limit 1), 0) > 0 136 and tos.`id` = (select max(id) from `tiki_object_scores` where `recipientObjectId` = tos.`recipientObjectId` and `recipientObjectType`='user' group by `recipientObjectId`) 137 group by `recipientObjectId`"; 138 139 $count = $this->getOne($query, [$score_expiry_days]); 140 } 141 142 return $count; 143 } 144 145 // All event types, for administration 146 /** 147 * @return array 148 */ 149 public function get_all_events() 150 { 151 $query = "SELECT * FROM `tiki_score` WHERE data IS NOT NULL"; 152 $result = $this->query($query, []); 153 $event_list = []; 154 while ($res = $result->fetchRow()) { 155 $res['scores'] = json_decode($res['data']); 156 foreach ($res['scores'] as $key => $score) { 157 $res['scores'][$key]->validObjectIds = implode(",", $score->validObjectIds); 158 } 159 160 $event_list[] = $res; 161 } 162 return $event_list; 163 } 164 165 // Read information from admin and updates event's punctuation 166 /** 167 * @param $events 168 */ 169 public function update_events($events) 170 { 171 //clear old scores before re-inserting 172 $query = "delete from `tiki_score`"; 173 $this->query($query); 174 175 foreach ($events as $event_name => $event_data) { 176 $reversalEvent = $event_data['reversalEvent']; 177 unset($event_data['reversalEvent']); 178 179 foreach ($event_data as $key => $rules) { 180 $tempArr = explode(',', $rules['validObjectIds']); 181 $event_data[$key]['validObjectIds'] = array_map('trim', $tempArr); 182 } 183 184 $event_data = json_encode($event_data); 185 186 $query = "insert into `tiki_score` (`event`,`reversalEvent`,`data`) values (?,?,?)"; 187 $this->query($query, [$event_name, $reversalEvent, $event_data]); 188 } 189 $this->touch(); 190 return; 191 } 192 193 /** 194 * Function to get available event types 195 */ 196 function getEventTypes() 197 { 198 $graph = TikiLib::events()->getEventGraph(); 199 sort($graph['nodes']); 200 return $graph['nodes']; 201 } 202 203 /** 204 * Bind events from the scoring system 205 * @param Tiki_Event_Manager $manager 206 */ 207 function bindEvents($manager) 208 { 209 try { 210 $list = $this->getScoreEvents(); 211 $eventsList = $list['events']; 212 $reversalEventsList = $list['reversalEvents']; 213 214 foreach ($reversalEventsList as $eventType) { 215 $manager->bind($eventType, Tiki_Event_Lib::defer('score', 'reversePoints')); 216 } 217 foreach ($eventsList as $eventType) { 218 $manager->bind($eventType, Tiki_Event_Lib::defer('score', 'assignPoints')); 219 } 220 } catch (TikiDb_Exception $e) { 221 // Prevent failures from locking-out users 222 } 223 } 224 225 /** 226 * This is the function called when a bound event is triggered. This stores the scoring transaction to the db 227 * and increases the score 228 * @param array $args 229 * @param string $eventType 230 * @throws Exception 231 */ 232 function assignPoints($args = [], $eventType = "") 233 { 234 $rules = $this->getScoreEventRules($eventType); 235 $date = TikiLib::lib('tiki')->now; 236 //for each rule associated with the event, set up the scor 237 foreach ($rules as $rule) { 238 // if the object is invalid, do nothing. 239 if (! $this->objectIsValid($args, $rule)) { 240 continue; 241 } 242 $recipient = $this->evaluateExpression($rule->recipient, $args, "eval"); 243 $recipientType = $this->evaluateExpression($rule->recipientType, $args); 244 $points = $this->evaluateExpression($rule->score, $args); 245 if (! $recipient || ! $points) { 246 continue; 247 } 248 if ($rule->expiration > 0 && ! $this->hasWaitedMinTime($args, $rule, $recipientType, $recipient)) { 249 continue; 250 } 251 //if user is anonymous, store a unique identifier in a cookie and set it as the user. 252 if (empty($args['user'])) { 253 $uniqueVal = getCookie('anonUserScoreId'); 254 if (empty($uniqueVal)) { 255 $uniqueVal = getenv('HTTP_CLIENT_IP') . time() . rand(); 256 $uniqueVal = md5($uniqueVal); 257 setCookieSection('anonUserScoreId', "anon" . $uniqueVal); 258 } 259 $args['user'] = $uniqueVal; 260 } 261 $pbalance = $this->getPointsBalance($recipientType, $recipient); 262 $data = [ 263 'triggerObjectType' => $args['type'], 264 'triggerObjectId' => $args['object'], 265 'triggerUser' => $args['user'], 266 'triggerEvent' => $eventType, 267 'ruleId' => $rule->ruleId, 268 'recipientObjectType' => $recipientType, 269 'recipientObjectId' => $recipient, 270 'pointsAssigned' => $points, 271 'pointsBalance' => $pbalance + $points, 272 'date' => $date, 273 ]; 274 275 $id = $this->table()->insert($data); 276 } 277 } 278 279 /** 280 * This is the reversal function. If a reversal event is triggered, then check if there is an associated 281 * score and reverse it. 282 * @param $args 283 * @param $eventType 284 * @throws Exception 285 */ 286 function reversePoints($args, $eventType) 287 { 288 $query = "SELECT event FROM `tiki_score` WHERE reversalEvent=?"; 289 //if you find an original event, reverse it. 290 if ($originalEvent = $this->getOne($query, [$eventType])) { 291 //fetch all the scoring entries that were put in the last time 292 $date = $this->table()->fetchOne( 293 'date', 294 ['triggerObjectType' => $args['type'], 295 'triggerObjectId' => $args['object'], 296 'triggerUser' => $args['user'], 297 'triggerEvent' => $originalEvent 298 ], 299 ["id" => "desc"] 300 ); 301 $result = $this->table()->fetchAll( 302 ['id', 'ruleId', 'pointsAssigned', 'recipientObjectType', 'recipientObjectId', 'reversalOf'], 303 ['triggerObjectType' => $args['type'], 304 'triggerObjectId' => $args['object'], 305 'triggerUser' => $args['user'], 306 'triggerEvent' => $originalEvent, 307 'date' => $date, 308 ] 309 ); 310 311 $date = TikiLib::lib('tiki')->now; 312 foreach ($result as $row) { 313 // if the most recent transaction was a reversal, exit as to not reverse again 314 if ($row['reversalOf'] > 0) { 315 continue; 316 } 317 $pbalance = $this->getPointsBalance($row['recipientObjectType'], $row['recipientObjectId']); 318 $data = [ 319 'triggerObjectType' => $args['type'], 320 'triggerObjectId' => $args['object'], 321 'triggerUser' => $args['user'], 322 'triggerEvent' => $eventType, 323 'ruleId' => $row['ruleId'], 324 'recipientObjectType' => $row['recipientObjectType'], 325 'recipientObjectId' => $row['recipientObjectId'], 326 'pointsAssigned' => -$row['pointsAssigned'], 327 'pointsBalance' => $pbalance - $row['pointsAssigned'], 328 'reversalOf' => $row['id'], 329 'date' => $date, 330 ]; 331 $id = $this->table()->insert($data); 332 } 333 } 334 return; 335 } 336 337 function table($tableName = 'tiki_object_scores', $autoIncrement = true) 338 { 339 return TikiDb::get()->table($tableName); 340 } 341 342 /** 343 * This fetches all the events in the score table to bind all of them 344 * @return array 345 * @throws Exception 346 */ 347 private function getScoreEvents() 348 { 349 $cachelib = TikiLib::lib('cache'); 350 if (! $result = $cachelib->getSerialized(self::CACHE_KEY)) { 351 $query = "SELECT * FROM `tiki_score` WHERE data IS NOT NULL"; 352 $result = $this->query($query, []); 353 $event_list = []; 354 $event_reversal_list = []; 355 356 while ($res = $result->fetchRow()) { 357 $event_list[] = $res['event']; 358 if ($res['reversalEvent']) { 359 $event_reversal_list[] = $res['reversalEvent']; 360 } 361 } 362 $result = ['events' => $event_list, 363 'reversalEvents' => $event_reversal_list 364 ]; 365 $cachelib->cacheItem(self::CACHE_KEY, serialize($result)); 366 } 367 368 return $result; 369 } 370 371 /** 372 * This gets all the rules associated with a given event. 373 * @param $eventType 374 * @return mixed 375 */ 376 private function getScoreEventRules($eventType) 377 { 378 $query = "SELECT data FROM `tiki_score` WHERE event=? and data IS NOT NULL"; 379 $result = $this->query($query, [$eventType]); 380 381 $rules = json_decode($result->fetchRow()['data']); 382 383 return $rules; 384 } 385 386 /** 387 * This retrieves the score of a given object. 388 * @param $recipientType 389 * @param $recipient 390 * @return bool|mixed 391 */ 392 function getPointsBalance($recipientType, $recipient) 393 { 394 $query = "SELECT pointsBalance FROM `tiki_object_scores` WHERE recipientObjectType=? and recipientObjectId=? order by id desc"; 395 $result = $this->getOne($query, [$recipientType,$recipient]); 396 397 if (empty($result)) { 398 return 0; 399 } 400 return $result; 401 } 402 403 /** 404 * This retrieves the score of a given object. 405 * @param $recipientType 406 * @param $recipient 407 * @return bool|mixed 408 */ 409 function getGroupedPointsBalance($recipientType, $recipient) 410 { 411 $query = "SELECT ruleId, SUM(pointsAssigned) as 'points' FROM `tiki_object_scores` WHERE recipientObjectType=? and recipientObjectId=? group by ruleId"; 412 $result = $this->fetchAll($query, [$recipientType,$recipient]); 413 414 if (empty($result)) { 415 return 0; 416 } 417 return $result; 418 } 419 420 function getPointsBalanceForRuleId($recipientType, $recipient, $ruleId) 421 { 422 $query = "SELECT SUM(pointsAssigned) FROM `tiki_object_scores` WHERE recipientObjectType=? and recipientObjectId=? and ruleId=? group by ruleId"; 423 $result = $this->getOne($query, [$recipientType,$recipient,$ruleId]); 424 425 if (empty($result)) { 426 return 0; 427 } 428 return $result; 429 } 430 431 /** 432 * This is only called and checked if you are assigning points. It is not done on reversals. 433 * 434 * @param $args 435 * @param $rule 436 * @return bool 437 */ 438 function objectIsValid($args, $rule) 439 { 440 if (empty($rule->validObjectIds) || empty($rule->validObjectIds[0])) { 441 return true; 442 } 443 if (in_array($args['object'], $rule->validObjectIds) || in_array($args['type'] . ":" . $args['object'], $rule->validObjectIds)) { 444 return true; 445 } 446 return false; 447 } 448 449 /** 450 * This is only called and checked if you are assigning points. It is not done on reversals. 451 * 452 * @param $args 453 * @param $rule 454 * @return bool 455 */ 456 function hasWaitedMinTime($args, $rule, $recipientType, $recipient) 457 { 458 $query = "SELECT date FROM `tiki_object_scores` 459 WHERE triggerObjectType=? and triggerObjectId=? and ruleId=? and recipientObjectType=? 460 and recipientObjectId=? and reversalOf is null 461 order by id desc"; 462 $date = $this->getOne($query, [$args['type'],$args['object'], $rule->ruleId, $recipientType, $recipient]); 463 $currentTime = time(); 464 $expiration = $date + $rule->expiration; 465 if ($expiration > $currentTime) { 466 return false; 467 } 468 return true; 469 } 470 471 /** 472 * This is called to evaluate a given expression. 473 * @param $expr 474 * @param $args 475 * @param string $default 476 * @return bool|float|void 477 */ 478 function evaluateExpression($expr, $args, $default = "str") 479 { 480 if (0 !== strpos($expr, "(")) { 481 $expr = "($default $expr)"; 482 } 483 $runner = new Math_Formula_Runner( 484 [ 485 'Math_Formula_Function_' => '', 486 'Tiki_Formula_Function_' => '', 487 ] 488 ); 489 try { 490 $runner->setVariables($args); 491 $runner->setFormula($expr); 492 return $runner->evaluate(); 493 } catch (Math_Formula_Exception $e) { 494 return; 495 } 496 } 497} 498