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