Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
101 / 101
100.00% covered (success)
100.00%
12 / 12
CRAP
100.00% covered (success)
100.00%
1 / 1
workflow_store
100.00% covered (success)
100.00%
101 / 101
100.00% covered (success)
100.00%
12 / 12
50
100.00% covered (success)
100.00%
1 / 1
 save
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
6
 get
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 delete
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 workflows_for_event
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 scheduled
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 check_run_as
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
8
 forget_event_map
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 trigger_settings
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 errors
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
11
 missing_params
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
7
 event_name
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 is_event
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2// This file is part of Moodle - https://moodle.org/
3//
4// Moodle is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// Moodle is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12// GNU General Public License for more details.
13//
14// You should have received a copy of the GNU General Public License
15// along with Moodle.  If not, see <https://www.gnu.org/licenses/>.
16
17namespace tool_autoflow\local;
18
19use tool_autoflow\local\engine\cron_expression;
20use tool_autoflow\local\engine\graph;
21use tool_autoflow\local\engine\invalid_graph;
22use tool_autoflow\local\engine\node_type;
23
24/**
25 * Saves and loads workflows.
26 *
27 * The trigger type and its setting (event or cron) are copied out of the graph
28 * on save, so the event observer and the schedule task never parse graphs.
29 *
30 * A workflow runs as the user who saved it last, unless the person saving picks
31 * someone else. Picking another user needs moodle/user:loginas, the permission
32 * Moodle already uses for acting as someone, and picking a site administrator
33 * needs a site administrator. So a manager who edits an admin's workflow brings it
34 * down to their own permissions, and can never raise a workflow above them.
35 *
36 * @package    tool_autoflow
37 * @copyright  2026 DevLMS <contact@devlms.com>
38 * @license    https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
39 */
40final class workflow_store {
41    /** Cache key holding event class name to workflow ids. */
42    private const EVENTMAP = 'map';
43
44    /**
45     * Save a workflow. An invalid graph is kept as a disabled draft.
46     *
47     * @param int $id 0 for a new workflow.
48     * @param string $name
49     * @param string $graphjson
50     * @param bool $enabled What the user asked for. Ignored if the graph has errors.
51     * @param int $runasuserid User the workflow runs as; 0 for the person saving.
52     * @return array id, enabled, runasuserid and errors (each with code and node ids).
53     */
54    public static function save(int $id, string $name, string $graphjson, bool $enabled, int $runasuserid = 0): array {
55        global $DB, $USER;
56        $name = trim($name);
57        if ($name === '') {
58            throw new \invalid_parameter_exception('A workflow needs a name.');
59        }
60        $runasuserid = self::check_run_as($runasuserid ?: (int) $USER->id);
61        try {
62            $graph = graph::from_json($graphjson);
63        } catch (invalid_graph $e) {
64            throw new \invalid_parameter_exception($e->getMessage());
65        }
66        [$triggertype, $triggerdata] = self::trigger_settings($graph);
67        $errors = self::errors($graph, $triggertype, $triggerdata);
68        $enabled = $enabled && !$errors;
69
70        $record = (object) [
71            'name' => $name,
72            'graph' => $graphjson,
73            'enabled' => (int) $enabled,
74            'triggertype' => $triggertype,
75            'triggerdata' => $triggerdata,
76            'usermodified' => $USER->id,
77            'runasuserid' => $runasuserid,
78            'timemodified' => time(),
79        ];
80        if ($id) {
81            $record->id = $DB->get_field('tool_autoflow_workflow', 'id', ['id' => $id], MUST_EXIST);
82            $DB->update_record('tool_autoflow_workflow', $record);
83        } else {
84            $record->timecreated = $record->timemodified;
85            $id = (int) $DB->insert_record('tool_autoflow_workflow', $record);
86        }
87        self::forget_event_map();
88        return ['id' => (int) $id, 'enabled' => $enabled, 'runasuserid' => $runasuserid, 'errors' => $errors];
89    }
90
91    /**
92     * Load a workflow.
93     *
94     * @param int $id
95     * @return \stdClass
96     */
97    public static function get(int $id): \stdClass {
98        global $DB;
99        return $DB->get_record('tool_autoflow_workflow', ['id' => $id], '*', MUST_EXIST);
100    }
101
102    /**
103     * Delete a workflow and its run history.
104     *
105     * @param int $id
106     */
107    public static function delete(int $id): void {
108        global $DB;
109        $DB->delete_records('tool_autoflow_run', ['workflowid' => $id]);
110        $DB->delete_records('tool_autoflow_workflow', ['id' => $id]);
111        self::forget_event_map();
112    }
113
114    /**
115     * Ids of enabled workflows started by an event. Cached; this runs on every event.
116     *
117     * @param string $eventname Event class name with a leading backslash.
118     * @return int[]
119     */
120    public static function workflows_for_event(string $eventname): array {
121        global $DB;
122        $cache = \cache::make('tool_autoflow', 'eventmap');
123        $map = $cache->get(self::EVENTMAP);
124        if ($map === false) {
125            $map = [];
126            $rows = $DB->get_records(
127                'tool_autoflow_workflow',
128                ['enabled' => 1, 'triggertype' => 'trigger.event'],
129                'id',
130                'id, triggerdata'
131            );
132            foreach ($rows as $row) {
133                $map[$row->triggerdata][] = (int) $row->id;
134            }
135            $cache->set(self::EVENTMAP, $map);
136        }
137        return $map[$eventname] ?? [];
138    }
139
140    /**
141     * Enabled workflows started by a schedule.
142     *
143     * @return \stdClass[] Keyed by id, with id, triggerdata and lastscheduled.
144     */
145    public static function scheduled(): array {
146        global $DB;
147        return $DB->get_records(
148            'tool_autoflow_workflow',
149            ['enabled' => 1, 'triggertype' => 'trigger.schedule'],
150            'id',
151            'id, triggerdata, lastscheduled'
152        );
153    }
154
155    /**
156     * Whether the current user may make a workflow run as this user.
157     *
158     * @param int $userid
159     * @return int The user id.
160     */
161    private static function check_run_as(int $userid): int {
162        global $USER;
163        $user = \core_user::get_user($userid);
164        if (!$user || $user->deleted || $user->suspended || isguestuser($user)) {
165            throw new \invalid_parameter_exception("No active user with id $userid.");
166        }
167        if ((int) $user->id === (int) $USER->id) {
168            return $userid;
169        }
170        require_capability('moodle/user:loginas', \context_system::instance());
171        if (is_siteadmin($user) && !is_siteadmin($USER)) {
172            throw new \invalid_parameter_exception('Only a site administrator can make a workflow run as a site administrator.');
173        }
174        return $userid;
175    }
176
177    /**
178     * Drop the cached event map after any change.
179     */
180    private static function forget_event_map(): void {
181        \cache::make('tool_autoflow', 'eventmap')->delete(self::EVENTMAP);
182    }
183
184    /**
185     * The trigger type and its setting, if the graph has exactly one trigger.
186     *
187     * @param graph $graph
188     * @return string[] Type and setting, empty strings when there is no single trigger.
189     */
190    private static function trigger_settings(graph $graph): array {
191        try {
192            $trigger = $graph->trigger();
193        } catch (invalid_graph $e) {
194            return ['', ''];
195        }
196        $params = $trigger['data']['params'] ?? [];
197        $setting = match ($trigger['type']) {
198            'trigger.schedule' => trim((string) ($params['cron'] ?? '')),
199            'trigger.event' => self::event_name((string) ($params['eventname'] ?? '')),
200            default => '',
201        };
202        return [$trigger['type'], $setting];
203    }
204
205    /**
206     * Everything that stops the workflow from running.
207     *
208     * @param graph $graph
209     * @param string $triggertype
210     * @param string $triggerdata
211     * @return array[]
212     */
213    private static function errors(graph $graph, string $triggertype, string $triggerdata): array {
214        $errors = $graph->errors();
215        $registry = node_catalogue::registry();
216        $unknown = [];
217        $missing = [];
218        foreach ($graph->nodes() as $node) {
219            if (!$registry->has($node['type'])) {
220                $unknown[] = $node['id'];
221            } else if (!graph::is_trigger($node['type']) && self::missing_params($registry->get($node['type']), $node)) {
222                $missing[] = $node['id'];
223            }
224        }
225        if ($unknown) {
226            $errors[] = ['code' => 'unknowntype', 'nodeids' => $unknown];
227        }
228        if ($missing) {
229            $errors[] = ['code' => 'missingparam', 'nodeids' => $missing];
230        }
231        if ($triggertype === 'trigger.schedule' && !cron_expression::is_valid($triggerdata)) {
232            $errors[] = ['code' => 'badcron', 'nodeids' => [$graph->trigger()['id']]];
233        }
234        if ($triggertype === 'trigger.event' && !self::is_event($triggerdata)) {
235            $errors[] = ['code' => 'badevent', 'nodeids' => [$graph->trigger()['id']]];
236        }
237        return $errors;
238    }
239
240    /**
241     * Whether a node leaves a required setting blank. Triggers have their own checks.
242     *
243     * @param node_type $type
244     * @param array $node
245     * @return bool
246     */
247    private static function missing_params(node_type $type, array $node): bool {
248        $values = $node['data']['params'] ?? [];
249        foreach ($type->params() as $param) {
250            $value = $values[$param->name] ?? null;
251            if ($param->required && ($value === null || (is_string($value) && trim($value) === '') || $value === [])) {
252                return true;
253            }
254        }
255        return false;
256    }
257
258    /**
259     * An event class name in the form Moodle uses for $event->eventname.
260     *
261     * @param string $name
262     * @return string
263     */
264    private static function event_name(string $name): string {
265        $name = trim($name);
266        return $name === '' ? '' : '\\' . ltrim($name, '\\');
267    }
268
269    /**
270     * Whether a class is a concrete Moodle event.
271     *
272     * @param string $classname
273     * @return bool
274     */
275    private static function is_event(string $classname): bool {
276        if ($classname === '' || !class_exists($classname)) {
277            return false;
278        }
279        $class = new \ReflectionClass($classname);
280        return $class->isSubclassOf(\core\event\base::class) && !$class->isAbstract();
281    }
282}