From 11f7a43d1d893cbf9936d49df1ceef359c7e104a Mon Sep 17 00:00:00 2001 From: boilerrat <128boilerrat@gmail.com> Date: Tue, 18 Mar 2025 22:03:47 -0400 Subject: [PATCH] Add search functionality and data export feature --- src/app/api/contacts/export/route.ts | 109 +++++++++++++++++++ src/app/contacts/page.tsx | 50 +++++++-- src/app/dashboard/page.tsx | 122 ++++++++++------------ src/components/contacts/contacts-list.tsx | 11 +- 4 files changed, 213 insertions(+), 79 deletions(-) create mode 100644 src/app/api/contacts/export/route.ts diff --git a/src/app/api/contacts/export/route.ts b/src/app/api/contacts/export/route.ts new file mode 100644 index 0000000..961bb59 --- /dev/null +++ b/src/app/api/contacts/export/route.ts @@ -0,0 +1,109 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getUser } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +export async function GET(request: NextRequest) { + try { + // Check if user is authenticated + const user = await getUser(); + if (!user) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ); + } + + // Get search parameter + const searchParams = request.nextUrl.searchParams; + const search = searchParams.get("search") || ""; + + // Fetch contacts with search filter if provided + const contacts = await prisma.contact.findMany({ + where: search ? { + OR: [ + { name: { contains: search, mode: 'insensitive' } }, + { ensName: { contains: search, mode: 'insensitive' } }, + { ethereumAddress: { contains: search, mode: 'insensitive' } }, + { email: { contains: search, mode: 'insensitive' } }, + { twitter: { contains: search, mode: 'insensitive' } }, + { discord: { contains: search, mode: 'insensitive' } }, + ], + } : undefined, + include: { + nftHoldings: true, + daoMemberships: true, + tokenHoldings: true, + tags: { + include: { + tag: true, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + }); + + // Convert to CSV format + const csvHeader = [ + "Name", + "Ethereum Address", + "ENS Name", + "Email", + "Twitter", + "Discord", + "Telegram", + "Farcaster", + "NFT Holdings", + "DAO Memberships", + "Token Holdings", + "Tags", + "Created At" + ].join(","); + + const csvRows = contacts.map(contact => { + const nftHoldings = contact.nftHoldings.map(nft => `${nft.collectionName || 'Unknown'} #${nft.tokenId}`).join("; "); + const daoMemberships = contact.daoMemberships.map(dao => dao.daoName).join("; "); + const tokenHoldings = contact.tokenHoldings.map(token => `${token.tokenSymbol}: ${token.balance}`).join("; "); + const tags = contact.tags.map(tag => tag.tag.name).join("; "); + + return [ + contact.name ? `"${contact.name.replace(/"/g, '""')}"` : "", + `"${contact.ethereumAddress}"`, + contact.ensName ? `"${contact.ensName.replace(/"/g, '""')}"` : "", + contact.email ? `"${contact.email.replace(/"/g, '""')}"` : "", + contact.twitter ? `"${contact.twitter.replace(/"/g, '""')}"` : "", + contact.discord ? `"${contact.discord.replace(/"/g, '""')}"` : "", + contact.telegram ? `"${contact.telegram.replace(/"/g, '""')}"` : "", + contact.farcaster ? `"${contact.farcaster.replace(/"/g, '""')}"` : "", + nftHoldings ? `"${nftHoldings.replace(/"/g, '""')}"` : "", + daoMemberships ? `"${daoMemberships.replace(/"/g, '""')}"` : "", + tokenHoldings ? `"${tokenHoldings.replace(/"/g, '""')}"` : "", + tags ? `"${tags.replace(/"/g, '""')}"` : "", + `"${new Date(contact.createdAt).toISOString()}"`, + ].join(","); + }); + + const csv = [csvHeader, ...csvRows].join("\n"); + + // Generate filename with date + const date = new Date().toISOString().split("T")[0]; + const filename = search + ? `stones-contacts-search-${search}-${date}.csv` + : `stones-contacts-${date}.csv`; + + // Return CSV file + return new NextResponse(csv, { + headers: { + "Content-Type": "text/csv", + "Content-Disposition": `attachment; filename="${filename}"`, + }, + }); + } catch (error) { + console.error("Export error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/contacts/page.tsx b/src/app/contacts/page.tsx index 7563c3d..b3459a4 100644 --- a/src/app/contacts/page.tsx +++ b/src/app/contacts/page.tsx @@ -19,11 +19,22 @@ interface ContactsPageProps { export default async function ContactsPage({ searchParams }: ContactsPageProps) { const user = await getUser(); const page = Number(searchParams.page) || 1; + const search = typeof searchParams.search === 'string' ? searchParams.search : ''; const limit = 25; const skip = (page - 1) * limit; - // Get contacts with pagination + // Get contacts with pagination and search const contacts = await prisma.contact.findMany({ + where: { + OR: [ + { name: { contains: search, mode: 'insensitive' } }, + { ensName: { contains: search, mode: 'insensitive' } }, + { ethereumAddress: { contains: search, mode: 'insensitive' } }, + { email: { contains: search, mode: 'insensitive' } }, + { twitter: { contains: search, mode: 'insensitive' } }, + { discord: { contains: search, mode: 'insensitive' } }, + ], + }, skip, take: limit, orderBy: { @@ -39,8 +50,19 @@ export default async function ContactsPage({ searchParams }: ContactsPageProps) }, }); - // Get total count for pagination - const totalContacts = await prisma.contact.count(); + // Get total count for pagination with search + const totalContacts = await prisma.contact.count({ + where: { + OR: [ + { name: { contains: search, mode: 'insensitive' } }, + { ensName: { contains: search, mode: 'insensitive' } }, + { ethereumAddress: { contains: search, mode: 'insensitive' } }, + { email: { contains: search, mode: 'insensitive' } }, + { twitter: { contains: search, mode: 'insensitive' } }, + { discord: { contains: search, mode: 'insensitive' } }, + ], + }, + }); const totalPages = Math.ceil(totalContacts / limit); return ( @@ -75,15 +97,26 @@ export default async function ContactsPage({ searchParams }: ContactsPageProps)