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:
@@ -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
|
||||||
|
|||||||
@@ -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 }));
|
||||||
|
|
||||||
|
|||||||
@@ -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,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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user