Repository navigation
Conversation
Adds a dedicated graph_data endpoint with a custom handler, formatter, and response mapping. The handler now supports user/lang filters and groups monthly page counts by date for chart output. This also includes endpoint parameter metadata updates, improved server-side error reporting, and small cleanup in the missing_exists query helpers.
Introduce EndpointContext::applyCampaignCategory (with PATTERN and InputSanitizer) to centralize campaign/category filtering and sanitization. Update GraphDataHandler and PagesHandler to exclude campaign/cat/category from applyFilters and call the new method. Remove the duplicated GraphDataHandler::handle1 and streamline query construction.
This change adds the get_lang_years endpoint, replacing the old user_lang_status alias with a proper language-years query and updating the API metadata and docs. It also makes filter handling more consistent by treating empty and 'All' values as inactive, fixes category/campaign validation, and adds the user_group filter support for graph_data with the required join.
Removes the old status endpoint and its docs/config entries, and replaces it with the newer user data status flow. The endpoint registry now exposes user_status and user_data_status, backed by a dedicated handler and formatter so the API reflects the current status reporting model.
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configuration
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
MrIbrahem
left a comment
There was a problem hiding this comment.
Review summary
This PR cleans up the statistics endpoints nicely — centralizing campaign/category filtering into EndpointContext and replacing ad-hoc handlers with dedicated ones is a real improvement, and the legacy status endpoint is removed completely (no dangling references). That said, it introduces a few regressions that should be fixed before merge: a TypeError that makes user_data_status fail whenever user is omitted, a shared filter helper that now silently drops 0-valued filters (e.g. pages?Deleted=0), and raw exception messages echoed to API clients.
Verdict: Changes requested 🔴 — submitted as COMMENT 💬 because the reviewer is the PR author, and GitHub does not allow requesting changes on your own PR. The blocking issues are listed below and in the inline comments.
Title
The current title Oo doesn't describe the change. The repo has no strict convention, but recent descriptive titles use imperative mood (e.g. #48 "Fix PHPStan and PHPUnit tests", #43 "Migrate exists_by_qids_query from all_articles to category_members"). Suggested alternatives:
Add graph_data/get_lang_years endpoints and user_data_status flowReplace legacy status endpoint and centralize campaign/category filteringRework statistics endpoints (graph_data, get_lang_years, user_data_status)
Highlights
EndpointContext::applyCampaignCategory()removes genuine duplication betweenPagesHandlerand the newGraphDataHandler, and keeps theInputSanitizerpattern allowlist.FilterBuilder::isValid()is a good single place to define the empty /Allsemantics.- The
statusremoval is thorough — handler,status.php,bootstrap.phpinclude,request.phpcase, README row, and OpenAPI entry are all gone. - The new endpoints are consistent with the existing handler/formatter architecture.
Issues found
- 🔴 Blocking: 3 — see inline comments (
UserDataStatusHandler,APIController,FilterBuilder) - 🟡 Suggestions: 6
- Minor nits: redundant
Logger::debuginDatabase::fetchQuery(line 122 duplicates theLogger::errorabove it, which already logs message and query).
Cross-cutting notes
user_data_statusis undocumented: it's registered inEndpointRegistryand formatted byUserDataStatusFormatter, but has no entry inendpoint_params.json,openapi.json, orREADME.md. That also meanssupported_params/supported_valuescome back empty. Add the config + docs, or mark it internal.- Removing
statusis a breaking change:api.php?get=statusnow returnsinvalid get request. The olduser_lang_statusused aredirectentry — consider the same forstatus→graph_data(oruser_status) if any external client still calls it. graph_dataresponse shape changed: it returned rows keyedm/cand now returnsdate/count. Confirm the consuming dashboard expects the new keys (theGraphDataFormatterthat would producelabels/countsis currently disabled — see inline comment).- Diff scope: most of
docs/new-structure-oc.mdis cosmetic markdown reformatting (list indentation / table alignment) unrelated to the endpoint changes. Fine, but splitting such churn out keeps the functional diff easier to review.
| $user = $ctx->request->get('user'); | ||
|
|
||
| if ($ctx->isValid($user)) { |
There was a problem hiding this comment.
🔴 Bug — user_data_status throws whenever user is not supplied.
Request::get() returns ?string (null when the parameter is absent), but EndpointContext::isValid() is declared as isValid(string $value) — and both files declare strict_types=1. So api.php?get=user_data_status (no user) raises TypeError: Argument #1 ($value) must be of type string, null given, which the controller converts into a 500.
The if clearly intends user to be optional, so widen the signature:
// EndpointContext.php
public function isValid(mixed $value): bool
{
return FilterBuilder::isValid($value);
}(or pass $user ?? '' at the call site). This is reachable today — user_data_status is registered in EndpointRegistry, so it's a live endpoint, and it currently 500s on its most basic call.
| http_response_code(500); | ||
| $this->emit($this->builder->errorOnly('internal error')); | ||
| $result = $this->builder->errorOnly('internal error'); | ||
| $result["e"] = $e->getMessage(); |
There was a problem hiding this comment.
🔴 Security — raw exception messages are now echoed to API clients.
$e->getMessage() for a PDOException / DatabaseException typically contains the failing SQL and schema details (e.g. Unknown column 'p.campaign' in 'where clause'), and other throwables leak file/class/method internals. For a public API this is information disclosure, and it also contradicts the deliberate errorOnly('internal error') user-facing message.
The codebase already has a gate for exactly this — ResponseBuilder::build() only exposes query when Environment::isLocalhost(). Reuse it:
$result = $this->builder->errorOnly('internal error');
if (Environment::isLocalhost()) {
$result['e'] = $e->getMessage();
}
$this->emit($result);(Side note: this PR also removed the Logger::error() doc-comment stating internal details never reach the user — worth preserving that intent.)
| /** AND إن كان في الاستعلام WHERE، وإلا WHERE */ | ||
| public static function isValid(mixed $str): bool | ||
| { | ||
| return !empty($str) && strtolower((string)$str) != "all"; |
There was a problem hiding this comment.
🔴 Regression — filter values of "0" are now silently dropped.
empty("0") is true in PHP, so isValid("0") returns false and the filter is skipped. The old check only excluded all, so a 0 value was applied. This affects every endpoint: pages has a Deleted → p.deleted param, so api.php?get=pages&Deleted=0 now silently returns both deleted and non-deleted pages. The same risk applies to other status/numeric params (result, translate_type, …).
Use an explicit emptiness check rather than empty():
public static function isValid(mixed $str): bool
{
return $str !== null && $str !== '' && strtolower((string)$str) !== 'all';
}Two related cleanups:
- This makes the
no_empty_valuebranch below dead code (!isValid($value)already continues on empty values), so the newno_empty_value: trueflags ongraph_data'smonth/yearhave no effect. Either drop the flags or keepisValidstrict only aboutall. != "all"works but should be!== 'all'on the lowercased value for a strict comparison.
| public function handle(EndpointContext $ctx): QuerySpec | ||
| { | ||
| // api.php?get=graph_data&year=All&month=&category=&campaign=All&user_group=all | ||
| $sql = "SELECT DISTINCT LEFT(p.pupdate, 7) AS date, COUNT(*) AS count |
There was a problem hiding this comment.
🟡 COUNT(*) can be inflated by the joins; DISTINCT is redundant here.
Since you GROUP BY LEFT(p.pupdate, 7) with an aggregate, SELECT DISTINCT does nothing — each group yields exactly one row. The real risk is fan-in: if categories.category isn't unique per cat, or a username appears more than once in users, these LEFT JOINs multiply rows and COUNT(*) overcounts. The old graph_data had no joins at all.
For exact counts, count the pages themselves:
SELECT LEFT(p.pupdate, 7) AS date, COUNT(DISTINCT p.id) AS count(adjust if pages has no id), or push the campaign filter into a subquery.
Two semantic notes on campaign:
endpoint_params.jsondeclarescampaign→p.campaign, but the applied filter isca.campaign(viaapplyCampaignCategory). Ifpages.campaignand its category's campaign can disagree,?campaign=Xwon't match the documented column.- The removed
StatusHandlerfiltered campaigns asp.cat IN (SELECT category FROM categories WHERE campaign = ?); the new shared helper usesca.campaign = ?. They're equivalent only if each category maps to exactly one campaign — worth confirming, since this helper now backspagestoo.
| "camps" => [], | ||
| ]; | ||
|
|
||
| foreach ($rows as $row) { |
There was a problem hiding this comment.
🟡 Consider aggregating in SQL instead of counting in PHP.
Without a user filter this pulls one row per page joined to categories (potentially the whole pages table) into PHP and loops over it, just to produce per-year/lang/campaign counts. A GROUP BY YEAR(p.pupdate), p.lang, ca.campaign with COUNT(*) in UserDataStatusHandler returns the same buckets in one round-trip and scales far better.
Also: the header comment on line 2 says // src/app/Formatting/LeaderboardFormatter.php — a copy-paste leftover; it should be UserDataStatusFormatter.php.
| 'get_lang_years' => new FilteredSqlHandler( | ||
| 'SELECT DISTINCT YEAR(p.pupdate) as year | ||
| FROM pages p | ||
| LEFT JOIN categories ca ON p.cat = ca.category', |
There was a problem hiding this comment.
🟡 Unnecessary join, and the SQL is duplicated.
The LEFT JOIN categories ca isn't needed here — the only filter is p.lang, and DISTINCT YEAR(...) already collapses any fan-in, so the join just adds cost. Drop it:
'get_lang_years' => new FilteredSqlHandler(
'SELECT DISTINCT YEAR(p.pupdate) AS year FROM pages p'
),The same query is also duplicated in the legacy request.php (case 'get_lang_years'). If that path is still served for this endpoint the two definitions can drift; if it isn't, the legacy case can go.
| public function format(string $get, array $results): array | ||
| { | ||
| return match ($get) { | ||
| // 'graph_data' => GraphDataFormatter::format($results), |
There was a problem hiding this comment.
🟡 GraphDataFormatter is dead code — what shape should graph_data return?
This mapping is commented out, so graph_data falls through to default and returns raw rows ([{ "date": …, "count": … }, …]), while GraphDataFormatter (and its labels/counts output) is never called. Either enable the line or delete the class and its import.
Worth confirming against the consumer: this PR also renamed the response keys from m/c (the old static SQL) to date/count, so any dashboard still reading m/c breaks silently.
(Nit: the four new use App\Formatting\* imports are no-ops — this file already lives in namespace App\Formatting, so those classes resolve without imports.)
| | `/api.php?get=translate_type` | GET | Translation types (lead/full) | | ||
| | `/api.php?get=user_access` | GET | Access keys with usernames | | ||
| | `/api.php?get=user_lang_status` | GET | User status by language (redirects to user_status) | | ||
| | `/api.php?get=get_lang_years` | GET | User status by language (redirects to user_status) | |
There was a problem hiding this comment.
🟡 This description is inaccurate for get_lang_years.
It's copied from the old user_lang_status alias, but get_lang_years no longer redirects — it's a real endpoint returning the distinct years present in pages, optionally filtered by lang. Something like:
| `/api.php?get=lang_years` | GET | Distinct years present in pages (optional lang filter) |
Also consider the endpoint name itself: every other endpoint is a bare noun (langs, pages, views, …), so get_lang_years reads like a function call. lang_years would match the house style — and this row is also out of alphabetical order, since it was left where user_lang_status used to sit.
| }, | ||
| "/api.php?get=get_lang_years": { | ||
| "get": { | ||
| "summary": "", |
There was a problem hiding this comment.
🟡 Empty summary, and user_data_status is missing entirely.
Swagger UI renders a blank title for this path. Fill it in, e.g. "summary": "List distinct years".
Also, the new user_data_status endpoint is registered in EndpointRegistry and formatted by UserDataStatusFormatter, but has no OpenAPI entry (and no README row or endpoint_params.json entry). If it's meant to be public, add all three; if not, it should be excluded from the registry or marked internal.
| return $stmt->fetchAll(PDO::FETCH_ASSOC); | ||
| } catch (PDOException $e) { | ||
| Logger::error('SQL Error in fetchQuery: ' . $e->getMessage() . ' | Query: ' . $sqlQuery); | ||
| Logger::debug('SQL Error in fetchQuery: Query: ' . $sqlQuery); |
There was a problem hiding this comment.
Nit: this Logger::debug(...) line duplicates the Logger::error(...) two lines above it, which already logs the message and the full query unconditionally. This one logs a strict subset (query only) and only in development — it adds nothing, so it can go.
Summary
Reworks the statistics/status endpoints: adds a dedicated
graph_datahandler with filtering, introduces a realget_lang_yearsendpoint (replacing theuser_lang_statusalias), adds a newuser_data_statusendpoint, and removes the legacystatusendpoint. Campaign/category filtering is centralized inEndpointContext::applyCampaignCategory, and empty/Allfilter values are now treated as inactive across endpoints.Changes
graph_data— replaced the static SQL with a newGraphDataHandlersupportingyear,month,user,lang,user_group,category/cat, andcampaignfilters, grouping monthly page counts by date (src/app/Endpoints/Handlers/GraphDataHandler.php,endpoint_params.json).get_lang_years— replaces theuser_lang_statusredirect alias with an actual query returning distinct years, with an optionallangfilter (EndpointRegistry.php,request.php,openapi.json,README.md).user_data_status— backed byUserDataStatusHandler+UserDataStatusFormatter, returning per-year / per-language / per-campaign counts.status— deletesStatusHandler.php,status.php, itsrequest.phpcase, thebootstrap.phpinclude, the README row, and the OpenAPI entry.EndpointContext::applyCampaignCategory()(sharedPATTERN+InputSanitizer), now used by bothPagesHandlerandGraphDataHandler.FilterBuilder::isValid()treats empty andAllvalues as inactive in one place;EndpointContext::isValid()exposes it.APIControllernow attaches the exception message to 500 responses;Database::fetchQuerylogs the failing query at debug level.docs/new-structure-oc.mdupdates and small cleanups insubs/missing_exists.php.Testing
No tests included in this PR.