From 10b9581c499740bebcb0469433e25cfe36f42139 Mon Sep 17 00:00:00 2001 From: boilerrat <128boilerrat@gmail.com> Date: Tue, 18 Mar 2025 22:10:08 -0400 Subject: [PATCH] Add contact creation and import features --- package-lock.json | 7 + package.json | 1 + src/app/api/contacts/import/route.ts | 130 +++++++++++ src/app/api/contacts/route.ts | 77 +++++++ src/app/contacts/import/page.tsx | 78 +++++++ src/app/contacts/new/page.tsx | 78 +++++++ src/app/contacts/page.tsx | 8 +- src/app/dashboard/page.tsx | 16 +- .../contacts/forms/contact-form.tsx | 203 ++++++++++++++++++ .../contacts/forms/import-contacts-form.tsx | 166 ++++++++++++++ 10 files changed, 755 insertions(+), 9 deletions(-) create mode 100644 src/app/api/contacts/import/route.ts create mode 100644 src/app/api/contacts/route.ts create mode 100644 src/app/contacts/import/page.tsx create mode 100644 src/app/contacts/new/page.tsx create mode 100644 src/components/contacts/forms/contact-form.tsx create mode 100644 src/components/contacts/forms/import-contacts-form.tsx diff --git a/package-lock.json b/package-lock.json index a02ad22..16ffc75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@radix-ui/react-toast": "^1.1.5", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "csv-parse": "^5.6.0", "express": "^4.18.2", "framer-motion": "^11.0.5", "lucide-react": "^0.331.0", @@ -2948,6 +2949,12 @@ "devOptional": true, "license": "MIT" }, + "node_modules/csv-parse": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.6.0.tgz", + "integrity": "sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==", + "license": "MIT" + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", diff --git a/package.json b/package.json index eec1993..715d801 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@radix-ui/react-toast": "^1.1.5", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "csv-parse": "^5.6.0", "express": "^4.18.2", "framer-motion": "^11.0.5", "lucide-react": "^0.331.0", diff --git a/src/app/api/contacts/import/route.ts b/src/app/api/contacts/import/route.ts new file mode 100644 index 0000000..4b88030 --- /dev/null +++ b/src/app/api/contacts/import/route.ts @@ -0,0 +1,130 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getUser } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { parse } from "csv-parse/sync"; + +// Max file size (5MB) +const MAX_FILE_SIZE = 5 * 1024 * 1024; + +export async function POST(request: NextRequest) { + try { + // Check authentication + const user = await getUser(); + if (!user) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ); + } + + // Get the form data + const formData = await request.formData(); + const file = formData.get("file") as File; + + if (!file) { + return NextResponse.json( + { error: "No file uploaded" }, + { status: 400 } + ); + } + + // Check file size + if (file.size > MAX_FILE_SIZE) { + return NextResponse.json( + { error: "File size exceeds 5MB limit" }, + { status: 400 } + ); + } + + // Check file type + if (!file.name.endsWith(".csv") && file.type !== "text/csv") { + return NextResponse.json( + { error: "Please upload a CSV file" }, + { status: 400 } + ); + } + + // Read and parse the CSV data + const csvText = await file.text(); + const records = parse(csvText, { + columns: true, + skip_empty_lines: true, + trim: true, + }); + + // Process the records + const stats = { + total: records.length, + imported: 0, + skipped: 0, + }; + + for (const record of records) { + // Map CSV headers to database fields (case-insensitive) + const getField = (fieldName: string) => { + const normalizedFieldName = fieldName.toLowerCase(); + for (const key of Object.keys(record)) { + if (key.toLowerCase() === normalizedFieldName) { + return record[key]; + } + } + return undefined; + }; + + const ethereumAddress = getField("ethereum address") || getField("address"); + + // Skip record if no Ethereum address is provided + if (!ethereumAddress) { + stats.skipped++; + continue; + } + + // Check if contact already exists + const existingContact = await prisma.contact.findFirst({ + where: { + ethereumAddress: ethereumAddress, + }, + }); + + if (existingContact) { + stats.skipped++; + continue; + } + + // Create the contact + await prisma.contact.create({ + data: { + name: getField("name"), + ethereumAddress: ethereumAddress, + ensName: getField("ens name") || getField("ens"), + email: getField("email"), + twitter: getField("twitter"), + discord: getField("discord"), + telegram: getField("telegram"), + farcaster: getField("farcaster"), + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + stats.imported++; + } + + return NextResponse.json(stats, { status: 200 }); + } catch (error: any) { + console.error("Error importing contacts:", error); + + // Handle CSV parsing errors specifically + if (error.code === 'CSV_INVALID_OPTION_VALUE' || error.code === 'CSV_PARSE_ERROR') { + return NextResponse.json( + { error: "Invalid CSV format. Please check your file and try again." }, + { status: 400 } + ); + } + + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/contacts/route.ts b/src/app/api/contacts/route.ts new file mode 100644 index 0000000..80ab4bf --- /dev/null +++ b/src/app/api/contacts/route.ts @@ -0,0 +1,77 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getUser } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +// Create a new contact +export async function POST(request: NextRequest) { + try { + // Check authentication + const user = await getUser(); + if (!user) { + return NextResponse.json( + { error: "Unauthorized" }, + { status: 401 } + ); + } + + // Parse request body + const body = await request.json(); + const { + name, + ethereumAddress, + ensName, + email, + twitter, + discord, + telegram, + farcaster, + } = body; + + // Validate required fields + if (!ethereumAddress) { + return NextResponse.json( + { error: "Ethereum address is required" }, + { status: 400 } + ); + } + + // Check for existing contact with the same Ethereum address + const existingContact = await prisma.contact.findFirst({ + where: { + ethereumAddress: ethereumAddress, + }, + }); + + if (existingContact) { + return NextResponse.json( + { error: "A contact with this Ethereum address already exists" }, + { status: 409 } + ); + } + + // Create the contact + const contact = await prisma.contact.create({ + data: { + name, + ethereumAddress, + ensName, + email, + twitter, + discord, + telegram, + farcaster, + // Additional fields with defaults + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + return NextResponse.json(contact, { status: 201 }); + } catch (error) { + console.error("Error creating contact:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/contacts/import/page.tsx b/src/app/contacts/import/page.tsx new file mode 100644 index 0000000..989fc2e --- /dev/null +++ b/src/app/contacts/import/page.tsx @@ -0,0 +1,78 @@ +import { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { getUser } from "@/lib/auth"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { LogoutButton } from "@/components/auth/logout-button"; +import { ImportContactsForm } from "@/components/contacts/forms/import-contacts-form"; + +export const metadata: Metadata = { + title: "Import Contacts - Stones Database", + description: "Import contacts to the Stones Database", +}; + +export default async function ImportContactsPage() { + const user = await getUser(); + + if (!user) { + notFound(); + } + + return ( +