diff --git a/src/app/api/user-community-activity/[wallet_address]/[entity]/route.ts b/src/app/api/user-community-activity/[wallet_address]/[entity]/route.ts new file mode 100644 index 0000000..0f55355 --- /dev/null +++ b/src/app/api/user-community-activity/[wallet_address]/[entity]/route.ts @@ -0,0 +1,62 @@ +import "server-only" +import { NextRequest, NextResponse } from "next/server" +import { pool } from "@/lib/fast-db" + +function isValidWalletAddress(address: string): boolean { + return /^0x[a-fA-F0-9]{40}$/.test(address) +} + +/** + * GET /api/user-community-activity/[wallet_address]/[entity] + * Returns the latest activity for a single entity for the user. 404 if no record exists. + */ +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ wallet_address: string; entity: string }> } +) { + try { + const { wallet_address, entity } = await params + + if (!wallet_address) { + return NextResponse.json({ error: "Wallet address is required" }, { status: 400 }) + } + + if (!isValidWalletAddress(wallet_address)) { + return NextResponse.json({ error: "Invalid wallet address format" }, { status: 400 }) + } + + const entityTrimmed = typeof entity === "string" ? entity.trim() : "" + if (!entityTrimmed) { + return NextResponse.json({ error: "Entity is required" }, { status: 400 }) + } + + const address = wallet_address.toLowerCase() + + const { rows } = await pool.query( + `SELECT entity, activity, chainid, created_at + FROM ( + SELECT entity, activity, chainid, created_at, + ROW_NUMBER() OVER (ORDER BY created_at DESC) AS rn + FROM user_activity + WHERE user_address = $1 AND entity = $2 + ) sub + WHERE rn = 1`, + [address, entityTrimmed] + ) + + if (rows.length === 0) { + return NextResponse.json({ error: "Activity not found" }, { status: 404 }) + } + + const row = rows[0] + return NextResponse.json({ + entity: row.entity, + activity: row.activity === true, + chainId: row.chainid ?? null, + createdAt: row.created_at, + }) + } catch (err) { + console.error("Error fetching user activity for entity:", err) + return NextResponse.json({ error: "Database query failed" }, { status: 500 }) + } +} diff --git a/src/app/api/user-community-activity/entities/route.ts b/src/app/api/user-community-activity/entities/route.ts new file mode 100644 index 0000000..07a0dcf --- /dev/null +++ b/src/app/api/user-community-activity/entities/route.ts @@ -0,0 +1,20 @@ +import "server-only" +import { NextResponse } from "next/server" +import { pool } from "@/lib/fast-db" + +/** + * GET /api/user-community-activity/entities + * Returns distinct entities that have at least one activity record. + */ +export async function GET() { + try { + const { rows } = await pool.query(`SELECT DISTINCT entity FROM user_activity ORDER BY entity`) + + const entities = rows.map((row) => row.entity) + + return NextResponse.json({ entities }) + } catch (err) { + console.error("Error fetching entities:", err) + return NextResponse.json({ error: "Database query failed" }, { status: 500 }) + } +} diff --git a/src/app/api/user-community-activity/entity/[entity]/route.ts b/src/app/api/user-community-activity/entity/[entity]/route.ts new file mode 100644 index 0000000..76ff572 --- /dev/null +++ b/src/app/api/user-community-activity/entity/[entity]/route.ts @@ -0,0 +1,66 @@ +import "server-only" +import { NextRequest, NextResponse } from "next/server" +import { pool } from "@/lib/fast-db" + +/** + * GET /api/user-community-activity/entity/[entity] + * Returns users who have verified activity for the given entity. + * Query params: limit (default 50, max 200), chainId (optional) + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ entity: string }> } +) { + try { + const { entity } = await params + const entityTrimmed = typeof entity === "string" ? entity.trim() : "" + + if (!entityTrimmed) { + return NextResponse.json({ error: "Entity is required" }, { status: 400 }) + } + + const { searchParams } = new URL(request.url) + const limitParam = searchParams.get("limit") + const limitRaw = limitParam ? parseInt(limitParam, 10) : 50 + const limit = Math.min(Math.max(limitRaw, 1), 200) + + const chainIdParam = searchParams.get("chainId") + const chainId = chainIdParam !== null && chainIdParam !== "" ? parseInt(chainIdParam, 10) : null + + const values: unknown[] = [entityTrimmed] + let paramIndex = 2 + + const chainFilter = + chainId !== null && !Number.isNaN(chainId) ? `AND chainid = $${paramIndex++}` : "" + if (chainId !== null && !Number.isNaN(chainId)) { + values.push(chainId) + } + values.push(limit) + + const { rows } = await pool.query( + `SELECT user_address, activity, chainid, created_at + FROM ( + SELECT user_address, activity, chainid, created_at, + ROW_NUMBER() OVER (PARTITION BY user_address ORDER BY created_at DESC) AS rn + FROM user_activity + WHERE entity = $1 ${chainFilter} + ) sub + WHERE rn = 1 + ORDER BY created_at DESC + LIMIT $${paramIndex}`, + values + ) + + const users = rows.map((row) => ({ + wallet: row.user_address, + activity: row.activity === true, + chainId: row.chainid ?? null, + createdAt: row.created_at, + })) + + return NextResponse.json({ users }) + } catch (err) { + console.error("Error fetching users by entity:", err) + return NextResponse.json({ error: "Database query failed" }, { status: 500 }) + } +} diff --git a/src/app/api/user-community-activity/stats/route.ts b/src/app/api/user-community-activity/stats/route.ts new file mode 100644 index 0000000..3de08d3 --- /dev/null +++ b/src/app/api/user-community-activity/stats/route.ts @@ -0,0 +1,69 @@ +import "server-only" +import { NextRequest, NextResponse } from "next/server" +import { pool } from "@/lib/fast-db" + +/** + * GET /api/user-community-activity/stats + * Returns aggregate stats for user_activity. Query params: entity (optional), chainId (optional) + */ +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const entity = searchParams.get("entity")?.trim() || null + const chainIdParam = searchParams.get("chainId") + const chainId = chainIdParam !== null && chainIdParam !== "" ? parseInt(chainIdParam, 10) : null + + const filters: string[] = [] + const values: unknown[] = [] + let paramIndex = 1 + + if (entity) { + filters.push(`entity = $${paramIndex}`) + values.push(entity) + paramIndex++ + } + if (chainId !== null && !Number.isNaN(chainId)) { + filters.push(`chainid = $${paramIndex}`) + values.push(chainId) + paramIndex++ + } + + const whereClause = filters.length > 0 ? `WHERE ${filters.join(" AND ")}` : "" + + const [totalRes, usersRes, byEntityRes, byChainRes] = await Promise.all([ + pool.query(`SELECT COUNT(*)::int AS total FROM user_activity ${whereClause}`, values), + pool.query( + `SELECT COUNT(DISTINCT user_address)::int AS count FROM user_activity ${whereClause}`, + values + ), + pool.query( + `SELECT entity, COUNT(*)::int AS count FROM user_activity ${whereClause} GROUP BY entity ORDER BY count DESC`, + values + ), + pool.query( + `SELECT chainid, COUNT(*)::int AS count FROM user_activity ${whereClause} GROUP BY chainid ORDER BY count DESC`, + values + ), + ]) + + const byEntity: Record = {} + for (const row of byEntityRes.rows) { + byEntity[row.entity] = row.count + } + + const byChain: Record = {} + for (const row of byChainRes.rows) { + byChain[row.chainid] = row.count + } + + return NextResponse.json({ + totalRecords: totalRes.rows[0]?.total ?? 0, + uniqueUsers: usersRes.rows[0]?.count ?? 0, + byEntity, + byChain, + }) + } catch (err) { + console.error("Error fetching user activity stats:", err) + return NextResponse.json({ error: "Database query failed" }, { status: 500 }) + } +}