diff --git a/.env.example b/.env.example index 633bfd9..344b539 100644 --- a/.env.example +++ b/.env.example @@ -15,9 +15,7 @@ NEXT_PUBLIC_API_URL=https://nodes-api-dev.desci.com # Allows testing local env with a prod API, but carries some security risk due to loosened cookie security settings, should only be enabled in dev mode. NEXT_PUBLIC_LOCAL_AUTH_COOKIE=true -DESCI_RESEARCH_CONNECTION_DATABASE_URL="" - NEXT_PUBLIC_BASE_URL=http://localhost:3000 SCIWEAVE_SUPABASE_URL= -SCIWEAVE_SUPABASE_SERVICE_KEY= \ No newline at end of file +SCIWEAVE_SUPABASE_SERVICE_KEY= diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..80d9d1f --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.15 \ No newline at end of file diff --git a/README.md b/README.md index 747c4cf..d531b87 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,8 @@ # DeSci Nodes Admin v2 -This repo is for the next version of the [DeSci Nodes Web v1 web app](https://github.com/desci-labs/nodes-web). - ## Getting Started ```bash -npm install -npm run dev -``` \ No newline at end of file +yarn +yarn dev +``` diff --git a/src/app/(auth)/metrics/sciweave/page.tsx b/src/app/(auth)/metrics/sciweave/page.tsx index 63a34a4..8b6beb6 100644 --- a/src/app/(auth)/metrics/sciweave/page.tsx +++ b/src/app/(auth)/metrics/sciweave/page.tsx @@ -80,17 +80,34 @@ async function getUserSessionsAnalytics( params.set("to", to); params.set("interval", interval); - const res = await fetch( - `${ - process.env.NEXT_PUBLIC_BASE_URL - }/api/sciweave-analytics/sessions?${params.toString()}`, - { - next: { - revalidate: 3600, - }, + try { + const res = await fetch( + `${ + process.env.NEXT_PUBLIC_BASE_URL + }/api/sciweave-analytics/sessions?${params.toString()}`, + { + next: { + revalidate: 3600, + }, + } + ); + + if (!res.ok) { + console.error('Failed to fetch user sessions:', res.status, res.statusText); + return []; } - ); - return res.json() as Promise; + + const data = await res.json(); + if (!Array.isArray(data)) { + console.error('Expected array from user sessions API, got:', data); + return []; + } + + return data as UserSessionsDataItem[]; + } catch (error) { + console.error('Error fetching user sessions:', error); + return []; + } } async function getDevicesAnalytics(from: string, to: string, interval: string) { diff --git a/src/app/(unauth)/login/page.tsx b/src/app/(unauth)/login/page.tsx index fbfd207..1907496 100644 --- a/src/app/(unauth)/login/page.tsx +++ b/src/app/(unauth)/login/page.tsx @@ -42,7 +42,7 @@ export default function Login() { login={formAction} email={state?.email} message={state?.error} - disabled={state?.user !== undefined} + disabled={!!state?.user} /> diff --git a/src/app/actions.tsx b/src/app/actions.tsx index 367a4fe..dd23bf4 100644 --- a/src/app/actions.tsx +++ b/src/app/actions.tsx @@ -30,10 +30,10 @@ export async function login(prevState: any, formData: FormData) { // Set cookie cookies().set(AUTH_COOKIE_FIELDNAME, response.user.token, { path: "/", - expires: new Date(Date.now() + 1000 * 60 * 60 * 2), // 3 hours + expires: new Date(Date.now() + 1000 * 60 * 60 * 2), // 2 hours httpOnly: true, secure: process.env.NEXT_ENV === "production", - domain: process.env.NODE_ENV === "production" ? ".desci.com" : undefined, + domain: process.env.NEXT_ENV === "production" ? ".desci.com" : undefined, }); if ( @@ -43,7 +43,7 @@ export async function login(prevState: any, formData: FormData) { console.log("[cookie]", AUTH_COOKIE_FIELDNAME, process.env.NEXT_ENV); cookies().set("auth", response.user.token, { path: "/", - expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30 * 1), // 2 hours + expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30), // 30 days httpOnly: true, secure: true, // domain: '.desci.com', diff --git a/src/app/api/download/route.ts b/src/app/api/download/route.ts index 2740a52..d376602 100644 --- a/src/app/api/download/route.ts +++ b/src/app/api/download/route.ts @@ -2,6 +2,8 @@ import { NODES_API_URL } from "@/lib/config"; import { cookies } from "next/headers"; import { NextResponse } from "next/server"; +export const maxDuration = 300; + export async function GET(_request: Request) { try { const response = await fetch( @@ -14,7 +16,27 @@ export async function GET(_request: Request) { }, } ); - return response + + if (!response.ok) { + return NextResponse.json( + { error: "Failed to fetch report" }, + { status: response.status } + ); + } + + if (!response.body) { + return NextResponse.json( + { error: "Missing response body" }, + { status: 502 } + ); + } + + return new Response(response.body, { + headers: { + "Content-Type": "text/csv", + "Content-Disposition": 'attachment; filename="report.csv"', + }, + }); } catch (err) { return NextResponse.json({ error: err }, { status: 500 }); } diff --git a/src/app/api/downloadAnalyticsReport/route.ts b/src/app/api/downloadAnalyticsReport/route.ts index 8ce5b39..df7cb3b 100644 --- a/src/app/api/downloadAnalyticsReport/route.ts +++ b/src/app/api/downloadAnalyticsReport/route.ts @@ -2,6 +2,8 @@ import { NODES_API_URL } from "@/lib/config"; import { cookies } from "next/headers"; import { NextResponse } from "next/server"; +export const maxDuration = 300; + export async function GET(request: Request) { try { const url = new URL(request.url); @@ -15,7 +17,27 @@ export async function GET(request: Request) { }, } ); - return response + + if (!response.ok) { + return NextResponse.json( + { error: "Failed to fetch report" }, + { status: response.status } + ); + } + + if (!response.body) { + return NextResponse.json( + { error: "Missing response body" }, + { status: 502 } + ); + } + + return new Response(response.body, { + headers: { + "Content-Type": "text/csv", + "Content-Disposition": 'attachment; filename="report.csv"', + }, + }); } catch (err) { return NextResponse.json({ error: err }, { status: 500 }); } diff --git a/src/app/api/sciweave-analytics/chats/route.ts b/src/app/api/sciweave-analytics/chats/route.ts index b5f4d8d..81c975b 100644 --- a/src/app/api/sciweave-analytics/chats/route.ts +++ b/src/app/api/sciweave-analytics/chats/route.ts @@ -3,7 +3,7 @@ import pool from "@/lib/postgresClient"; import { PROD_FILTER_AND } from "@/lib/config"; import { analyticsQuerySchema, intervalToDateTrunc } from "@/lib/schema"; -export async function GET(request: NextRequest) { +async function handleRequest(request: NextRequest) { try { const { searchParams } = new URL(request.url); const { from, to, interval } = analyticsQuerySchema.parse( @@ -42,3 +42,21 @@ export async function GET(request: NextRequest) { ); } } + +export async function GET(request: NextRequest) { + return handleRequest(request); +} + +export async function POST(request: NextRequest) { + const { searchParams } = new URL(request.url); + const isGet = searchParams.get("get") === "true"; + + if (isGet) { + return handleRequest(request); + } + + return NextResponse.json( + { error: "POST method requires ?get=true parameter for replica compatibility" }, + { status: 400 } + ); +} diff --git a/src/app/api/sciweave-analytics/devices/route.ts b/src/app/api/sciweave-analytics/devices/route.ts index 7c15ca7..f5ef2e2 100644 --- a/src/app/api/sciweave-analytics/devices/route.ts +++ b/src/app/api/sciweave-analytics/devices/route.ts @@ -3,7 +3,7 @@ import pool from "@/lib/postgresClient"; import { PROD_FILTER_AND } from "@/lib/config"; import { analyticsQuerySchema, intervalToDateTrunc } from "@/lib/schema"; -export async function GET(request: NextRequest) { +async function handleRequest(request: NextRequest) { try { const { searchParams } = new URL(request.url); const { from, to, interval } = analyticsQuerySchema.parse( @@ -14,45 +14,13 @@ export async function GET(request: NextRequest) { const result = await client.query( ` SELECT - date_trunc($3, created_at) AS DATE, - COUNT( - CASE - WHEN (device_info ->> 'isMobile') :: boolean THEN 1 - END - ) AS "mobile", - COUNT( - CASE - WHEN (device_info ->> 'isTablet') :: boolean THEN 1 - END - ) AS "tablet", - COUNT( - CASE - WHEN (device_info ->> 'isDesktop') :: boolean - AND device_info ->> 'osName' ILIKE 'mac%' THEN 1 - END - ) AS "mac", - COUNT( - CASE - WHEN (device_info ->> 'isDesktop') :: boolean - AND device_info ->> 'osName' ILIKE 'windows%' THEN 1 - END - ) AS "windows", - COUNT( - CASE - WHEN (device_info ->> 'isDesktop') :: boolean - AND device_info ->> 'osName' NOT ILIKE 'mac%' - AND device_info ->> 'osName' NOT ILIKE 'windows%' THEN 1 - END - ) AS "otherDesktops", - COUNT( - CASE - WHEN NOT ( - (device_info ->> 'isMobile') :: boolean - OR (device_info ->> 'isTablet') :: boolean - OR (device_info ->> 'isDesktop') :: boolean - ) THEN 1 - END - ) AS unknown + date_trunc($3, created_at) AS DATE, + SUM(((device_info->>'isMobile')::boolean)::int) AS "mobile", + SUM(((device_info->>'isTablet')::boolean)::int) AS "tablet", + SUM(((device_info->>'isDesktop')::boolean AND device_info->>'osName' ILIKE 'mac%')::int) AS "mac", + SUM(((device_info->>'isDesktop')::boolean AND device_info->>'osName' ILIKE 'windows%')::int) AS "windows", + SUM(((device_info->>'isDesktop')::boolean AND device_info->>'osName' NOT ILIKE 'mac%' AND device_info->>'osName' NOT ILIKE 'windows%')::int) AS "otherDesktops", + SUM((NOT((device_info->>'isMobile')::boolean OR (device_info->>'isTablet')::boolean OR (device_info->>'isDesktop')::boolean))::int) AS "unknown" FROM search_logs WHERE created_at >= $1 AND created_at <= $2 @@ -82,3 +50,21 @@ export async function GET(request: NextRequest) { ); } } + +export async function GET(request: NextRequest) { + return handleRequest(request); +} + +export async function POST(request: NextRequest) { + const { searchParams } = new URL(request.url); + const isGet = searchParams.get("get") === "true"; + + if (isGet) { + return handleRequest(request); + } + + return NextResponse.json( + { error: "POST method requires ?get=true parameter for replica compatibility" }, + { status: 400 } + ); +} diff --git a/src/app/api/sciweave-analytics/sessions/route.ts b/src/app/api/sciweave-analytics/sessions/route.ts index a15d533..b94b80f 100644 --- a/src/app/api/sciweave-analytics/sessions/route.ts +++ b/src/app/api/sciweave-analytics/sessions/route.ts @@ -3,7 +3,7 @@ import pool from "@/lib/postgresClient"; import { PROD_FILTER_LEADING } from "@/lib/config"; import { analyticsQuerySchema, intervalToDateTrunc } from "@/lib/schema"; -export async function GET(request: NextRequest) { +async function handleRequest(request: NextRequest) { try { const { searchParams } = new URL(request.url); const { from, to, interval } = analyticsQuerySchema.parse( @@ -109,3 +109,21 @@ export async function GET(request: NextRequest) { ); } } + +export async function GET(request: NextRequest) { + return handleRequest(request); +} + +export async function POST(request: NextRequest) { + const { searchParams } = new URL(request.url); + const isGet = searchParams.get("get") === "true"; + + if (isGet) { + return handleRequest(request); + } + + return NextResponse.json( + { error: "POST method requires ?get=true parameter for replica compatibility" }, + { status: 400 } + ); +} diff --git a/src/app/api/sciweave-analytics/users/route.ts b/src/app/api/sciweave-analytics/users/route.ts index 66d337e..b2eeb46 100644 --- a/src/app/api/sciweave-analytics/users/route.ts +++ b/src/app/api/sciweave-analytics/users/route.ts @@ -3,7 +3,7 @@ import pool from "@/lib/postgresClient"; import { PROD_FILTER_AND } from "@/lib/config"; import { analyticsQuerySchema, intervalToDateTrunc } from "@/lib/schema"; -export async function GET(request: NextRequest) { +async function handleRequest(request: NextRequest) { try { const { searchParams } = new URL(request.url); const { from, to, interval } = analyticsQuerySchema.parse( @@ -41,3 +41,21 @@ export async function GET(request: NextRequest) { ); } } + +export async function GET(request: NextRequest) { + return handleRequest(request); +} + +export async function POST(request: NextRequest) { + const { searchParams } = new URL(request.url); + const isGet = searchParams.get("get") === "true"; + + if (isGet) { + return handleRequest(request); + } + + return NextResponse.json( + { error: "POST method requires ?get=true parameter for replica compatibility" }, + { status: 400 } + ); +} diff --git a/src/components/molecules/DesciResearchAnalytics.tsx b/src/components/molecules/DesciResearchAnalytics.tsx index 2acde5e..87c94b4 100644 --- a/src/components/molecules/DesciResearchAnalytics.tsx +++ b/src/components/molecules/DesciResearchAnalytics.tsx @@ -581,6 +581,10 @@ export function DesciResearchAnalytics({ } }; const userSessionsData = useMemo(() => { + if (!Array.isArray(userSessions)) { + console.error('userSessions is not an array:', userSessions); + return []; + } return userSessions.map((item) => ({ date: formatDate(item.date, "dd MMM"), value: item.sessionCount, @@ -588,6 +592,10 @@ export function DesciResearchAnalytics({ }, [userSessions]); const userSessionsDurationData = useMemo(() => { + if (!Array.isArray(userSessions)) { + console.error('userSessions is not an array:', userSessions); + return []; + } return userSessions.map((item) => ({ date: formatDate(item.date, "dd MMM"), value: item.durationInSeconds, diff --git a/src/components/molecules/LoginForm.tsx b/src/components/molecules/LoginForm.tsx index 59af715..1174061 100644 --- a/src/components/molecules/LoginForm.tsx +++ b/src/components/molecules/LoginForm.tsx @@ -84,7 +84,7 @@ const SubmitButton = ( if (props.email && pending) return "Verifying code"; if (!props.email && pending) return "Verifying email"; if (!props.email && !pending) return "Verify email"; - if (props.email && !pending) return "Send code"; + if (props.email && !pending) return "Verify code"; }, [props.email, pending]); return (