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