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 ( +
+
+
+
+ + Stones Database + +
+ +
+
+
+
+
+ + + +

Import Contacts

+
+
+ + + + Import from CSV + + + + + +
+ +
+ ); +} \ No newline at end of file diff --git a/src/app/contacts/new/page.tsx b/src/app/contacts/new/page.tsx new file mode 100644 index 0000000..622962a --- /dev/null +++ b/src/app/contacts/new/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 { ContactForm } from "@/components/contacts/forms/contact-form"; + +export const metadata: Metadata = { + title: "Add Contact - Stones Database", + description: "Add a new contact to the Stones Database", +}; + +export default async function NewContactPage() { + const user = await getUser(); + + if (!user) { + notFound(); + } + + return ( +
+
+
+
+ + Stones Database + +
+ +
+
+
+
+
+ + + +

Add New Contact

+
+
+ + + + Contact Information + + + + + +
+ +
+ ); +} \ No newline at end of file diff --git a/src/app/contacts/page.tsx b/src/app/contacts/page.tsx index b3459a4..45b82c1 100644 --- a/src/app/contacts/page.tsx +++ b/src/app/contacts/page.tsx @@ -109,9 +109,11 @@ export default async function ContactsPage({ searchParams }: ContactsPageProps) {page > 1 && } - + + + - - + + + + + + diff --git a/src/components/contacts/forms/contact-form.tsx b/src/components/contacts/forms/contact-form.tsx new file mode 100644 index 0000000..f8f3c4d --- /dev/null +++ b/src/components/contacts/forms/contact-form.tsx @@ -0,0 +1,203 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + +interface ContactFormProps { + defaultValues?: { + name?: string; + ethereumAddress?: string; + ensName?: string; + email?: string; + twitter?: string; + discord?: string; + telegram?: string; + farcaster?: string; + }; + contactId?: string; +} + +export function ContactForm({ defaultValues = {}, contactId }: ContactFormProps) { + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(""); + + const isEditing = !!contactId; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + setError(""); + + const formData = new FormData(e.currentTarget); + const data = Object.fromEntries(formData.entries()); + + try { + const response = await fetch( + isEditing ? `/api/contacts/${contactId}` : "/api/contacts", + { + method: isEditing ? "PATCH" : "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + } + ); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Something went wrong"); + } + + const result = await response.json(); + + // Redirect to the new/edited contact + router.push(`/contacts/${result.id}`); + router.refresh(); + } catch (error: any) { + setError(error.message || "Something went wrong. Please try again."); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + {error && ( +
+
+
{error}
+
+
+ )} +
+ +
+ + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/contacts/forms/import-contacts-form.tsx b/src/components/contacts/forms/import-contacts-form.tsx new file mode 100644 index 0000000..5d8f514 --- /dev/null +++ b/src/components/contacts/forms/import-contacts-form.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; + +export function ImportContactsForm() { + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + const [file, setFile] = useState(null); + const [importStats, setImportStats] = useState<{ + total: number; + imported: number; + skipped: number; + } | null>(null); + + const handleFileChange = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files.length > 0) { + setFile(e.target.files[0]); + setError(""); + setSuccess(""); + setImportStats(null); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!file) { + setError("Please select a CSV file to import"); + return; + } + + setIsLoading(true); + setError(""); + setSuccess(""); + setImportStats(null); + + // Check if file is a CSV + if (file.type !== "text/csv" && !file.name.endsWith('.csv')) { + setError("Please upload a CSV file"); + setIsLoading(false); + return; + } + + const formData = new FormData(); + formData.append("file", file); + + try { + const response = await fetch("/api/contacts/import", { + method: "POST", + body: formData, + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Failed to import contacts"); + } + + const result = await response.json(); + + setSuccess(`Import successful! ${result.imported} contacts imported.`); + setImportStats({ + total: result.total, + imported: result.imported, + skipped: result.skipped, + }); + } catch (error: any) { + setError(error.message || "An error occurred during import"); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+

+ Upload a CSV file with contact information. The CSV should include headers and at least an Ethereum Address column. +

+

+ Supported columns: Name, Ethereum Address, ENS Name, Email, Twitter, Discord, Telegram, Farcaster +

+
+ +
+
+ + +

+ Max file size: 5MB +

+
+ + {file && ( +
+ Selected file: {file.name} ({(file.size / 1024).toFixed(2)} KB) +
+ )} + + {error && ( +
+
+
{error}
+
+
+ )} + + {success && ( +
+
+
{success}
+
+
+ )} + + {importStats && ( +
+

Import Summary:

+
    +
  • Total records processed: {importStats.total}
  • +
  • Successfully imported: {importStats.imported}
  • +
  • Skipped (duplicates/errors): {importStats.skipped}
  • +
+
+ )} + +
+ + +
+
+ + {!importStats && ( +
+

Example CSV Format:

+
+            {"Name,Ethereum Address,ENS Name,Email,Twitter,Discord,Telegram,Farcaster\n"}
+            {"John Doe,0x1234567890abcdef1234567890abcdef12345678,johndoe.eth,john@example.com,@johndoe,johndoe#1234,@johndoe,@johndoe\n"}
+            {"Jane Smith,0xabcdef1234567890abcdef1234567890abcdef12,,jane@example.com,@janesmith,,,"}
+          
+
+ )} +
+ ); +} \ No newline at end of file