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
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -298,7 +298,8 @@
|
||||
"from": "من",
|
||||
"to": "إلى",
|
||||
"apply": "تطبيق",
|
||||
"invalid": "اختر نطاقًا صحيحًا"
|
||||
"invalid": "اختر نطاقًا صحيحًا",
|
||||
"loadError": "تعذّر تحميل الإحصائيات للنطاق المحدّد. يرجى التحقق من التواريخ والمحاولة مرة أخرى."
|
||||
},
|
||||
"topApps": "أكثر التطبيقات استخدامًا",
|
||||
"topAppsSubtitle": "حسب الفتح في {{range}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{adminStatsErrorMessage && (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded-lg px-3 py-2"
|
||||
>
|
||||
{t("admin.dashboard.customRange.loadError")}
|
||||
<div className="mt-0.5 font-mono text-[11px] opacity-80 break-words">
|
||||
{adminStatsErrorMessage}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
|
||||
@@ -4068,7 +4068,7 @@ export const getGetAdminStatsQueryKey = (params?: GetAdminStatsParams) => {
|
||||
|
||||
export const getGetAdminStatsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getAdminStats>>,
|
||||
TError = ErrorType<unknown>,
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
>(
|
||||
params?: GetAdminStatsParams,
|
||||
options?: {
|
||||
@@ -4098,7 +4098,7 @@ export const getGetAdminStatsQueryOptions = <
|
||||
export type GetAdminStatsQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getAdminStats>>
|
||||
>;
|
||||
export type GetAdminStatsQueryError = ErrorType<unknown>;
|
||||
export type GetAdminStatsQueryError = ErrorType<ErrorResponse>;
|
||||
|
||||
/**
|
||||
* @summary Get admin dashboard trend stats
|
||||
@@ -4106,7 +4106,7 @@ export type GetAdminStatsQueryError = ErrorType<unknown>;
|
||||
|
||||
export function useGetAdminStats<
|
||||
TData = Awaited<ReturnType<typeof getAdminStats>>,
|
||||
TError = ErrorType<unknown>,
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
>(
|
||||
params?: GetAdminStatsParams,
|
||||
options?: {
|
||||
|
||||
@@ -1027,6 +1027,12 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AdminStats"
|
||||
"400":
|
||||
description: Invalid range parameters (e.g. range=custom with missing/malformed from/to or inverted dates)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
components:
|
||||
schemas:
|
||||
|
||||
Reference in New Issue
Block a user