From 4e91c6eef7220800ea844c456b84abfa65596aa1 Mon Sep 17 00:00:00 2001 From: Helder Mendes Date: Thu, 12 Mar 2026 21:19:26 +0100 Subject: [PATCH] feat: api routes, data fetching, and forms --- app-next/src/app/[locale]/dashboard/page.tsx | 16 +- app-next/src/app/api/(dev)/debug-env/route.ts | 37 ++ .../src/app/api/auth/[...nextauth]/route.ts | 53 +- .../src/app/api/collections/create/route.ts | 150 +++++ app-next/src/app/api/count/route.ts | 9 - .../src/app/api/datasets/[id]/edit/route.ts | 72 ++- .../src/app/api/datasets/[id]/stats/route.ts | 87 +++ .../src/app/api/datasets/[id]/tags/route.ts | 85 +++ app-next/src/app/api/datasets/upload/route.ts | 134 +++++ app-next/src/app/api/es-proxy/route.ts | 8 - app-next/src/app/api/search/route.ts | 28 +- .../src/app/api/study/[id]/datasets/route.ts | 91 +++ .../src/app/api/study/[id]/tasks/route.ts | 90 +++ app-next/src/app/api/tasks/create/route.ts | 136 +++++ .../src/app/api/user/[id]/datasets/route.ts | 20 +- app-next/src/app/api/user/[id]/flows/route.ts | 21 +- app-next/src/app/api/user/[id]/route.ts | 3 - app-next/src/app/api/user/[id]/tasks/route.ts | 25 +- app-next/src/app/api/user/api-key/route.ts | 3 +- app-next/src/app/api/user/profile/route.ts | 9 + .../src/components/auth/profile-settings.tsx | 3 +- app-next/src/components/auth/sign-in-form.tsx | 8 +- app-next/src/components/auth/sign-up-form.tsx | 16 +- .../collection/collection-create-form.tsx | 293 ++++++++++ .../components/dashboard/user-dashboard.tsx | 288 ++++++---- .../components/dataset/dataset-edit-form.tsx | 162 +++++- .../dataset/dataset-upload-form.tsx | 526 ++++++++++++++++++ .../src/components/task/task-create-form.tsx | 400 +++++++++++++ app-next/src/hooks/use-op-speed.ts | 62 +++ app-next/src/hooks/useDatasetStats.ts | 131 +++++ app-next/src/hooks/useParquetData.ts | 43 +- app-next/src/hooks/usePlotlyTheme.ts | 28 + app-next/src/lib/api/dataset.ts | 8 +- app-next/src/lib/api/flow.ts | 2 +- app-next/src/lib/api/measure.ts | 100 ++++ app-next/src/lib/api/run.ts | 175 ++++++ app-next/src/lib/api/study.ts | 35 ++ app-next/src/lib/api/task.ts | 5 +- app-next/src/lib/api/user.ts | 39 ++ app-next/src/types/measure.ts | 15 + app-next/src/types/next-auth.d.ts | 35 +- app-next/src/types/task.ts | 6 +- server/data/views.py | 209 +++++-- 43 files changed, 3325 insertions(+), 341 deletions(-) create mode 100644 app-next/src/app/api/(dev)/debug-env/route.ts create mode 100644 app-next/src/app/api/collections/create/route.ts create mode 100644 app-next/src/app/api/datasets/[id]/stats/route.ts create mode 100644 app-next/src/app/api/datasets/[id]/tags/route.ts create mode 100644 app-next/src/app/api/datasets/upload/route.ts create mode 100644 app-next/src/app/api/study/[id]/datasets/route.ts create mode 100644 app-next/src/app/api/study/[id]/tasks/route.ts create mode 100644 app-next/src/app/api/tasks/create/route.ts create mode 100644 app-next/src/components/collection/collection-create-form.tsx create mode 100644 app-next/src/components/dataset/dataset-upload-form.tsx create mode 100644 app-next/src/components/task/task-create-form.tsx create mode 100644 app-next/src/hooks/use-op-speed.ts create mode 100644 app-next/src/hooks/useDatasetStats.ts create mode 100644 app-next/src/hooks/usePlotlyTheme.ts create mode 100644 app-next/src/lib/api/measure.ts create mode 100644 app-next/src/lib/api/run.ts create mode 100644 app-next/src/lib/api/study.ts create mode 100644 app-next/src/lib/api/user.ts create mode 100644 app-next/src/types/measure.ts diff --git a/app-next/src/app/[locale]/dashboard/page.tsx b/app-next/src/app/[locale]/dashboard/page.tsx index 00ce855b..b5d94f25 100644 --- a/app-next/src/app/[locale]/dashboard/page.tsx +++ b/app-next/src/app/[locale]/dashboard/page.tsx @@ -1,4 +1,7 @@ import { getTranslations } from "next-intl/server"; +import { getServerSession } from "next-auth"; +import { redirect } from "next/navigation"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; import { UserDashboard } from "@/components/dashboard/user-dashboard"; import type { Metadata } from "next"; @@ -23,6 +26,17 @@ export async function generateMetadata({ }; } -export default function DashboardPage() { +export default async function DashboardPage({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + const session = await getServerSession(authOptions); + + if (!session) { + redirect(`/${locale}/auth/sign-in?callbackUrl=/${locale}/dashboard`); + } + return ; } diff --git a/app-next/src/app/api/(dev)/debug-env/route.ts b/app-next/src/app/api/(dev)/debug-env/route.ts new file mode 100644 index 00000000..2765a06f --- /dev/null +++ b/app-next/src/app/api/(dev)/debug-env/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; + +/** + * DEBUG ENDPOINT - Remove in production! + * Shows server-side environment variables + */ +export async function GET() { + // Only allow in development/testing + if (process.env.NODE_ENV === "production") { + return NextResponse.json({ error: "Not available in production" }, { status: 403 }); + } + + const serverEnv = { + // Database + MYSQL_HOST: process.env.MYSQL_HOST || "(not set)", + MYSQL_PORT: process.env.MYSQL_PORT || "(not set)", + MYSQL_DATABASE: process.env.MYSQL_DATABASE || "(not set)", + MYSQL_USER: process.env.MYSQL_USER || "(not set)", + MYSQL_PASSWORD: process.env.MYSQL_PASSWORD ? "***SET***" : "(not set)", + + // GitHub + GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID || "(not set)", + GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET ? "***SET***" : "(not set)", + + // Other secrets + JWT_SECRET: process.env.JWT_SECRET ? "***SET***" : "(not set)", + + // Public URLs (for comparison) + NEXT_PUBLIC_OPENML_API_URL: process.env.NEXT_PUBLIC_OPENML_API_URL || "(not set)", + NEXT_PUBLIC_ELASTICSEARCH_URL: process.env.NEXT_PUBLIC_ELASTICSEARCH_URL || "(not set)", + }; + + return NextResponse.json({ + message: "Server-side environment variables", + env: serverEnv, + }); +} diff --git a/app-next/src/app/api/auth/[...nextauth]/route.ts b/app-next/src/app/api/auth/[...nextauth]/route.ts index 64eaf242..4179f592 100644 --- a/app-next/src/app/api/auth/[...nextauth]/route.ts +++ b/app-next/src/app/api/auth/[...nextauth]/route.ts @@ -65,8 +65,6 @@ export const authOptions: NextAuthOptions = { try { // Direct database authentication - bypasses Flask - console.log("[Auth] Direct DB login for:", credentials.email); - // Find user by email or username // Query only columns guaranteed to exist in the legacy schema const user = await queryOne( @@ -75,7 +73,6 @@ export const authOptions: NextAuthOptions = { ); if (!user) { - console.log("[Auth] User not found:", credentials.email); return null; } @@ -83,7 +80,6 @@ export const authOptions: NextAuthOptions = { // Check if user is active if (!dbUser.active) { - console.log("[Auth] User not activated:", credentials.email); return null; } @@ -94,12 +90,9 @@ export const authOptions: NextAuthOptions = { ); if (!isValid) { - console.log("[Auth] Invalid password for:", credentials.email); return null; } - console.log("[Auth] Login successful for:", dbUser.username); - // Try to get session_hash (API key) if column exists let sessionHash: string | null = null; try { @@ -112,6 +105,36 @@ export const authOptions: NextAuthOptions = { // session_hash column may not exist in all deployments } + // Resolve real OpenML user ID from API key (handles local dev ID mismatch) + let openmlUserId: string | undefined; + if (sessionHash) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 4000); + try { + const openmlApiUrl = + process.env.OPENML_API_URL || "https://www.openml.org"; + // Try /user/whoami first, fall back to /user/data + for (const path of ["/api/v1/json/user/whoami", "/api/v1/json/user/data"]) { + const res = await fetch( + `${openmlApiUrl}${path}?api_key=${encodeURIComponent(sessionHash)}`, + { signal: controller.signal }, + ); + if (res.ok) { + const data = await res.json(); + const rawId = data?.user?.id ?? data?.id; + if (rawId != null) { + openmlUserId = String(rawId); + break; + } + } + } + } catch { + // Non-critical: fall back to local DB ID for ownership checks + } finally { + clearTimeout(timeoutId); + } + } + // Return user object return { id: dbUser.id.toString(), @@ -123,6 +146,7 @@ export const authOptions: NextAuthOptions = { dbUser.image && dbUser.image !== "0000" ? dbUser.image : null, username: dbUser.username, session_hash: sessionHash, + openmlUserId, }; } catch (error) { console.error("Login error:", error); @@ -309,7 +333,7 @@ export const authOptions: NextAuthOptions = { user.session_hash = dbUser.session_hash || null; } // Mark as local user (OAuth users don't exist on openml.org) - (user as any).isLocalUser = true; + user.isLocalUser = true; return true; } catch (error) { console.error("SignIn Callback Error:", error); @@ -336,7 +360,8 @@ export const authOptions: NextAuthOptions = { token.firstName = user.firstName; token.lastName = user.lastName; token.picture = user.image; - token.isLocalUser = (user as any).isLocalUser || false; + token.isLocalUser = user.isLocalUser || false; + token.openmlUserId = user.openmlUserId; } return token; @@ -352,14 +377,18 @@ export const authOptions: NextAuthOptions = { session.user.lastName = token.lastName; // Add API key to session for likes/votes if (token.apikey) { - session.apikey = token.apikey as string; + session.apikey = token.apikey; } // Add profile image to session if (token.picture) { - session.user.image = token.picture as string; + session.user.image = token.picture; } // Mark if user is local-only (not from openml.org) - (session.user as any).isLocalUser = token.isLocalUser || false; + session.user.isLocalUser = token.isLocalUser || false; + // Real OpenML user ID (may differ from local DB ID in dev environments) + if (token.openmlUserId) { + session.user.openmlUserId = token.openmlUserId; + } } return session; }, diff --git a/app-next/src/app/api/collections/create/route.ts b/app-next/src/app/api/collections/create/route.ts new file mode 100644 index 00000000..d9d53416 --- /dev/null +++ b/app-next/src/app/api/collections/create/route.ts @@ -0,0 +1,150 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { sendCreationConfirmationEmail } from "@/lib/mail"; + +const OPENML_API = + process.env.OPENML_API_URL || + process.env.NEXT_PUBLIC_OPENML_API_URL || + "https://www.openml.org"; + +function buildStudyXml(fields: { + name: string; + description?: string; + mainEntityType: "task" | "run"; + taskIds?: number[]; + runIds?: number[]; +}): string { + const esc = (s: string) => + s.replace(/&/g, "&").replace(//g, ">"); + + // OpenML uses `alias` as the human-readable name; must be URL-safe + const alias = fields.name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); + + const taskSection = fields.taskIds?.length + ? ` \n` + + fields.taskIds.map((id) => ` ${id}\n`).join("") + + ` \n` + : ""; + + const runSection = fields.runIds?.length + ? ` \n` + + fields.runIds.map((id) => ` ${id}\n`).join("") + + ` \n` + : ""; + + return ( + `\n` + + `\n` + + ` ${esc(alias)}\n` + + ` ${fields.mainEntityType}\n` + + ` ${esc(fields.name)}\n` + + (fields.description + ? ` ${esc(fields.description)}\n` + : "") + + taskSection + + runSection + + `` + ); +} + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const apiKey = (session as { apikey?: string }).apikey; + if (!apiKey) { + return NextResponse.json( + { error: "No API key found. Please re-sign in." }, + { status: 401 }, + ); + } + + const body = await request.json(); + const { collectionname, description, taskids, runids, collectiontype } = body; + + if (!collectionname || (!taskids && !runids)) { + return NextResponse.json( + { error: "collectionname and at least one task or run ID are required." }, + { status: 400 }, + ); + } + + const parseIds = (raw: string | undefined) => + raw + ? String(raw).split(/[\s,]+/).map((s) => parseInt(s.trim())).filter((n) => !isNaN(n)) + : []; + + const taskIdList = parseIds(taskids); + const runIdList = parseIds(runids); + + if (taskIdList.length === 0 && runIdList.length === 0) { + return NextResponse.json( + { error: "No valid task or run IDs provided." }, + { status: 400 }, + ); + } + + const xml = buildStudyXml({ + name: collectionname, + description: description || undefined, + mainEntityType: collectiontype === "runs" ? "run" : "task", + taskIds: taskIdList.length ? taskIdList : undefined, + runIds: runIdList.length ? runIdList : undefined, + }); + + const openmlForm = new FormData(); + openmlForm.append("api_key", apiKey); + openmlForm.append( + "description", + new Blob([xml], { type: "text/xml" }), + "description.xml", + ); + + const response = await fetch(`${OPENML_API}/api/v1/study`, { + method: "POST", + body: openmlForm, + }); + + if (!response.ok) { + const text = await response.text(); + console.error("OpenML study create error:", text); + let message = "Failed to create collection. Please try again."; + if (response.status === 401 || response.status === 403) { + message = "Your API key was rejected."; + } else { + const msgMatch = text.match(/([^<]+)<\/oml:message>/); + const infoMatch = text.match(/([^<]+)<\/oml:additional_information>/); + if (msgMatch) message = msgMatch[1].trim(); + if (infoMatch) message += ` β€” ${infoMatch[1].trim()}`; + } + return NextResponse.json({ error: message }, { status: response.status }); + } + + const text = await response.text(); + const idMatch = text.match(/(\d+)<\/oml:id>/); + const studyId: string = idMatch ? idMatch[1] : "new"; + + if (session.user.email) { + sendCreationConfirmationEmail( + session.user.email, + "collection", + collectionname, + studyId, + ).catch((err: unknown) => + console.error("Failed to send collection creation email:", err), + ); + } + + return NextResponse.json({ success: true, id: studyId }); + } catch (error) { + console.error("Collection create error:", error); + return NextResponse.json( + { error: "Failed to create collection." }, + { status: 500 }, + ); + } +} diff --git a/app-next/src/app/api/count/route.ts b/app-next/src/app/api/count/route.ts index 469b6bd4..167889ae 100644 --- a/app-next/src/app/api/count/route.ts +++ b/app-next/src/app/api/count/route.ts @@ -11,9 +11,6 @@ export async function GET() { (i) => i !== "user" && i !== "benchmark", ); - // console.log("πŸ” [Count API] Elasticsearch URL:", elasticsearchEndpoint); - // console.log("πŸ“¦ [Count API] Indices:", indices); - // Build NDJSON body for _msearch - correct format // For datasets (data index), only count active ones per team leader request let requestBody = ""; @@ -44,16 +41,11 @@ export async function GET() { const startTime = Date.now(); try { - // console.log("⏳ [Count API] Sending request..."); - const response = await axios.post(elasticsearchEndpoint, requestBody, { headers: { "Content-Type": "application/x-ndjson" }, timeout: 30000, // 30 second timeout }); - const duration = Date.now() - startTime; - // console.log(`βœ… [Count API] Success in ${duration}ms`); - // Extract counts safely const allLabels = [...indices, ...extraLabels]; const counts = response.data.responses.map((r: any, i: number) => ({ @@ -62,7 +54,6 @@ export async function GET() { typeof r.hits.total === "number" ? r.hits.total : r.hits.total.value, })); - // console.log("πŸ“Š [Count API] Counts:", counts); return NextResponse.json(counts); } catch (error) { const duration = Date.now() - startTime; diff --git a/app-next/src/app/api/datasets/[id]/edit/route.ts b/app-next/src/app/api/datasets/[id]/edit/route.ts index d73b8bb6..38b6fdb8 100644 --- a/app-next/src/app/api/datasets/[id]/edit/route.ts +++ b/app-next/src/app/api/datasets/[id]/edit/route.ts @@ -1,9 +1,13 @@ import { NextRequest, NextResponse } from "next/server"; import { getServerSession } from "next-auth"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { sendDatasetEditEmail } from "@/lib/mail"; +import { APP_CONFIG } from "@/lib/config"; const OPENML_API = - process.env.FLASK_BACKEND_URL || "https://www.openml.org"; + process.env.OPENML_API_URL || + APP_CONFIG.openmlApiUrl || + "https://www.openml.org"; export async function POST( request: NextRequest, @@ -27,11 +31,10 @@ export async function POST( const body = await request.json(); - // Build form data for OpenML REST API - const formData = new URLSearchParams(); - formData.append("api_key", apiKey); + // Build XML for OpenML edit_parameters (required by the API) + const xmlFields: string[] = []; - const fields = [ + const textFields = [ "description", "creator", "collection_date", @@ -42,39 +45,54 @@ export async function POST( ]; // Owner-only fields - if (body.isOwner) { - fields.push( - "default_target_attribute", - "ignore_attribute", - "row_id_attribute", - ); - } + const ownerFields = body.isOwner + ? ["default_target_attribute", "ignore_attribute", "row_id_attribute"] + : []; - for (const field of fields) { + for (const field of [...textFields, ...ownerFields]) { if (body[field] !== undefined) { - // Send empty string as-is (the API will clear the field) - formData.append(field, body[field] || ""); + const value = (body[field] || "").toString().replace(/&/g, "&").replace(//g, ">"); + xmlFields.push(` ${value}`); } } + const editParametersXml = + `\n` + + `\n` + + xmlFields.join("\n") + + `\n`; + + const formData = new URLSearchParams(); + formData.append("api_key", apiKey); + formData.append("edit_parameters", editParametersXml); + try { - const response = await fetch( - `${OPENML_API}/api/v1/json/data/${id}`, - { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: formData.toString(), + const response = await fetch(`${OPENML_API}/api/v1/json/data/edit/${id}`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", }, - ); + body: formData.toString(), + }); if (!response.ok) { const text = await response.text(); console.error(`OpenML API error editing dataset ${id}:`, text); - return NextResponse.json( - { error: "Failed to save changes. Please try again." }, - { status: response.status }, + const message = + response.status === 401 || response.status === 403 + ? "Your API key is not accepted by the OpenML server. If you are using a local test account, dataset editing is not supported β€” only real OpenML accounts can save changes." + : response.status === 412 + ? "The OpenML server rejected this edit. You can only edit datasets you own." + : "Failed to save changes. Please try again."; + return NextResponse.json({ error: message }, { status: response.status }); + } + + // Send email notification upon successful edit + if (session.user?.email) { + // We don't always have the name in the body, so we use ID as fallback + const datasetName = body.name || `Dataset ${id}`; + await sendDatasetEditEmail(session.user.email, datasetName, id).catch( + (err) => console.error("Failed to send edit email:", err), ); } diff --git a/app-next/src/app/api/datasets/[id]/stats/route.ts b/app-next/src/app/api/datasets/[id]/stats/route.ts new file mode 100644 index 00000000..8a3d9e5f --- /dev/null +++ b/app-next/src/app/api/datasets/[id]/stats/route.ts @@ -0,0 +1,87 @@ +import { NextRequest, NextResponse } from "next/server"; + +const FLASK_BACKEND_URL = + process.env.FLASK_BACKEND_URL || "http://localhost:5000"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id: datasetId } = await params; + const searchParams = request.nextUrl.searchParams; + const maxPreviewRows = searchParams.get("max_preview_rows") || "100"; + const forceRefresh = searchParams.get("force_refresh") || "false"; + + const controller = new AbortController(); + // Flask needs time to download + process large datasets from OpenML + const timeoutId = setTimeout(() => controller.abort(), 120_000); + + try { + const flaskUrl = `${FLASK_BACKEND_URL}/api/v1/datasets/${datasetId}/stats?max_preview_rows=${maxPreviewRows}&force_refresh=${forceRefresh}`; + + const response = await fetch(flaskUrl, { + headers: { + "Accept": "application/json", + }, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + const contentType = response.headers.get("content-type"); + + // If Flask returned XML error (OpenML production server) + if (contentType?.includes("xml")) { + console.error(`[Stats API] Flask not available at ${FLASK_BACKEND_URL}. Is Flask running locally?`); + return NextResponse.json( + { + error: `Stats API not available. Flask may not be running at ${FLASK_BACKEND_URL}. Start Flask with: cd server && python app.py`, + }, + { status: 503 } + ); + } + + const errorText = await response.text(); + console.error(`[Stats API] Flask error:`, errorText); + return NextResponse.json( + { error: `Flask error: ${errorText}` }, + { status: response.status } + ); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch (error) { + clearTimeout(timeoutId); + console.error("[Stats API] Failed to fetch stats from Flask:", error); + + // Timeout β€” dataset too large or Flask download stalled + if (error instanceof Error && error.name === "AbortError") { + return NextResponse.json( + { error: "Stats computation timed out. The dataset may be too large to process." }, + { status: 504 } + ); + } + + // Network error - Flask likely not running + if (error instanceof TypeError && error.message.includes("fetch")) { + return NextResponse.json( + { + error: `Cannot connect to Flask at ${FLASK_BACKEND_URL}. Is Flask running? Start with: cd server && python app.py`, + }, + { status: 503 } + ); + } + + return NextResponse.json( + { + error: + error instanceof Error + ? error.message + : "Failed to fetch dataset statistics", + }, + { status: 500 } + ); + } +} diff --git a/app-next/src/app/api/datasets/[id]/tags/route.ts b/app-next/src/app/api/datasets/[id]/tags/route.ts new file mode 100644 index 00000000..93943d8d --- /dev/null +++ b/app-next/src/app/api/datasets/[id]/tags/route.ts @@ -0,0 +1,85 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { APP_CONFIG } from "@/lib/config"; + +const OPENML_API = + process.env.OPENML_API_URL || + APP_CONFIG.openmlApiUrl || + "https://www.openml.org"; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const apiKey = (session as { apikey?: string }).apikey; + if (!apiKey) { + return NextResponse.json({ error: "No API key found." }, { status: 401 }); + } + + const { tag } = await request.json(); + if (!tag) { + return NextResponse.json({ error: "tag is required" }, { status: 400 }); + } + + const form = new URLSearchParams({ api_key: apiKey, data_id: id, tag }); + const response = await fetch(`${OPENML_API}/api/v1/json/data/tag`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: form.toString(), + }); + + if (!response.ok) { + const text = await response.text(); + return NextResponse.json( + { error: `Failed to add tag: ${text.slice(0, 200)}` }, + { status: response.status }, + ); + } + + return NextResponse.json({ success: true }); +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const apiKey = (session as { apikey?: string }).apikey; + if (!apiKey) { + return NextResponse.json({ error: "No API key found." }, { status: 401 }); + } + + const { tag } = await request.json(); + if (!tag) { + return NextResponse.json({ error: "tag is required" }, { status: 400 }); + } + + const form = new URLSearchParams({ api_key: apiKey, data_id: id, tag }); + const response = await fetch(`${OPENML_API}/api/v1/json/data/untag`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: form.toString(), + }); + + if (!response.ok) { + const text = await response.text(); + return NextResponse.json( + { error: `Failed to remove tag: ${text.slice(0, 200)}` }, + { status: response.status }, + ); + } + + return NextResponse.json({ success: true }); +} diff --git a/app-next/src/app/api/datasets/upload/route.ts b/app-next/src/app/api/datasets/upload/route.ts new file mode 100644 index 00000000..0bd57820 --- /dev/null +++ b/app-next/src/app/api/datasets/upload/route.ts @@ -0,0 +1,134 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { sendDatasetUploadEmail, sendCreationConfirmationEmail } from "@/lib/mail"; + +import { APP_CONFIG } from "@/lib/config"; + +const FLASK_BACKEND_URL = + process.env.FLASK_BACKEND_URL || "http://localhost:5000"; + +const OPENML_API = + process.env.OPENML_API_URL || + APP_CONFIG.openmlApiUrl || + "https://www.openml.org"; + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const apiKey = (session as { apikey?: string }).apikey; + if (!apiKey) { + return NextResponse.json( + { error: "No API key found. Please re-sign in." }, + { status: 401 }, + ); + } + + const formData = await request.formData(); + const file = formData.get("file") as File | null; + const name = formData.get("name")?.toString() || ""; + + if (!file || !name) { + return NextResponse.json( + { error: "File and name are required." }, + { status: 400 }, + ); + } + + // Build metadata JSON as Flask expects + const metadata = { + dataset_name: name, + description: formData.get("description")?.toString() || "", + creator: formData.get("creator")?.toString() || "", + contributor: formData.get("contributor")?.toString() || "", + collection_date: formData.get("collection_date")?.toString() || "", + licence: formData.get("licence")?.toString() || "", + language: formData.get("language")?.toString() || "", + def_tar_att: formData.get("default_target_attribute")?.toString() || "", + ignore_attribute: formData.get("ignore_attribute")?.toString() || "", + citation: formData.get("citation")?.toString() || "", + }; + + const flaskForm = new FormData(); + flaskForm.append("api_key", apiKey); + flaskForm.append("dataset", file, file.name); + flaskForm.append( + "metadata", + new Blob([JSON.stringify(metadata)], { type: "application/json" }), + "metadata.json", + ); + + const response = await fetch(`${FLASK_BACKEND_URL}/data-upload`, { + method: "POST", + body: flaskForm, + }); + + if (!response.ok) { + const text = await response.text(); + console.error("Flask upload error:", text); + let errorMessage = "Upload failed. Please try again."; + try { + const errorJson = JSON.parse(text); + if (errorJson.msg) errorMessage = errorJson.msg; + else if (errorJson.error) errorMessage = errorJson.error; + } catch { + // text is not JSON β€” use as-is if it's short enough + if (text && text.length < 300) errorMessage = text; + } + return NextResponse.json( + { error: errorMessage }, + { status: response.status }, + ); + } + + const result = await response.json(); + const datasetId: string = result.id ?? "new"; + + // Apply tags if provided and we have a real dataset ID + const tagsRaw = formData.get("tags")?.toString() || ""; + if (tagsRaw && datasetId !== "new") { + const tagList = tagsRaw + .split(",") + .map((t) => t.trim()) + .filter(Boolean); + for (const tag of tagList) { + const tagForm = new URLSearchParams({ api_key: apiKey, data_id: datasetId, tag }); + fetch(`${OPENML_API}/api/v1/json/data/tag`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: tagForm.toString(), + }).catch((err) => console.error(`Failed to apply tag "${tag}":`, err)); + } + } + + if (session.user.email) { + sendDatasetUploadEmail( + session.user.name || session.user.email, + name, + datasetId, + ).catch((err: unknown) => + console.error("Failed to send admin upload email:", err), + ); + sendCreationConfirmationEmail( + session.user.email, + "dataset", + name, + datasetId, + ).catch((err: unknown) => + console.error("Failed to send uploader confirmation email:", err), + ); + } + + return NextResponse.json({ success: true, id: datasetId }); + } catch (error) { + console.error("Upload error:", error); + return NextResponse.json( + { error: "Failed to upload dataset." }, + { status: 500 }, + ); + } +} diff --git a/app-next/src/app/api/es-proxy/route.ts b/app-next/src/app/api/es-proxy/route.ts index 329ffed3..295aa902 100644 --- a/app-next/src/app/api/es-proxy/route.ts +++ b/app-next/src/app/api/es-proxy/route.ts @@ -9,8 +9,6 @@ export async function POST(req: NextRequest) { const body = await req.json(); const { indexName, esQuery } = body; - // console.log("πŸ” [ES Proxy] Request for index:", indexName); - if (!indexName || !esQuery) { return NextResponse.json( { error: "Missing indexName or esQuery" }, @@ -19,18 +17,12 @@ export async function POST(req: NextRequest) { } const url = getElasticsearchUrl(`${indexName}/_search`); - // console.log("⏳ [ES Proxy] Sending to:", url); const response = await axios.post(url, esQuery, { headers: { "Content-Type": "application/json" }, timeout: 30000, // 30 second timeout }); - const duration = Date.now() - startTime; - // console.log( - // `βœ… [ES Proxy] Success in ${duration}ms - ${response.data.hits?.total?.value || 0} results`, - // ); - return NextResponse.json(response.data); } catch (error: unknown) { const duration = Date.now() - startTime; diff --git a/app-next/src/app/api/search/route.ts b/app-next/src/app/api/search/route.ts index fab9e4c1..75f9ccfc 100644 --- a/app-next/src/app/api/search/route.ts +++ b/app-next/src/app/api/search/route.ts @@ -44,12 +44,24 @@ export async function POST(req: NextRequest) { const { indexName, esQuery } = body; const url = getElasticsearchUrl(`${indexName}/_search`); - const response = await axios.post(url, esQuery, { + // Use fetch instead of axios (matches original MeasureList pattern) + const response = await fetch(url, { + method: "POST", headers: { "Content-Type": "application/json" }, - timeout: 30000, + body: JSON.stringify(esQuery), }); - return NextResponse.json(response.data); + if (!response.ok) { + const errorText = await response.text(); + console.error(`[Search API] ES Error:`, errorText); + throw new Error( + `Elasticsearch returned ${response.status}: ${errorText}`, + ); + } + + const data = await response.json(); + + return NextResponse.json(data); } // Case 3: Raw multi-search or other requests (fallback) @@ -61,10 +73,20 @@ export async function POST(req: NextRequest) { const duration = Date.now() - startTime; console.error(`❌ [Search API] Failed after ${duration}ms:`, error.message); + // Log full Elasticsearch error details + if (error.response) { + console.error(`[Search API] ES Error Status:`, error.response.status); + console.error( + `[Search API] ES Error Data:`, + JSON.stringify(error.response.data, null, 2), + ); + } + return NextResponse.json( { error: "Search failed", details: error.message, + esError: error.response?.data, }, { status: error.response?.status || 500 }, ); diff --git a/app-next/src/app/api/study/[id]/datasets/route.ts b/app-next/src/app/api/study/[id]/datasets/route.ts new file mode 100644 index 00000000..8fc8830d --- /dev/null +++ b/app-next/src/app/api/study/[id]/datasets/route.ts @@ -0,0 +1,91 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getElasticsearchUrl } from "@/lib/elasticsearch"; + +/** + * GET /api/study/:id/datasets?page=1&limit=20&q=search + * + * Fetches the dataset IDs from the OpenML REST API for this study, + * then returns a paginated slice from Elasticsearch with full details. + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + const searchParams = request.nextUrl.searchParams; + const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10)); + const limit = Math.min(100, Math.max(1, parseInt(searchParams.get("limit") || "20", 10))); + const query = searchParams.get("q") || ""; + const sortField = searchParams.get("sort") || "runs"; + const sortDir = searchParams.get("dir") || "desc"; + + // 1. Fetch study member IDs from REST API (cached) + const studyRes = await fetch( + `https://www.openml.org/api/v1/json/study/${id}`, + { next: { revalidate: 3600 } }, + ); + + if (!studyRes.ok) { + return NextResponse.json( + { error: "Study not found" }, + { status: 404 }, + ); + } + + const studyJson = await studyRes.json(); + const allIds: string[] = studyJson.study?.data?.data_id || []; + + if (allIds.length === 0) { + return NextResponse.json({ results: [], total: 0, page, limit }); + } + + // 2. Build ES query β€” filter by IDs + optional text search + const must: Record[] = [ + { ids: { values: allIds } }, + ]; + if (query) { + must.push({ + multi_match: { + query, + fields: ["name^3", "description"], + type: "best_fields", + }, + }); + } + + const esUrl = getElasticsearchUrl("data/_search"); + const esRes = await fetch(esUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: { bool: { must } }, + _source: [ + "data_id", "name", "version", "description", "format", "status", "date", + "qualities.NumberOfInstances", "qualities.NumberOfFeatures", + "qualities.NumberOfClasses", + "runs", "nr_of_likes", "nr_of_downloads", "uploader", + ], + from: (page - 1) * limit, + size: limit, + sort: query + ? [{ _score: { order: "desc" } }] + : [{ [sortField]: { order: sortDir } }], + }), + next: { revalidate: 300 }, + }); + + if (!esRes.ok) { + return NextResponse.json( + { error: "Failed to fetch datasets" }, + { status: 500 }, + ); + } + + const esData = await esRes.json(); + const results = (esData.hits?.hits || []).map( + (hit: { _source: Record }) => hit._source, + ); + const total = esData.hits?.total?.value ?? esData.hits?.total ?? 0; + + return NextResponse.json({ results, total, page, limit }); +} diff --git a/app-next/src/app/api/study/[id]/tasks/route.ts b/app-next/src/app/api/study/[id]/tasks/route.ts new file mode 100644 index 00000000..412577ff --- /dev/null +++ b/app-next/src/app/api/study/[id]/tasks/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getElasticsearchUrl } from "@/lib/elasticsearch"; + +/** + * GET /api/study/:id/tasks?page=1&limit=20&q=search + * + * Fetches the task IDs from the OpenML REST API for this study, + * then returns a paginated slice from Elasticsearch with full details. + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + const searchParams = request.nextUrl.searchParams; + const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10)); + const limit = Math.min(100, Math.max(1, parseInt(searchParams.get("limit") || "20", 10))); + const query = searchParams.get("q") || ""; + const sortField = searchParams.get("sort") || "runs"; + const sortDir = searchParams.get("dir") || "desc"; + + // 1. Fetch study member IDs from REST API (cached) + const studyRes = await fetch( + `https://www.openml.org/api/v1/json/study/${id}`, + { next: { revalidate: 3600 } }, + ); + + if (!studyRes.ok) { + return NextResponse.json( + { error: "Study not found" }, + { status: 404 }, + ); + } + + const studyJson = await studyRes.json(); + const allIds: string[] = studyJson.study?.tasks?.task_id || []; + + if (allIds.length === 0) { + return NextResponse.json({ results: [], total: 0, page, limit }); + } + + // 2. Build ES query β€” filter by IDs + optional text search + const must: Record[] = [ + { ids: { values: allIds } }, + ]; + if (query) { + must.push({ + multi_match: { + query, + fields: ["source_data.name^3", "task_type^2"], + type: "best_fields", + }, + }); + } + + const esUrl = getElasticsearchUrl("task/_search"); + const esRes = await fetch(esUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: { bool: { must } }, + _source: [ + "task_id", "task_type_id", "task_type", + "source_data", "estimation_procedure", + "runs", "nr_of_likes", "nr_of_downloads", + ], + from: (page - 1) * limit, + size: limit, + sort: query + ? [{ _score: { order: "desc" } }] + : [{ [sortField]: { order: sortDir } }], + }), + next: { revalidate: 300 }, + }); + + if (!esRes.ok) { + return NextResponse.json( + { error: "Failed to fetch tasks" }, + { status: 500 }, + ); + } + + const esData = await esRes.json(); + const results = (esData.hits?.hits || []).map( + (hit: { _source: Record }) => hit._source, + ); + const total = esData.hits?.total?.value ?? esData.hits?.total ?? 0; + + return NextResponse.json({ results, total, page, limit }); +} diff --git a/app-next/src/app/api/tasks/create/route.ts b/app-next/src/app/api/tasks/create/route.ts new file mode 100644 index 00000000..4e02f70a --- /dev/null +++ b/app-next/src/app/api/tasks/create/route.ts @@ -0,0 +1,136 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { sendCreationConfirmationEmail } from "@/lib/mail"; + +const OPENML_API = + process.env.OPENML_API_URL || + process.env.NEXT_PUBLIC_OPENML_API_URL || + "https://www.openml.org"; + +const TASK_TYPE_IDS: Record = { + classification: 1, + regression: 2, + learningcurve: 3, + clustering: 5, + supervised: 1, +}; + +function buildTaskXml(fields: { + taskTypeId: number; + datasetId: number; + targetName?: string; + estimationProcedure?: string; + evaluationMeasure?: string; +}): string { + const esc = (s: string) => + s.replace(/&/g, "&").replace(//g, ">"); + + // is a simpleType in OpenML's XSD β€” text only, no child elements + return ( + `\n` + + `\n` + + ` ${fields.taskTypeId}\n` + + ` ${fields.datasetId}\n` + + (fields.targetName + ? ` ${esc(fields.targetName)}\n` + : "") + + (fields.estimationProcedure + ? ` ${esc(fields.estimationProcedure)}\n` + : "") + + (fields.evaluationMeasure + ? ` ${esc(fields.evaluationMeasure)}\n` + : "") + + `` + ); +} + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const apiKey = (session as { apikey?: string }).apikey; + if (!apiKey) { + return NextResponse.json( + { error: "No API key found. Please re-sign in." }, + { status: 401 }, + ); + } + + const body = await request.json(); + const { task_type, dataset_id, target_name, estimation_procedure, evaluation_measure } = body; + + const isClustering = task_type === "clustering"; + if (!task_type || !dataset_id || (!target_name && !isClustering)) { + return NextResponse.json( + { error: "task_type and dataset_id are required. target_name is required for non-clustering tasks." }, + { status: 400 }, + ); + } + + const taskTypeId = TASK_TYPE_IDS[task_type] ?? 1; + const xml = buildTaskXml({ + taskTypeId, + datasetId: parseInt(dataset_id), + estimationProcedure: estimation_procedure || undefined, + targetName: target_name, + evaluationMeasure: evaluation_measure || undefined, + }); + + const openmlForm = new FormData(); + openmlForm.append("api_key", apiKey); + openmlForm.append( + "description", + new Blob([xml], { type: "text/xml" }), + "description.xml", + ); + + const response = await fetch(`${OPENML_API}/api/v1/task`, { + method: "POST", + body: openmlForm, + }); + + if (!response.ok) { + const text = await response.text(); + console.error("OpenML task create error:", text); + let message = "Failed to create task. Please try again."; + if (response.status === 401 || response.status === 403) { + message = "Your API key was rejected."; + } else { + // Parse OpenML XML error: and + const msgMatch = text.match(/([^<]+)<\/oml:message>/); + const infoMatch = text.match(/([^<]+)<\/oml:additional_information>/); + if (msgMatch) message = msgMatch[1].trim(); + if (infoMatch) message += ` β€” ${infoMatch[1].trim()}`; + } + return NextResponse.json({ error: message }, { status: response.status }); + } + + const text = await response.text(); + const idMatch = text.match(/(\d+)<\/oml:id>/); + const taskId: string = idMatch ? idMatch[1] : "new"; + const taskName = `Task for dataset ${dataset_id} (${task_type})`; + + if (session.user.email) { + sendCreationConfirmationEmail( + session.user.email, + "task", + taskName, + taskId, + ).catch((err: unknown) => + console.error("Failed to send task creation email:", err), + ); + } + + return NextResponse.json({ success: true, id: taskId }); + } catch (error) { + console.error("Task create error:", error); + return NextResponse.json( + { error: "Failed to create task." }, + { status: 500 }, + ); + } +} diff --git a/app-next/src/app/api/user/[id]/datasets/route.ts b/app-next/src/app/api/user/[id]/datasets/route.ts index bb1b6045..3cedc977 100644 --- a/app-next/src/app/api/user/[id]/datasets/route.ts +++ b/app-next/src/app/api/user/[id]/datasets/route.ts @@ -17,10 +17,15 @@ export async function GET( const { searchParams } = new URL(req.url); const page = parseInt(searchParams.get("page") || "1"); const size = parseInt(searchParams.get("size") || "10"); + const sort = searchParams.get("sort") || "date_desc"; - // console.log( - // `πŸ” [User Datasets API] Fetching datasets for user ${id}, page ${page}`, - // ); + const sortMap: Record = { + date_desc: [{ date: { order: "desc" } }], + runs_desc: [{ runs: { order: "desc" } }], + likes_desc: [{ nr_of_likes: { order: "desc" } }], + downloads_desc: [{ nr_of_downloads: { order: "desc" } }], + name_asc: [{ "name.keyword": { order: "asc" } }], + }; // Query ElasticSearch for datasets by uploader_id const esQuery = { @@ -29,7 +34,7 @@ export async function GET( uploader_id: id, }, }, - sort: [{ date: { order: "desc" } }], + sort: sortMap[sort] ?? sortMap["date_desc"], from: (page - 1) * size, size: size, }; @@ -41,12 +46,11 @@ export async function GET( }); const hits = (response.data.hits?.hits || []) as ElasticsearchHit[]; - const total = response.data.hits?.total?.value || 0; + const totalHits = response.data.hits?.total; + const total = + typeof totalHits === "object" ? totalHits.value : totalHits || 0; const datasets = hits.map((hit) => hit._source); - // console.log( - // `βœ… [User Datasets API] Found ${datasets.length} datasets (${total} total)`, - // ); return NextResponse.json({ datasets, diff --git a/app-next/src/app/api/user/[id]/flows/route.ts b/app-next/src/app/api/user/[id]/flows/route.ts index 404a82ad..0464ab59 100644 --- a/app-next/src/app/api/user/[id]/flows/route.ts +++ b/app-next/src/app/api/user/[id]/flows/route.ts @@ -17,10 +17,15 @@ export async function GET( const { searchParams } = new URL(req.url); const page = parseInt(searchParams.get("page") || "1"); const size = parseInt(searchParams.get("size") || "10"); + const sort = searchParams.get("sort") || "date_desc"; - // console.log( - // `πŸ” [User Flows API] Fetching flows for user ${id}, page ${page}`, - // ); + const sortMap: Record = { + date_desc: [{ date: { order: "desc" } }], + runs_desc: [{ runs: { order: "desc" } }], + likes_desc: [{ nr_of_likes: { order: "desc" } }], + downloads_desc: [{ nr_of_downloads: { order: "desc" } }], + name_asc: [{ "name.keyword": { order: "asc" } }], + }; // Query ElasticSearch for flows by uploader_id const esQuery = { @@ -29,7 +34,7 @@ export async function GET( uploader_id: id, }, }, - sort: [{ date: { order: "desc" } }], + sort: sortMap[sort] ?? sortMap["date_desc"], from: (page - 1) * size, size: size, }; @@ -41,14 +46,12 @@ export async function GET( }); const hits = (response.data.hits?.hits || []) as ElasticsearchHit[]; - const total = response.data.hits?.total?.value || 0; + const totalHits = response.data.hits?.total; + const total = + typeof totalHits === "object" ? totalHits.value : totalHits || 0; const flows = hits.map((hit) => hit._source); - // console.log( - // `βœ… [User Flows API] Found ${flows.length} flows (${total} total)`, - // ); - return NextResponse.json({ flows, total, diff --git a/app-next/src/app/api/user/[id]/route.ts b/app-next/src/app/api/user/[id]/route.ts index 8cd82476..964f3c7d 100644 --- a/app-next/src/app/api/user/[id]/route.ts +++ b/app-next/src/app/api/user/[id]/route.ts @@ -10,7 +10,6 @@ export async function GET( ) { try { const { id } = await params; - // console.log("πŸ” [User API] Fetching user:", id); // Query ElasticSearch for user by ID const esQuery = { @@ -31,12 +30,10 @@ export async function GET( const hits = response.data.hits?.hits || []; if (hits.length === 0) { - // console.log("❌ [User API] User not found:", id); return NextResponse.json({ error: "User not found" }, { status: 404 }); } const user = hits[0]._source; - // console.log("βœ… [User API] User found:", user.username); return NextResponse.json(user); } catch (error) { diff --git a/app-next/src/app/api/user/[id]/tasks/route.ts b/app-next/src/app/api/user/[id]/tasks/route.ts index 62e48863..fafe81c6 100644 --- a/app-next/src/app/api/user/[id]/tasks/route.ts +++ b/app-next/src/app/api/user/[id]/tasks/route.ts @@ -17,20 +17,13 @@ export async function GET( const { searchParams } = new URL(req.url); const page = parseInt(searchParams.get("page") || "1"); const size = parseInt(searchParams.get("size") || "10"); + const sort = searchParams.get("sort") || "date_desc"; - // Query ElasticSearch for tasks by uploader_id (or creator, usually uploader_id for tasks) - // Note: In OpenML ES, tasks usually have `creator` or `uploader` field. - // Based on datasets route using `uploader_id`, we will try `creator` first as tasks are often created by system/users. - // Actually, let's check what the UserProfile page implementation expects. - // It says "tasks_uploaded" stats. - // Most reliable field for ownership in OpenML ES is usually `creator`. - // However, I will check if `uploader_id` exists on tasks. - // I'll stick to `uploader_id` as it was used for datasets, but if it fails I might need to switch. - // Let's stick to the pattern `uploader_id` for now as it is standard across OpenML ES types usually. - - // UPDATE: Task index uses `creator` often for the user ID in text, but let's check `uploader_id`. - // Actually, looking at previous Task types, we saw `uploader` (string) and `uploader_id` (number? or not present?). - // I will try `uploader_id` first. + const sortMap: Record = { + date_desc: [{ date: { order: "desc" } }], + runs_desc: [{ runs: { order: "desc" } }], + name_asc: [{ "name.keyword": { order: "asc" } }], + }; const esQuery = { query: { @@ -38,7 +31,7 @@ export async function GET( uploader_id: id, }, }, - sort: [{ date: { order: "desc" } }], + sort: sortMap[sort] ?? sortMap["date_desc"], from: (page - 1) * size, size: size, }; @@ -50,7 +43,9 @@ export async function GET( }); const hits = (response.data.hits?.hits || []) as ElasticsearchHit[]; - const total = response.data.hits?.total?.value || 0; + const totalHits = response.data.hits?.total; + const total = + typeof totalHits === "object" ? totalHits.value : totalHits || 0; const tasks = hits.map((hit) => hit._source); diff --git a/app-next/src/app/api/user/api-key/route.ts b/app-next/src/app/api/user/api-key/route.ts index b942f8a9..7daa167e 100644 --- a/app-next/src/app/api/user/api-key/route.ts +++ b/app-next/src/app/api/user/api-key/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { APP_CONFIG } from "@/lib/config"; /** * API Route: GET /api/user/api-key @@ -24,7 +25,7 @@ export async function GET(request: NextRequest) { // Use 127.0.0.1 instead of localhost for more reliable resolution const localApiUrl = "http://127.0.0.1:8000"; const prodApiUrl = - process.env.NEXT_PUBLIC_OPENML_URL || "https://www.openml.org"; + APP_CONFIG.openmlApiUrl || "https://www.openml.org"; // Try local first, then production const urlsToTry = [localApiUrl, prodApiUrl]; diff --git a/app-next/src/app/api/user/profile/route.ts b/app-next/src/app/api/user/profile/route.ts index 07aa0720..12faac7a 100644 --- a/app-next/src/app/api/user/profile/route.ts +++ b/app-next/src/app/api/user/profile/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getServerSession } from "next-auth"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; import { execute, queryOne } from "@/lib/db"; +import { sendProfileUpdateEmail } from "@/lib/mail"; interface UserProfile { first_name: string; @@ -87,6 +88,14 @@ export async function POST(request: NextRequest) { ], ); + // Send notification email + const targetEmail = email || session.user.email; + const targetName = firstName || session.user.name || "User"; + + if (targetEmail) { + await sendProfileUpdateEmail(targetEmail, targetName); + } + return NextResponse.json({ success: true, message: "Profile updated successfully", diff --git a/app-next/src/components/auth/profile-settings.tsx b/app-next/src/components/auth/profile-settings.tsx index 735f0b42..321c859a 100644 --- a/app-next/src/components/auth/profile-settings.tsx +++ b/app-next/src/components/auth/profile-settings.tsx @@ -29,6 +29,7 @@ import { import { PasskeyRegistration } from "@/components/auth/passkey-registration"; import { listPasskeys, removePasskey, Passkey } from "@/services/passkey"; import { useToast } from "@/hooks/use-toast"; +import { APP_CONFIG } from "@/lib/config"; /** * Profile Settings Page - Inspired by OpenML's /auth/edit-profile @@ -175,7 +176,7 @@ export function ProfileSettings() { } const apiUrl = - process.env.NEXT_PUBLIC_OPENML_URL || "https://www.openml.org"; + APP_CONFIG.openmlApiUrl || "https://www.openml.org"; const response = await fetch(`${apiUrl}/api-key/regenerate`, { method: "POST", diff --git a/app-next/src/components/auth/sign-in-form.tsx b/app-next/src/components/auth/sign-in-form.tsx index 9e156800..cbec3ca9 100644 --- a/app-next/src/components/auth/sign-in-form.tsx +++ b/app-next/src/components/auth/sign-in-form.tsx @@ -8,7 +8,7 @@ import Link from "next/link"; import { Button } from "@/components/ui/button"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Github, Eye, EyeOff } from "lucide-react"; -import { FcGoogle } from "react-icons/fc"; +import { GoogleIcon } from "@/components/icons/google-icon"; import { PasskeySignInButton } from "./passkey-signin-button"; import { FloatingInput } from "@/components/ui/floating-input"; import { Badge } from "@/components/ui/badge"; @@ -56,7 +56,7 @@ export default function SignInForm() { router.push("/dashboard"); router.refresh(); } - } catch (err) { + } catch (_err) { setError(t("signIn.error")); } finally { setIsLoading(false); @@ -69,7 +69,7 @@ export default function SignInForm() { try { await signIn(provider, { callbackUrl: "/dashboard" }); - } catch (err) { + } catch (_err) { setError(t("signIn.oauthError")); setIsLoading(false); } @@ -97,7 +97,7 @@ export default function SignInForm() { onClick={() => handleOAuthSignIn("google")} disabled={isLoading} > - + Google diff --git a/app-next/src/components/auth/sign-up-form.tsx b/app-next/src/components/auth/sign-up-form.tsx index a97dbcca..447227a2 100644 --- a/app-next/src/components/auth/sign-up-form.tsx +++ b/app-next/src/components/auth/sign-up-form.tsx @@ -8,7 +8,7 @@ import Link from "next/link"; import { Button } from "@/components/ui/button"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Github, Eye, EyeOff, Fingerprint } from "lucide-react"; -import { FcGoogle } from "react-icons/fc"; +import { GoogleIcon } from "@/components/icons/google-icon"; import { startRegistration } from "@simplewebauthn/browser"; import { FloatingInput } from "@/components/ui/floating-input"; import { Badge } from "@/components/ui/badge"; @@ -41,8 +41,8 @@ export default function SignUpForm() { } else { setEmailError(""); } - } catch (err) { - console.error("Error checking email:", err); + } catch (_err) { + console.error("Error checking email:", _err); } }; @@ -100,7 +100,7 @@ export default function SignUpForm() { } else { setError(data.message || t("signUp.registrationFailed")); } - } catch (err) { + } catch (_err) { setError(t("signUp.registrationError")); } finally { setIsLoading(false); @@ -113,7 +113,7 @@ export default function SignUpForm() { try { await signIn(provider, { callbackUrl: "/dashboard" }); - } catch (err) { + } catch (_err) { setError(t("signUp.oauthError")); setIsLoading(false); } @@ -186,9 +186,9 @@ export default function SignUpForm() { } else { router.push("/auth/sign-in?success=account_created"); } - } catch (err: any) { + } catch (err: unknown) { console.error("Passkey Sign-up error:", err); - setError(err.message || t("signUp.passkeyError")); + setError(err instanceof Error ? err.message : t("signUp.passkeyError")); } finally { setIsLoading(false); } @@ -224,7 +224,7 @@ export default function SignUpForm() { onClick={() => handleOAuthSignIn("google")} disabled={isLoading} > - + Google diff --git a/app-next/src/components/collection/collection-create-form.tsx b/app-next/src/components/collection/collection-create-form.tsx new file mode 100644 index 00000000..bb221dbe --- /dev/null +++ b/app-next/src/components/collection/collection-create-form.tsx @@ -0,0 +1,293 @@ +"use client"; + +import { useState, useRef } from "react"; +import { useRouter } from "next/navigation"; +import { useSession } from "next-auth/react"; +import { Card, CardContent, CardFooter, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Loader2, PlusCircle, AlertCircle, X, ListTodo, FlaskConical, CheckCircle2 } from "lucide-react"; + +type CollectionType = "tasks" | "runs"; + +export function CollectionCreateForm() { + const { data: session } = useSession(); + const router = useRouter(); + + const [collectionType, setCollectionType] = useState("tasks"); + const [collectionName, setCollectionName] = useState(""); + const [description, setDescription] = useState(""); + const [ids, setIds] = useState([]); + const [idInput, setIdInput] = useState(""); + const [idInputError, setIdInputError] = useState(null); + + const [isLoading, setIsLoading] = useState(false); + const [loadingStep, setLoadingStep] = useState(0); + const [error, setError] = useState(null); + const errorRef = useRef(null); + + const LOADING_STEPS = [ + "Submitting to OpenML...", + "Processing your collection...", + "Almost done...", + ]; + + const addId = () => { + const trimmed = idInput.trim().replace(/,$/, ""); + if (!trimmed) return; + const num = parseInt(trimmed, 10); + if (isNaN(num) || num <= 0) { + setIdInputError("Please enter a valid positive integer ID."); + return; + } + if (!ids.includes(num)) { + setIds((prev) => [...prev, num]); + } + setIdInput(""); + setIdInputError(null); + }; + + const removeId = (id: number) => setIds((prev) => prev.filter((n) => n !== id)); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + addId(); + } + }; + + const handleSubmit = async (e: React.SyntheticEvent) => { + e.preventDefault(); + if (!collectionName) { + setError("Collection name is required."); + return; + } + if (ids.length === 0) { + setError(`Please add at least one ${collectionType === "tasks" ? "task" : "run"} ID.`); + return; + } + if (!session) { + setError("You must be signed in to create a collection."); + return; + } + + setIsLoading(true); + setLoadingStep(0); + setError(null); + + const stepInterval = setInterval(() => { + setLoadingStep((prev) => Math.min(prev + 1, LOADING_STEPS.length - 1)); + }, 3000); + + try { + const response = await fetch("/api/collections/create", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + collectionname: collectionName, + description: description || undefined, + collectiontype: collectionType, + taskids: collectionType === "tasks" ? ids.join(",") : undefined, + runids: collectionType === "runs" ? ids.join(",") : undefined, + }), + }); + + const data = await response.json(); + if (!response.ok) { + clearInterval(stepInterval); + setError(data.error || "Failed to create collection. Please try again."); + setTimeout(() => errorRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }), 50); + return; + } + + clearInterval(stepInterval); + router.push(`/collections/${data.id}`); + router.refresh(); + } catch { + clearInterval(stepInterval); + setError("Failed to create collection. Please try again."); + setTimeout(() => errorRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }), 50); + } finally { + setIsLoading(false); + } + }; + + const typeLabel = collectionType === "tasks" ? "task" : "run"; + + return ( + + {isLoading && ( +
+ +
+

{LOADING_STEPS[loadingStep]}

+

This may take a few seconds.

+
+
+ )} + + + + Create Collection + + + Create a new benchmark suite or study by grouping tasks or runs. + + + +
+ + {error && ( + + + Error + {error} + + )} + + {/* Step 1: Collection Type */} +
+ +
+ + + +
+
+ + {/* Step 2: Name + Description */} +
+
+ + setCollectionName(e.target.value)} + required + /> +
+ +
+ +