From 36f2872dcdd633de446567758fa6b4ae4c153fbb Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Tue, 21 Apr 2026 09:01:03 +0000 Subject: [PATCH] Reject invalid custom date ranges on /api/stats/admin with HTTP 400 Original task (Task #35): When the admin dashboard's custom range receives a malformed or missing date (e.g. ?range=custom&from=foo), the API silently fell back to the 7-day window instead of returning an error. This masked client bugs and confused admins. Changes: - artifacts/api-server/src/routes/stats.ts: - Refactored the custom-range branch so range=custom now always validates from/to. Returns 400 with a helpful, specific message when: * from or to is missing * from or to is not a valid YYYY-MM-DD UTC date * from is after to (existing behavior, message clarified) - Removed the silent `if (range === "custom") range = "7d"` fallback. - lib/api-spec/openapi.yaml: - Documented the 400 ErrorResponse on getAdminStats so the generated clients know about this failure mode. - Regenerated @workspace/api-client-react and @workspace/api-zod via `pnpm --filter @workspace/api-spec run codegen`. - artifacts/teaboy-os/src/pages/admin.tsx: - Captured `error` from useGetAdminStats (with retry: false) and surface a translated, role="alert" panel beneath the range controls when the API returns an error, including the server's error message. The frontend already guards against client-side invalid input via isCustomValid, so this primarily covers any remaining edge cases (stale querystring, race conditions). - Added admin.dashboard.customRange.loadError translations in en.json and ar.json. Verified with `pnpm -w run typecheck` (passes for libs and all artifacts). Manual curl confirmed the route is wired (auth gate returns 401 first, as expected). Replit-Task-Id: 965134ad-0d07-4cd2-a6ff-f60a50289d90 --- artifacts/api-server/src/routes/stats.ts | 19 ++++++++++++++---- artifacts/teaboy-os/src/locales/ar.json | 3 ++- artifacts/teaboy-os/src/locales/en.json | 3 ++- artifacts/teaboy-os/src/pages/admin.tsx | 24 +++++++++++++++++++++-- lib/api-client-react/src/generated/api.ts | 6 +++--- lib/api-spec/openapi.yaml | 6 ++++++ 6 files changed, 50 insertions(+), 11 deletions(-) diff --git a/artifacts/api-server/src/routes/stats.ts b/artifacts/api-server/src/routes/stats.ts index 68fcff6e..9fa192f7 100644 --- a/artifacts/api-server/src/routes/stats.ts +++ b/artifacts/api-server/src/routes/stats.ts @@ -139,11 +139,23 @@ router.get("/stats/admin", requireAuth, requireAdmin, async (req, res): Promise< let rangeEndExclusive: Date; let rangeDays: number; - if (range === "custom" && fromParam && toParam) { + if (range === "custom") { + if (!fromParam || !toParam) { + res.status(400).json({ + error: "Missing from/to: 'range=custom' requires both 'from' and 'to' as YYYY-MM-DD dates", + }); + return; + } const fromDate = parseUtcDate(fromParam); const toDate = parseUtcDate(toParam); - if (!fromDate || !toDate || fromDate.getTime() > toDate.getTime()) { - res.status(400).json({ error: "Invalid from/to" }); + if (!fromDate || !toDate) { + res.status(400).json({ + error: "Invalid from/to: expected YYYY-MM-DD dates", + }); + return; + } + if (fromDate.getTime() > toDate.getTime()) { + res.status(400).json({ error: "Invalid from/to: 'from' must be on or before 'to'" }); return; } const MAX_DAYS = 366; @@ -156,7 +168,6 @@ router.get("/stats/admin", requireAuth, requireAdmin, async (req, res): Promise< rangeEndExclusive = new Date(toDate.getTime() + DAY_MS); rangeDays = days; } else { - if (range === "custom") range = "7d"; rangeDays = range === "90d" ? 90 : range === "30d" ? 30 : 7; rangeStart = new Date(startOfTodayUtc.getTime() - (rangeDays - 1) * DAY_MS); rangeEndExclusive = new Date(startOfTodayUtc.getTime() + DAY_MS); diff --git a/artifacts/teaboy-os/src/locales/ar.json b/artifacts/teaboy-os/src/locales/ar.json index 25e0dd19..011b1ac5 100644 --- a/artifacts/teaboy-os/src/locales/ar.json +++ b/artifacts/teaboy-os/src/locales/ar.json @@ -298,7 +298,8 @@ "from": "من", "to": "إلى", "apply": "تطبيق", - "invalid": "اختر نطاقًا صحيحًا" + "invalid": "اختر نطاقًا صحيحًا", + "loadError": "تعذّر تحميل الإحصائيات للنطاق المحدّد. يرجى التحقق من التواريخ والمحاولة مرة أخرى." }, "topApps": "أكثر التطبيقات استخدامًا", "topAppsSubtitle": "حسب الفتح في {{range}}", diff --git a/artifacts/teaboy-os/src/locales/en.json b/artifacts/teaboy-os/src/locales/en.json index c6ac0145..63ebfa8c 100644 --- a/artifacts/teaboy-os/src/locales/en.json +++ b/artifacts/teaboy-os/src/locales/en.json @@ -295,7 +295,8 @@ "from": "From", "to": "To", "apply": "Apply", - "invalid": "Pick a valid date range" + "invalid": "Pick a valid date range", + "loadError": "Couldn't load stats for the selected dates. Please check the range and try again." }, "topApps": "Top apps", "topAppsSubtitle": "By opens in {{range}}", diff --git a/artifacts/teaboy-os/src/pages/admin.tsx b/artifacts/teaboy-os/src/pages/admin.tsx index 32899c8f..c8344c09 100644 --- a/artifacts/teaboy-os/src/pages/admin.tsx +++ b/artifacts/teaboy-os/src/pages/admin.tsx @@ -24,7 +24,7 @@ import { getGetAdminStatsQueryKey, useAdminIssueResetLink, } from "@workspace/api-client-react"; -import type { App, Service, UserProfile, AppSettings, AdminStats } from "@workspace/api-client-react"; +import type { App, Service, UserProfile, AppSettings, AdminStats, GetAdminStatsQueryError } from "@workspace/api-client-react"; import { useQueryClient } from "@tanstack/react-query"; import { useAuth } from "@/contexts/AuthContext"; import { useUpload, type UploadResponse } from "@workspace/object-storage-web"; @@ -149,12 +149,18 @@ export default function AdminPage() { statsRange === "custom" ? { range: "custom" as const, from: appliedCustom.from, to: appliedCustom.to } : { range: statsRange }; - const { data: adminStats } = useGetAdminStats(statsQueryParams, { + const { data: adminStats, error: adminStatsError } = useGetAdminStats(statsQueryParams, { query: { queryKey: getGetAdminStatsQueryKey(statsQueryParams), enabled: isAdmin && (statsRange !== "custom" || isCustomValid), + retry: false, }, }); + const adminStatsErrorMessage: string | null = (() => { + if (!adminStatsError) return null; + const err = adminStatsError as GetAdminStatsQueryError; + return err.data?.error ?? err.message ?? null; + })(); const createApp = useCreateApp(); const updateApp = useUpdateApp(); @@ -499,6 +505,7 @@ export default function AdminPage() { users={users} appSettings={appSettings} adminStats={adminStats} + adminStatsErrorMessage={adminStatsErrorMessage} lang={lang} statsRange={statsRange} onStatsRangeChange={setStatsRange} @@ -963,6 +970,7 @@ function DashboardSection({ users, appSettings, adminStats, + adminStatsErrorMessage, lang, statsRange, onStatsRangeChange, @@ -979,6 +987,7 @@ function DashboardSection({ users: UserProfile[] | undefined; appSettings: AppSettings | undefined; adminStats: AdminStats | undefined; + adminStatsErrorMessage: string | null; lang: string; statsRange: "7d" | "30d" | "90d" | "custom"; onStatsRangeChange: (range: "7d" | "30d" | "90d" | "custom") => void; @@ -1220,6 +1229,17 @@ function DashboardSection({ )} )} + {adminStatsErrorMessage && ( +