Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
17.14% covered (danger)
17.14%
24 / 140
25.00% covered (danger)
25.00%
1 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
ActivityLogPage
17.14% covered (danger)
17.14%
24 / 140
25.00% covered (danger)
25.00%
1 / 4
247.54
0.00% covered (danger)
0.00%
0 / 1
 register
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 addMenu
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 render
13.56% covered (danger)
13.56%
16 / 118
0.00% covered (danger)
0.00%
0 / 1
122.15
 renderPagination
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2/**
3 * Activity Log Admin Page (F230).
4 *
5 * Operator-facing view of their server-side audit log.
6 *
7 * Surfaces the wp_swapads_audit_log entries (which have been written
8 * since F230) so the operator can see exactly what happened on their
9 * account: when a credit was spent, when a backlink was approved /
10 * rejected, when source-check ran and what it found.
11 *
12 * Audit log is critical for trust in a 1:1 barter exchange — operators
13 * must be able to see "who did what" so a compromised admin cookie or
14 * buggy automation can't drain credits without notice.
15 *
16 * @package SwapAds\Client\Admin
17 * @since   1.5.2
18 */
19
20declare(strict_types=1);
21
22namespace SwapAds\Client\Admin;
23
24use SwapAds\Client\Admin\OperatorDashboardHubPage;
25use SwapAds\Client\Api\RestClient;
26use SwapAds\Client\License\LicenseManager;
27
28/**
29 * Class ActivityLogPage.
30 *
31 * @since 1.5.2
32 */
33final class ActivityLogPage
34{
35    public const MENU_SLUG     = 'swapads-client-activity-log';
36    public const ERROR_CONTEXT = 'swapads_client_activity_log';
37    public const PER_PAGE      = 50;
38
39    /**
40     * Register WP hooks.
41     */
42    public static function register(): void
43    {
44        add_action('admin_menu', [self::class, 'addMenu']);
45    }
46
47    /**
48     * Register the admin page (sub-menu of SwapAds).
49     */
50    public static function addMenu(): void
51    {
52        add_submenu_page(
53            OperatorDashboardHubPage::MENU_SLUG,
54            'Activity Log',
55            'Activity Log',
56            'manage_options',
57            self::MENU_SLUG,
58            [self::class, 'render']
59        );
60    }
61
62    /**
63     * Render the activity log page.
64     */
65    public static function render(): void
66    {
67        if (!current_user_can('manage_options')) {
68            wp_die('Insufficient permissions', 'Forbidden', ['response' => 403]);
69        }
70        $licensed = LicenseManager::isLicensed();
71        if (!$licensed) {
72            echo Renderer::pageHeader(
73                'Activity Log',
74                'Every state-changing action on your account is recorded here.'
75            );
76            echo Renderer::card(
77                'License required',
78                '<p>You need an active license before activity events are recorded.</p>'
79                    . '<p><a href="' . esc_url(admin_url('admin.php?page=' . OperatorDashboardHubPage::MENU_SLUG))
80                    . '" class="button">Activate license</a></p>',
81                'swapads-card-warning'
82            );
83            echo Renderer::pageFooter();
84            return;
85        }
86
87        $rest   = RestClient::fromOption();
88        $action = trim((string) ($_GET['action_filter'] ?? ''));
89        $type   = trim((string) ($_GET['entity_filter'] ?? ''));
90        $offset = max(0, (int) ($_GET['offset'] ?? 0));
91        $resp   = $rest->listAuditLog($action, $type, self::PER_PAGE, $offset);
92        $items  = isset($resp['items']) && is_array($resp['items']) ? $resp['items'] : [];
93        $total  = isset($resp['total']) ? (int) $resp['total'] : count($items);
94        $error  = $rest->lastError();
95
96        echo Renderer::pageHeader(
97            'Activity Log',
98            sprintf(
99                '%s events recorded. Newest first.',
100                number_format_i18n($total)
101            )
102        );
103
104        // Surface any inline error.
105        if ($error !== null) {
106            echo ClientErrorRenderer::render(self::ERROR_CONTEXT, [
107                'code'    => 'BACKEND_UNREACHABLE',
108                'message' => (string) wp_json_encode($error),
109            ], 'load activity log');
110        }
111
112        // Filters row
113        echo '<form method="get" class="swapads-filter-bar" style="margin-bottom:16px;">';
114        echo '<input type="hidden" name="page" value="' . esc_attr(self::MENU_SLUG) . '">';
115        echo '<label style="margin-right:12px;">Action: ';
116        echo '<select name="action_filter">';
117        $opts = [
118            '' => 'All actions',
119            'backlink.offer'        => 'backlink.offer',
120            'backlink.remove'       => 'backlink.remove',
121            'backlink.update'       => 'backlink.update',
122            'approval.pending'      => 'approval.pending',
123            'approval.reject'       => 'approval.reject',
124            'source_check.ok'       => 'source_check.ok',
125            'source_check.404'      => 'source_check.404',
126            'source_check.error'    => 'source_check.error',
127            'source_check.refund'   => 'source_check.refund',
128        ];
129        foreach ($opts as $val => $label) {
130            printf(
131                '<option value="%s"%s>%s</option>',
132                esc_attr($val),
133                selected($action, $val, false),
134                esc_html($label)
135            );
136        }
137        echo '</select></label>';
138        echo '<label style="margin-right:12px;">Entity: ';
139        echo '<select name="entity_filter">';
140        $eopts = [
141            ''              => 'All entities',
142            'backlink'      => 'backlink',
143            'approval'      => 'approval',
144            'source_check'  => 'source_check',
145            'license'       => 'license',
146        ];
147        foreach ($eopts as $val => $label) {
148            printf(
149                '<option value="%s"%s>%s</option>',
150                esc_attr($val),
151                selected($type, $val, false),
152                esc_html($label)
153            );
154        }
155        echo '</select></label>';
156        echo '<button type="submit" class="button">Filter</button>';
157        echo '</form>';
158
159        if (empty($items)) {
160            echo Renderer::card(
161                'No activity yet',
162                '<p>No matching events. Once you approve, reject, or run source-checks, they will appear here.</p>',
163                'swapads-card-info'
164            );
165        } else {
166            echo '<table class="widefat striped" role="table" aria-label="Activity log">';
167            echo '<thead><tr>';
168            echo '<th scope="col">When</th>';
169            echo '<th scope="col">Actor</th>';
170            echo '<th scope="col">Action</th>';
171            echo '<th scope="col">Entity</th>';
172            echo '<th scope="col">Details</th>';
173            echo '</tr></thead><tbody>';
174            foreach ($items as $row) {
175                $when = (string) ($row['created_at'] ?? '');
176                $actor = (string) ($row['actor'] ?? 'system');
177                $a = (string) ($row['action'] ?? '');
178                $eT = (string) ($row['entity_type'] ?? '');
179                $eI = (string) ($row['entity_id'] ?? '');
180                $details = $row['details'] ?? null;
181                echo '<tr>';
182                echo '<td>' . esc_html(mysql2date('Y-m-d H:i:s', $when)) . '</td>';
183                echo '<td><span class="swapads-actor swapads-actor--' . esc_attr($actor) . '">' . esc_html($actor) . '</span></td>';
184                echo '<td><code>' . esc_html($a) . '</code></td>';
185                echo '<td>' . esc_html($eT) . ($eI !== '' ? ' #' . esc_html($eI) : '') . '</td>';
186                echo '<td>';
187                if (is_array($details)) {
188                    $json = (string) wp_json_encode($details);
189                    echo '<pre style="margin:0; font-size:11px; max-width:480px; overflow-x:auto;">'
190                        . esc_html($json)
191                        . '</pre>';
192                } else {
193                    echo '<span class="swapads-muted">—</span>';
194                }
195                echo '</td>';
196                echo '</tr>';
197            }
198            echo '</tbody></table>';
199
200            // Pagination links
201            self::renderPagination($offset, count($items), $total, $action, $type);
202        }
203
204        echo Renderer::pageFooter();
205    }
206
207    /**
208     * Render Next / Prev pagination links.
209     */
210    private static function renderPagination(int $offset, int $shown, int $total, string $action, string $type): void
211    {
212        if ($shown < self::PER_PAGE && $offset === 0) {
213            return; // Only one page, no nav needed.
214        }
215        $baseUrl = admin_url('admin.php?page=' . self::MENU_SLUG);
216        $params  = ['action_filter' => $action, 'entity_filter' => $type];
217
218        echo '<div class="tablenav" style="margin-top:12px;"><div class="tablenav-pages">';
219        if ($offset > 0) {
220            $prev = $offset - self::PER_PAGE;
221            $params['offset'] = max(0, $prev);
222            echo '<a class="button" href="' . esc_url(add_query_arg($params, $baseUrl)) . '">&laquo; Previous</a> ';
223        }
224        if ($offset + $shown < $total) {
225            $params['offset'] = $offset + self::PER_PAGE;
226            echo '<a class="button" href="' . esc_url(add_query_arg($params, $baseUrl)) . '">Next &raquo;</a>';
227        }
228        echo '</div></div>';
229    }
230}