{"version":3,"file":"dashboard-nfqAD0pb.mjs","names":[],"sources":["../src/scheduler-health.ts","../src/api/handlers/dashboard.ts"],"sourcesContent":["import type { Kysely } from \"kysely\";\n\nimport { OptionsRepository } from \"./database/repositories/options.js\";\nimport type { Database } from \"./database/types.js\";\n\nexport const SCHEDULER_HEARTBEAT_OPTION = \"system:scheduler:last_completed_at\";\nexport const SCHEDULER_STALE_AFTER_MS = 5 * 60 * 1000;\n\nexport interface SchedulerHealth {\n\tstatus: \"healthy\" | \"stale\" | \"unknown\";\n\tlastCompletedAt: string | null;\n}\n\nexport async function recordSchedulerHeartbeat(\n\tdb: Kysely<Database>,\n\tcompletedAt = new Date(),\n): Promise<void> {\n\tawait new OptionsRepository(db).set(SCHEDULER_HEARTBEAT_OPTION, completedAt.toISOString());\n}\n\nexport async function recordSchedulerHeartbeatSafely(\n\tdb: Kysely<Database>,\n\tcompletedAt = new Date(),\n): Promise<void> {\n\ttry {\n\t\tawait recordSchedulerHeartbeat(db, completedAt);\n\t} catch (error) {\n\t\tconsole.error(\"[scheduler] Failed to record heartbeat:\", error);\n\t}\n}\n\nexport async function getSchedulerHealth(\n\tdb: Kysely<Database>,\n\tnow = new Date(),\n): Promise<SchedulerHealth> {\n\tconst lastCompletedAt = await new OptionsRepository(db).get<string>(SCHEDULER_HEARTBEAT_OPTION);\n\tconst lastCompletedAtMs = lastCompletedAt ? Date.parse(lastCompletedAt) : Number.NaN;\n\tif (!lastCompletedAt || Number.isNaN(lastCompletedAtMs)) {\n\t\treturn { status: \"unknown\", lastCompletedAt: null };\n\t}\n\n\treturn {\n\t\tstatus: now.getTime() - lastCompletedAtMs > SCHEDULER_STALE_AFTER_MS ? \"stale\" : \"healthy\",\n\t\tlastCompletedAt,\n\t};\n}\n","/**\n * Dashboard stats handler\n *\n * Returns summary data for the admin dashboard in a single request:\n * collection content counts, media count, user count, and recent\n * content across all collections.\n */\n\nimport { sql, type Kysely } from \"kysely\";\n\nimport { ContentRepository } from \"../../database/repositories/content.js\";\nimport { MediaRepository } from \"../../database/repositories/media.js\";\nimport { UserRepository } from \"../../database/repositories/user.js\";\nimport type { Database } from \"../../database/types.js\";\nimport { validateIdentifier } from \"../../database/validate.js\";\nimport { getSchedulerHealth, type SchedulerHealth } from \"../../scheduler-health.js\";\nimport type { ApiResult } from \"../types.js\";\n\nexport interface CollectionStats {\n\tslug: string;\n\tlabel: string;\n\ttotal: number;\n\tpublished: number;\n\tdraft: number;\n\tscheduled: number;\n\toverdueScheduled: number;\n}\n\nexport interface RecentItem {\n\tid: string;\n\tcollection: string;\n\tcollectionLabel: string;\n\ttitle: string;\n\tslug: string | null;\n\tstatus: string;\n\tupdatedAt: string;\n\tauthorId: string | null;\n}\n\nexport interface DashboardStats {\n\tcollections: CollectionStats[];\n\tmediaCount: number;\n\tuserCount: number;\n\trecentItems: RecentItem[];\n\tschedulerHealth: SchedulerHealth;\n}\n\n/**\n * Fetch dashboard statistics.\n *\n * Queries are intentionally lightweight — counts use indexed columns,\n * and recent items are capped at 10.\n */\nexport async function handleDashboardStats(\n\tdb: Kysely<Database>,\n\tnow = new Date(),\n): Promise<ApiResult<DashboardStats>> {\n\ttry {\n\t\t// Discover collections from the system table\n\t\tconst collections = await db\n\t\t\t.selectFrom(\"_emdash_collections\")\n\t\t\t.select([\"slug\", \"label\"])\n\t\t\t.orderBy(\"slug\", \"asc\")\n\t\t\t.execute();\n\n\t\t// Gather per-collection counts in parallel\n\t\tconst contentRepo = new ContentRepository(db);\n\t\tconst collectionStats: CollectionStats[] = await Promise.all(\n\t\t\tcollections.map(async (col) => {\n\t\t\t\tconst stats = await contentRepo.getStats(col.slug, now);\n\t\t\t\treturn {\n\t\t\t\t\tslug: col.slug,\n\t\t\t\t\tlabel: col.label,\n\t\t\t\t\ttotal: stats.total,\n\t\t\t\t\tpublished: stats.published,\n\t\t\t\t\tdraft: stats.draft,\n\t\t\t\t\tscheduled: stats.scheduled,\n\t\t\t\t\toverdueScheduled: stats.overdueScheduled,\n\t\t\t\t};\n\t\t\t}),\n\t\t);\n\n\t\t// Media and user counts\n\t\tconst mediaRepo = new MediaRepository(db);\n\t\tconst userRepo = new UserRepository(db);\n\t\tconst [mediaCount, userCount, schedulerHealth] = await Promise.all([\n\t\t\tmediaRepo.count(),\n\t\t\tuserRepo.count(),\n\t\t\tgetSchedulerHealth(db, now),\n\t\t]);\n\n\t\t// Recent items across all collections (last 10 updated, any status)\n\t\tconst recentItems = await fetchRecentItems(db, collections);\n\n\t\treturn {\n\t\t\tsuccess: true,\n\t\t\tdata: {\n\t\t\t\tcollections: collectionStats,\n\t\t\t\tmediaCount,\n\t\t\t\tuserCount,\n\t\t\t\trecentItems,\n\t\t\t\tschedulerHealth,\n\t\t\t},\n\t\t};\n\t} catch (error) {\n\t\tconsole.error(\"Dashboard stats error:\", error);\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: {\n\t\t\t\tcode: \"DASHBOARD_STATS_ERROR\",\n\t\t\t\tmessage: \"Failed to load dashboard statistics\",\n\t\t\t},\n\t\t};\n\t}\n}\n\n/** Raw row shape from the UNION ALL query — all snake_case. */\ninterface RecentItemRow {\n\tid: string;\n\tcollection: string;\n\tcollection_label: string;\n\ttitle: string;\n\tslug: string | null;\n\tstatus: string;\n\tupdated_at: string;\n\tauthor_id: string | null;\n}\n\n/**\n * Fetch the 10 most recently updated items across all collections.\n *\n * Uses UNION ALL over each ec_* table. The query is safe because\n * collection slugs come from the system table and are validated.\n *\n * `title` is not a standard column — it's a user-defined field. We query\n * `_emdash_fields` to discover which collections have one and fall back\n * to `slug` (which is always present) otherwise.\n */\nasync function fetchRecentItems(\n\tdb: Kysely<Database>,\n\tcollections: Array<{ slug: string; label: string }>,\n): Promise<RecentItem[]> {\n\tif (collections.length === 0) return [];\n\n\t// Discover which collections have a \"title\" column\n\tconst titleFields = await db\n\t\t.selectFrom(\"_emdash_fields as f\")\n\t\t.innerJoin(\"_emdash_collections as c\", \"c.id\", \"f.collection_id\")\n\t\t.select([\"c.slug as collection_slug\"])\n\t\t.where(\"f.slug\", \"=\", \"title\")\n\t\t.execute();\n\n\tconst collectionsWithTitle = new Set(titleFields.map((r) => r.collection_slug));\n\n\t// Issue one query per collection in parallel, then merge in JS.\n\t// A single UNION ALL across N collections trips D1's\n\t// SQLITE_LIMIT_COMPOUND_SELECT cap when N is large enough (#895);\n\t// per-collection queries side-step that. Each query fetches at most\n\t// 10 rows, so the merge handles at most N * 10 rows before slicing.\n\tconst perCollection = await Promise.all(\n\t\tcollections.map(async (col) => {\n\t\t\tvalidateIdentifier(col.slug);\n\t\t\tconst table = `ec_${col.slug}`;\n\t\t\tconst hasTitle = collectionsWithTitle.has(col.slug);\n\n\t\t\t// Use title column if it exists, otherwise fall back to slug, id.\n\t\t\t// All output uses snake_case to avoid SQLite quoting issues on D1.\n\t\t\tconst titleExpr = hasTitle ? sql`COALESCE(title, slug, id)` : sql`COALESCE(slug, id)`;\n\n\t\t\tconst result = await sql<RecentItemRow>`\n\t\t\t\tSELECT\n\t\t\t\t\tid,\n\t\t\t\t\t${sql.lit(col.slug)} AS collection,\n\t\t\t\t\t${sql.lit(col.label)} AS collection_label,\n\t\t\t\t\t${titleExpr} AS title,\n\t\t\t\t\tslug,\n\t\t\t\t\tstatus,\n\t\t\t\t\tupdated_at,\n\t\t\t\t\tauthor_id\n\t\t\t\tFROM ${sql.ref(table)}\n\t\t\t\tWHERE deleted_at IS NULL\n\t\t\t\tORDER BY updated_at DESC\n\t\t\t\tLIMIT 10\n\t\t\t`.execute(db);\n\t\t\treturn result.rows;\n\t\t}),\n\t);\n\n\t// Merge across collections, sort by updated_at desc, take top 10.\n\tconst merged = perCollection\n\t\t.flat()\n\t\t.toSorted((a, b) => (a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : 0))\n\t\t.slice(0, 10);\n\n\t// Map snake_case DB rows to camelCase API shape\n\treturn merged.map((row) => ({\n\t\tid: row.id,\n\t\tcollection: row.collection,\n\t\tcollectionLabel: row.collection_label,\n\t\ttitle: row.title,\n\t\tslug: row.slug,\n\t\tstatus: row.status,\n\t\tupdatedAt: row.updated_at,\n\t\tauthorId: row.author_id,\n\t}));\n}\n"],"mappings":"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"}