Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
176 / 176
100.00% covered (success)
100.00%
19 / 19
CRAP
100.00% covered (success)
100.00%
1 / 1
case_manager
100.00% covered (success)
100.00%
176 / 176
100.00% covered (success)
100.00%
19 / 19
46
100.00% covered (success)
100.00%
1 / 1
 open
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
5
 move
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
11
 add_note
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 save_evidence
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 respond
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 appeal
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 get
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 events
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 for_course
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 for_student
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 can_view
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 stage_name
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 student_case
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 record
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 touch
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 log
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 notify_student
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 notify_handler
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 send
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
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 local_integritycase\local;
18
19use context_course;
20use core_user;
21use local_integritycase\event\case_created;
22use local_integritycase\event\case_updated;
23use moodle_exception;
24use moodle_url;
25use stdClass;
26
27/**
28 * Opens cases and moves them through the workflow, keeping an audit trail of every action.
29 *
30 * Every write checks the capability of the current user and records an event row. Pages call these
31 * methods; they never write to the tables themselves.
32 *
33 * @package    local_integritycase
34 * @copyright  2026 DevLMS <contact@devlms.com>
35 * @license    https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
36 */
37final class case_manager {
38    /** @var string Case table. */
39    public const TABLE = 'local_integritycase';
40    /** @var string Audit trail table. */
41    public const EVENTS = 'local_integritycase_event';
42
43    /**
44     * Open a case about a student in a course.
45     *
46     * @param int $courseid
47     * @param int $studentid Must be enrolled in the course.
48     * @param string $summary What was found, plain text.
49     * @param int $cmid Related activity in the course, 0 for none.
50     * @param int|null $handlerid Staff member handling it; the current user when null.
51     * @return stdClass The case.
52     */
53    public static function open(int $courseid, int $studentid, string $summary, int $cmid = 0, ?int $handlerid = null): stdClass {
54        global $DB, $USER;
55        $context = context_course::instance($courseid);
56        require_capability('local/integritycase:manage', $context);
57        if (!is_enrolled($context, $studentid)) {
58            throw new moodle_exception('error:notenrolled', 'local_integritycase');
59        }
60        if ($cmid && !$DB->record_exists('course_modules', ['id' => $cmid, 'course' => $courseid])) {
61            throw new moodle_exception('error:cmnotincourse', 'local_integritycase');
62        }
63        $handlerid = $handlerid ?? (int) $USER->id;
64        if (!has_capability('local/integritycase:manage', $context, $handlerid)) {
65            throw new moodle_exception('error:handlernotstaff', 'local_integritycase');
66        }
67        $now = time();
68        $id = (int) $DB->insert_record(self::TABLE, (object) [
69            'courseid' => $courseid,
70            'studentid' => $studentid,
71            'cmid' => $cmid,
72            'handlerid' => $handlerid,
73            'createdby' => $USER->id,
74            'summary' => $summary,
75            'summaryformat' => FORMAT_PLAIN,
76            'stage' => workflow::REPORTED,
77            'outcome' => null,
78            'studentvisible' => 0,
79            'timecreated' => $now,
80            'timemodified' => $now,
81            'timeclosed' => 0,
82        ]);
83        self::record($id, 'created', null, workflow::REPORTED, null, null, false);
84        case_created::create([
85            'objectid' => $id,
86            'context' => $context,
87            'relateduserid' => $studentid,
88        ])->trigger();
89        return self::get($id);
90    }
91
92    /**
93     * Move a case to another stage. Staff only.
94     *
95     * Reaching "awaiting response" or "decided" makes the case visible to the student, who is then
96     * told about every later move. A move into "decided" needs an outcome; moving back to
97     * "investigating" clears it until a new decision is made.
98     *
99     * @param int $caseid
100     * @param string $to
101     * @param string $note Shown to the student once the case is visible.
102     * @param string|null $outcome Required when $to is "decided".
103     * @return stdClass The updated case.
104     */
105    public static function move(int $caseid, string $to, string $note = '', ?string $outcome = null): stdClass {
106        global $DB;
107        $case = self::get($caseid);
108        $context = context_course::instance($case->courseid);
109        require_capability('local/integritycase:manage', $context);
110        if (!workflow::can_move($case->stage, $to)) {
111            throw new moodle_exception('error:nomove', 'local_integritycase', '', (object) [
112                'from' => self::stage_name($case->stage),
113                'to' => self::stage_name($to),
114            ]);
115        }
116        if ($to === workflow::DECIDED && ($outcome === null || !workflow::is_outcome($outcome))) {
117            throw new moodle_exception('outcomerequired', 'local_integritycase');
118        }
119
120        $from = $case->stage;
121        $case->stage = $to;
122        if ($to === workflow::DECIDED) {
123            $case->outcome = $outcome;
124        } else if ($to === workflow::INVESTIGATING) {
125            $case->outcome = null;
126        }
127        $case->studentvisible = (int) $case->studentvisible || workflow::reveals($to) ? 1 : 0;
128        $case->timeclosed = $to === workflow::CLOSED ? time() : 0;
129        $case->timemodified = time();
130        $DB->update_record(self::TABLE, $case);
131
132        $visible = (bool) $case->studentvisible;
133        self::record($caseid, 'stage', $from, $to, $case->outcome, $note, $visible);
134        self::log($case, $context);
135        if ($visible) {
136            self::notify_student($case, $note);
137        }
138        return self::get($caseid);
139    }
140
141    /**
142     * Add a note only staff can see.
143     *
144     * @param int $caseid
145     * @param string $note
146     */
147    public static function add_note(int $caseid, string $note): void {
148        $case = self::get($caseid);
149        $context = context_course::instance($case->courseid);
150        require_capability('local/integritycase:manage', $context);
151        self::record($caseid, 'note', null, null, null, $note, false);
152        self::log($case, $context);
153    }
154
155    /**
156     * Replace the case's evidence files with those in a draft area. Staff only, logged privately.
157     *
158     * @param int $caseid
159     * @param int $draftitemid
160     */
161    public static function save_evidence(int $caseid, int $draftitemid): void {
162        $case = self::get($caseid);
163        $context = context_course::instance($case->courseid);
164        require_capability('local/integritycase:manage', $context);
165        file_save_draft_area_files(
166            $draftitemid,
167            $context->id,
168            'local_integritycase',
169            'evidence',
170            $caseid,
171            ['subdirs' => 0, 'maxfiles' => 20]
172        );
173        self::record($caseid, 'evidence', null, null, null, null, false);
174        self::log($case, $context);
175    }
176
177    /**
178     * The student gives their side while the case awaits it. The handler is told.
179     *
180     * @param int $caseid
181     * @param string $text
182     */
183    public static function respond(int $caseid, string $text): void {
184        $case = self::student_case($caseid, workflow::AWAITING);
185        self::record($caseid, 'response', null, null, null, $text, true);
186        self::touch($case);
187        self::notify_handler($case, 'response');
188    }
189
190    /**
191     * The student appeals a decision. The case moves to "appealed" and the handler is told.
192     *
193     * @param int $caseid
194     * @param string $reason
195     * @return stdClass The updated case.
196     */
197    public static function appeal(int $caseid, string $reason): stdClass {
198        global $DB;
199        $case = self::student_case($caseid, workflow::DECIDED);
200        $DB->set_field(self::TABLE, 'stage', workflow::APPEALED, ['id' => $caseid]);
201        $case->stage = workflow::APPEALED;
202        self::record($caseid, 'appeal', workflow::DECIDED, workflow::APPEALED, null, $reason, true);
203        self::touch($case);
204        self::notify_handler($case, 'appeal');
205        return self::get($caseid);
206    }
207
208    /**
209     * Load a case.
210     *
211     * @param int $caseid
212     * @return stdClass
213     */
214    public static function get(int $caseid): stdClass {
215        global $DB;
216        return $DB->get_record(self::TABLE, ['id' => $caseid], '*', MUST_EXIST);
217    }
218
219    /**
220     * The audit trail, oldest first.
221     *
222     * @param stdClass $case
223     * @param bool $forstudent Only what the student may see.
224     * @return stdClass[]
225     */
226    public static function events(stdClass $case, bool $forstudent): array {
227        global $DB;
228        $conditions = ['caseid' => $case->id];
229        if ($forstudent) {
230            $conditions['visibletostudent'] = 1;
231        }
232        return array_values($DB->get_records(self::EVENTS, $conditions, 'timecreated ASC, id ASC'));
233    }
234
235    /**
236     * All cases in a course, most recently changed first. Callers check local/integritycase:view.
237     *
238     * @param int $courseid
239     * @return stdClass[] Keyed by id.
240     */
241    public static function for_course(int $courseid): array {
242        global $DB;
243        return $DB->get_records(self::TABLE, ['courseid' => $courseid], 'timemodified DESC, id DESC');
244    }
245
246    /**
247     * The cases a student can see in a course.
248     *
249     * @param int $courseid
250     * @param int $studentid
251     * @return stdClass[] Keyed by id.
252     */
253    public static function for_student(int $courseid, int $studentid): array {
254        global $DB;
255        return $DB->get_records(
256            self::TABLE,
257            ['courseid' => $courseid, 'studentid' => $studentid, 'studentvisible' => 1],
258            'timemodified DESC, id DESC'
259        );
260    }
261
262    /**
263     * Whether the current user may see this case: staff with view, or the student once it is visible.
264     *
265     * @param stdClass $case
266     * @return bool
267     */
268    public static function can_view(stdClass $case): bool {
269        global $USER;
270        $context = context_course::instance($case->courseid);
271        if (has_capability('local/integritycase:view', $context)) {
272            return true;
273        }
274        return (int) $case->studentid === (int) $USER->id && $case->studentvisible
275            && has_capability('local/integritycase:respond', $context);
276    }
277
278    /**
279     * Human name of a stage.
280     *
281     * @param string $stage
282     * @return string
283     */
284    public static function stage_name(string $stage): string {
285        return get_string_manager()->string_exists("stage:$stage", 'local_integritycase')
286            ? get_string("stage:$stage", 'local_integritycase') : $stage;
287    }
288
289    /**
290     * Load the current user's own visible case at the given stage, or fail.
291     *
292     * @param int $caseid
293     * @param string $stage
294     * @return stdClass
295     */
296    private static function student_case(int $caseid, string $stage): stdClass {
297        global $USER;
298        $case = self::get($caseid);
299        require_capability('local/integritycase:respond', context_course::instance($case->courseid));
300        if ((int) $case->studentid !== (int) $USER->id || !$case->studentvisible) {
301            throw new moodle_exception('error:notyourcase', 'local_integritycase');
302        }
303        if ($case->stage !== $stage) {
304            throw new moodle_exception('error:wrongstage', 'local_integritycase', '', self::stage_name($case->stage));
305        }
306        return $case;
307    }
308
309    /**
310     * Write an audit trail row.
311     *
312     * @param int $caseid
313     * @param string $action created, stage, note, response or appeal.
314     * @param string|null $from
315     * @param string|null $to
316     * @param string|null $outcome
317     * @param string|null $note
318     * @param bool $visible Whether the student may see it.
319     */
320    private static function record(
321        int $caseid,
322        string $action,
323        ?string $from,
324        ?string $to,
325        ?string $outcome,
326        ?string $note,
327        bool $visible
328    ): void {
329        global $DB, $USER;
330        $DB->insert_record(self::EVENTS, (object) [
331            'caseid' => $caseid,
332            'userid' => $USER->id,
333            'action' => $action,
334            'fromstage' => $from,
335            'tostage' => $to,
336            'outcome' => $outcome,
337            'note' => $note === '' ? null : $note,
338            'noteformat' => FORMAT_PLAIN,
339            'visibletostudent' => $visible ? 1 : 0,
340            'timecreated' => time(),
341        ]);
342    }
343
344    /**
345     * Mark a case as changed and log it.
346     *
347     * @param stdClass $case
348     */
349    private static function touch(stdClass $case): void {
350        global $DB;
351        $DB->set_field(self::TABLE, 'timemodified', time(), ['id' => $case->id]);
352        self::log($case, context_course::instance($case->courseid));
353    }
354
355    /**
356     * Trigger the case updated log event.
357     *
358     * @param stdClass $case
359     * @param context_course $context
360     */
361    private static function log(stdClass $case, context_course $context): void {
362        case_updated::create([
363            'objectid' => $case->id,
364            'context' => $context,
365            'relateduserid' => $case->studentid,
366            'other' => ['stage' => $case->stage],
367        ])->trigger();
368    }
369
370    /**
371     * Tell the student where their case is now.
372     *
373     * @param stdClass $case
374     * @param string $note
375     */
376    private static function notify_student(stdClass $case, string $note): void {
377        $course = get_course($case->courseid);
378        $coursename = format_string($course->fullname, true, ['context' => context_course::instance($course->id)]);
379        $stage = self::stage_name($case->stage);
380        if ($case->outcome && in_array($case->stage, [workflow::DECIDED, workflow::CLOSED], true)) {
381            $stage .= ' (' . get_string('outcome:' . $case->outcome, 'local_integritycase') . ')';
382        }
383        $text = get_string('message:student', 'local_integritycase', (object) ['course' => $coursename, 'stage' => $stage]);
384        if ($note !== '') {
385            $text .= "\n\n" . $note;
386        }
387        self::send(
388            $case,
389            'casestudent',
390            (int) $case->studentid,
391            get_string('message:subject:student', 'local_integritycase', $coursename),
392            $text
393        );
394    }
395
396    /**
397     * Tell the handler the student responded or appealed.
398     *
399     * @param stdClass $case
400     * @param string $what response or appeal.
401     */
402    private static function notify_handler(stdClass $case, string $what): void {
403        $course = get_course($case->courseid);
404        $student = fullname(core_user::get_user($case->studentid));
405        $a = (object) [
406            'student' => $student,
407            'course' => format_string($course->fullname, true, ['context' => context_course::instance($course->id)]),
408        ];
409        self::send(
410            $case,
411            'casehandler',
412            (int) $case->handlerid,
413            get_string('message:subject:handler', 'local_integritycase', $student),
414            get_string("message:handler:$what", 'local_integritycase', $a)
415        );
416    }
417
418    /**
419     * Send a notification about a case.
420     *
421     * @param stdClass $case
422     * @param string $provider
423     * @param int $userid
424     * @param string $subject
425     * @param string $text Plain text.
426     */
427    private static function send(stdClass $case, string $provider, int $userid, string $subject, string $text): void {
428        $url = new moodle_url('/local/integritycase/view.php', ['id' => $case->id]);
429        $message = new \core\message\message();
430        $message->component = 'local_integritycase';
431        $message->name = $provider;
432        $message->userfrom = core_user::get_noreply_user();
433        $message->userto = $userid;
434        $message->subject = $subject;
435        $message->fullmessage = $text;
436        $message->fullmessageformat = FORMAT_PLAIN;
437        $message->fullmessagehtml = text_to_html(s($text), false, false, true);
438        $message->smallmessage = $subject;
439        $message->notification = 1;
440        $message->contexturl = $url->out(false);
441        $message->contexturlname = get_string('pluginname', 'local_integritycase');
442        $message->courseid = $case->courseid;
443        message_send($message);
444    }
445}