Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 117
0.00% covered (danger)
0.00%
0 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserActivityController
0.00% covered (danger)
0.00%
0 / 117
0.00% covered (danger)
0.00%
0 / 4
1056
0.00% covered (danger)
0.00%
0 / 1
 record
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
132
 timeline
0.00% covered (danger)
0.00%
0 / 72
0.00% covered (danger)
0.00%
0 / 1
132
 resolveUserId
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
56
 callerIsAdmin
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace App\Http\Controllers;
4
5use App\Models\TblAuditLogs;
6use App\Models\TblUserActivity;
7use Illuminate\Http\Request;
8use Illuminate\Support\Facades\DB;
9
10class UserActivityController extends Controller
11{
12    /**
13     * POST /api/user-activity
14     *
15     * Frontend pushes batches of events here. Body:
16     *   {
17     *     "session_id": "abc123",
18     *     "events": [
19     *       { "action": "page_view", "context": {...}, "url": "...", "client_timestamp": "..." },
20     *       ...
21     *     ]
22     *   }
23     *
24     * Server fills in user_id from the authenticated user; server time is
25     * authoritative for created_at. Returns the count of rows persisted.
26     */
27    public function record(Request $request)
28    {
29        $userId = $this->resolveUserId($request);
30        $sessionId = (string) ($request->input('session_id') ?? '');
31        $events = $request->input('events', []);
32
33        if (! is_array($events) || empty($events)) {
34            return response(['message' => 'OK', 'inserted' => 0]);
35        }
36
37        $rows = [];
38        $now = now();
39        foreach ($events as $e) {
40            if (! is_array($e) || empty($e['action'])) {
41                continue;
42            }
43
44            $rows[] = [
45                'user_id' => $userId,
46                'session_id' => $sessionId !== '' ? mb_substr($sessionId, 0, 64) : null,
47                'action' => mb_substr((string) $e['action'], 0, 80),
48                'context' => isset($e['context']) ? json_encode($e['context']) : null,
49                'url' => isset($e['url']) ? mb_substr((string) $e['url'], 0, 500) : null,
50                'ip_address' => $request->ip(),
51                'created_at' => $now,
52            ];
53        }
54
55        if (empty($rows)) {
56            return response(['message' => 'OK', 'inserted' => 0]);
57        }
58
59        try {
60            DB::table('tbl_user_activity')->insert($rows);
61
62            return response(['message' => 'OK', 'inserted' => count($rows)]);
63        } catch (\Exception $e) {
64            /** @disregard P1014 */
65            $e->exceptionCode = 'RECORD_USER_ACTIVITY_EXCEPTION';
66            report($e);
67
68            return response(['message' => 'KO', 'error' => $e->getMessage()]);
69        }
70    }
71
72    /**
73     * GET /api/users/{id}/activity?days=30&per_page=50&page=1
74     *
75     * Admin-only. Merged chronological timeline of:
76     *   - DB changes from tbl_audit_logs (UPDATE/DELETE/CREATE on the records they touched)
77     *   - UI actions from tbl_user_activity (page views, clicks, filters)
78     */
79    public function timeline(Request $request, int $id)
80    {
81        if (! $this->callerIsAdmin($request)) {
82            return response(['message' => 'KO', 'error' => 'Forbidden'], 403);
83        }
84
85        $days = max(1, min(365, (int) $request->query('days', 30)));
86        $perPage = max(1, min(200, (int) $request->query('per_page', 50)));
87        $page = max(1, (int) $request->query('page', 1));
88        $since = now()->subDays($days);
89
90        try {
91            $audit = TblAuditLogs::query()
92                ->where('user_id', $id)
93                ->where('created_at', '>=', $since)
94                ->select([
95                    DB::raw("'db' as source"),
96                    'id',
97                    'created_at',
98                    'event as action',
99                    'auditable_type',
100                    'auditable_id',
101                    'old_data',
102                    'new_data',
103                    'url',
104                    'ip_address',
105                ]);
106
107            $activity = TblUserActivity::query()
108                ->where('user_id', $id)
109                ->where('created_at', '>=', $since)
110                ->select([
111                    DB::raw("'ui' as source"),
112                    'id',
113                    'created_at',
114                    'action',
115                    DB::raw('NULL as auditable_type'),
116                    DB::raw('NULL as auditable_id'),
117                    DB::raw('NULL as old_data'),
118                    DB::raw('NULL as new_data'),
119                    'url',
120                    'ip_address',
121                ]);
122
123            $merged = $audit->unionAll($activity);
124
125            $sql = 'SELECT * FROM (' . $merged->toSql() . ') AS combined ORDER BY created_at DESC';
126            $bindings = $merged->getBindings();
127
128            $total = DB::selectOne(
129                'SELECT COUNT(*) AS c FROM (' . $merged->toSql() . ') AS combined',
130                $bindings,
131            )->c;
132
133            $offset = ($page - 1) * $perPage;
134            $rows = DB::select($sql . " LIMIT {$perPage} OFFSET {$offset}", $bindings);
135
136            // Decode JSON columns for the client.
137            foreach ($rows as $r) {
138                if (isset($r->old_data) && $r->old_data) {
139                    $r->old_data = json_decode($r->old_data, true);
140                }
141                if (isset($r->new_data) && $r->new_data) {
142                    $r->new_data = json_decode($r->new_data, true);
143                }
144            }
145
146            // Pull context for ui rows in one go (kept out of the union to avoid
147            // mixing JSON columns from two different schemas).
148            $uiIds = collect($rows)->where('source', 'ui')->pluck('id')->all();
149            if (! empty($uiIds)) {
150                $contexts = TblUserActivity::whereIn('id', $uiIds)
151                    ->pluck('context', 'id')
152                    ->all();
153                foreach ($rows as $r) {
154                    if ($r->source === 'ui') {
155                        $r->context = $contexts[$r->id] ?? null;
156                    }
157                }
158            }
159
160            return response([
161                'message' => 'OK',
162                'data' => [
163                    'current_page' => $page,
164                    'per_page' => $perPage,
165                    'total' => (int) $total,
166                    'days' => $days,
167                    'data' => $rows,
168                ],
169            ]);
170        } catch (\Exception $e) {
171            /** @disregard P1014 */
172            $e->exceptionCode = 'USER_ACTIVITY_TIMELINE_EXCEPTION';
173            report($e);
174
175            return response(['message' => 'KO', 'error' => $e->getMessage()]);
176        }
177    }
178
179    private function resolveUserId(Request $request): ?int
180    {
181        $user = $request->user();
182        if ($user && $user->getKey()) {
183            return (int) $user->getKey();
184        }
185
186        $backend = $request->header('backend-user-id');
187        if ($backend && is_numeric($backend)) {
188            return (int) $backend;
189        }
190
191        $client = $request->header('User-ID');
192        if ($client && is_numeric($client)) {
193            return (int) $client;
194        }
195
196        return null;
197    }
198
199    private function callerIsAdmin(Request $request): bool
200    {
201        $userId = (int) $request->header('User-ID');
202        if (! $userId) {
203            return false;
204        }
205        $roleId = DB::table('tbl_users')->where('id', $userId)->value('role_id');
206        if (! $roleId) {
207            return false;
208        }
209        $roleName = DB::table('tbl_roles')->where('role_id', $roleId)->value('name');
210
211        return $roleName === 'Admin';
212    }
213}