fix: Admin Insights page could crash on a single query failure (v0.55.2)

Two changes:
1. Replaced positional `GROUP BY 1` (not used anywhere else in this
   codebase) with the conventional pattern of repeating the actual
   grouped expression — matches every other groupBy call site in the app.
2. Switched Promise.all -> Promise.allSettled across the six independent
   aggregate queries feeding the six charts, defaulting a failed one to
   an empty array (that chart just renders "No data yet") instead of
   taking the whole page down. Logs the failure server-side either way.

Couldn't reproduce locally (no DB in this sandbox), but confirmed the
allSettled path works: the production build itself hit a real connection
failure against the (absent) local DB and degraded cleanly instead of
crashing, which is exactly the resilience this fixes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-19 20:43:11 +02:00
parent a6e3dc999b
commit 6eb759dd43
5 changed files with 38 additions and 15 deletions
+5
View File
@@ -2,6 +2,11 @@
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together. All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
## 0.55.2 — 2026-07-19 18:20
### Fixed
- Admin Insights page could 500 the whole panel if any one of its six aggregate queries failed — replaced a non-standard positional GROUP BY with the expression-repeat pattern used everywhere else in the codebase, and made the six queries independent (Promise.allSettled) so one failing degrades that one chart instead of crashing the page.
## 0.55.1 — 2026-07-19 18:05 ## 0.55.1 — 2026-07-19 18:05
### Fixed ### Fixed
+23 -12
View File
@@ -43,28 +43,24 @@ export default async function AdminInsightsPage() {
const since = new Date(); const since = new Date();
since.setDate(since.getDate() - DAYS); since.setDate(since.getDate() - DAYS);
const [ // Promise.allSettled, not all — six independent aggregate queries feeding
signupRows, // six independent charts; one query breaking (e.g. a table that's empty
recipeRows, // in a fresh install) shouldn't take down every chart on the page.
tierRows, const results = await Promise.allSettled([
visibilityRows,
usageRows,
ticketRows,
] = await Promise.all([
db db
.select({ day: sql<string>`to_char(${users.createdAt}, 'YYYY-MM-DD')`, n: sql<number>`count(*)::int` }) .select({ day: sql<string>`to_char(${users.createdAt}, 'YYYY-MM-DD')`.as("day"), n: sql<number>`count(*)::int` })
.from(users) .from(users)
.where(gte(users.createdAt, since)) .where(gte(users.createdAt, since))
.groupBy(sql`1`), .groupBy(sql`to_char(${users.createdAt}, 'YYYY-MM-DD')`),
db db
.select({ .select({
day: sql<string>`to_char(${recipes.createdAt}, 'YYYY-MM-DD')`, day: sql<string>`to_char(${recipes.createdAt}, 'YYYY-MM-DD')`.as("day"),
aiGenerated: recipes.aiGenerated, aiGenerated: recipes.aiGenerated,
n: sql<number>`count(*)::int`, n: sql<number>`count(*)::int`,
}) })
.from(recipes) .from(recipes)
.where(gte(recipes.createdAt, since)) .where(gte(recipes.createdAt, since))
.groupBy(sql`1`, recipes.aiGenerated), .groupBy(sql`to_char(${recipes.createdAt}, 'YYYY-MM-DD')`, recipes.aiGenerated),
db.select({ tier: users.tier, n: sql<number>`count(*)::int` }).from(users).groupBy(users.tier), db.select({ tier: users.tier, n: sql<number>`count(*)::int` }).from(users).groupBy(users.tier),
db.select({ visibility: recipes.visibility, n: sql<number>`count(*)::int` }).from(recipes).groupBy(recipes.visibility), db.select({ visibility: recipes.visibility, n: sql<number>`count(*)::int` }).from(recipes).groupBy(recipes.visibility),
db db
@@ -74,6 +70,21 @@ export default async function AdminInsightsPage() {
db.select({ status: supportTickets.status, n: sql<number>`count(*)::int` }).from(supportTickets).groupBy(supportTickets.status), db.select({ status: supportTickets.status, n: sql<number>`count(*)::int` }).from(supportTickets).groupBy(supportTickets.status),
]); ]);
for (const r of results) {
if (r.status === "rejected") console.error("[admin/insights] query failed", r.reason);
}
const [signupRows, recipeRows, tierRows, visibilityRows, usageRows, ticketRows] = results.map((r) =>
r.status === "fulfilled" ? r.value : []
) as [
{ day: string; n: number }[],
{ day: string; aiGenerated: boolean; n: number }[],
{ tier: "free" | "pro" | "family"; n: number }[],
{ visibility: "private" | "unlisted" | "public" | "followers"; n: number }[],
{ month: string; n: number }[],
{ status: "open" | "triaged" | "closed"; n: number }[],
];
const signupByDay = new Map(signupRows.map((r) => [r.day, r.n])); const signupByDay = new Map(signupRows.map((r) => [r.day, r.n]));
const signupSeries = lastNDays(DAYS).map((day) => ({ date: day, value: signupByDay.get(day) ?? 0 })); const signupSeries = lastNDays(DAYS).map((day) => ({ date: day, value: signupByDay.get(day) ?? 0 }));
+8 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together. // Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.55.1"; export const APP_VERSION = "0.55.2";
export type ChangelogEntry = { export type ChangelogEntry = {
version: string; version: string;
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: ChangelogEntry[] = [ export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.55.2",
date: "2026-07-19 18:20",
fixed: [
"Admin Insights page could 500 the whole panel if any one of its six aggregate queries failed — replaced a non-standard positional GROUP BY with the same expression-repeat pattern used everywhere else in the codebase, and made the six queries independent (Promise.allSettled) so one failing degrades that one chart instead of crashing the page.",
],
},
{ {
version: "0.55.1", version: "0.55.1",
date: "2026-07-19 18:05", date: "2026-07-19 18:05",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@epicure/web", "name": "@epicure/web",
"version": "0.55.1", "version": "0.55.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "epicure", "name": "epicure",
"version": "0.55.1", "version": "0.55.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "pnpm --filter web dev", "dev": "pnpm --filter web dev",