From c6ff651f21b6ed0219387d32276c22392c35179f Mon Sep 17 00:00:00 2001 From: brian Date: Tue, 27 Jan 2026 16:56:57 -0500 Subject: [PATCH] vendor slugs routes instead of ids --- app/(auth)/vendors/{[id] => [slug]}/page.tsx | 10 +- app/api/vendors/[id]/route.ts | 242 ++++++++++-------- app/api/vendors/create/route.ts | 13 + components/forms/CreateVendorForm.tsx | 1 - components/tables/VendorsTable.tsx | 3 +- lib/utils/slugify.ts | 30 +++ .../migration.sql | 11 + .../migration.sql | 8 + .../migration.sql | 35 +++ prisma/schema.prisma | 14 + 10 files changed, 247 insertions(+), 120 deletions(-) rename app/(auth)/vendors/{[id] => [slug]}/page.tsx (88%) create mode 100644 lib/utils/slugify.ts create mode 100644 prisma/migrations/20260127213236_add_slug_to_vendor/migration.sql create mode 100644 prisma/migrations/20260127213325_add_slug_to_vendor_required/migration.sql create mode 100644 prisma/migrations/20260127213920_add_indexes_to_vendor/migration.sql diff --git a/app/(auth)/vendors/[id]/page.tsx b/app/(auth)/vendors/[slug]/page.tsx similarity index 88% rename from app/(auth)/vendors/[id]/page.tsx rename to app/(auth)/vendors/[slug]/page.tsx index 1c28947..e3c31f8 100644 --- a/app/(auth)/vendors/[id]/page.tsx +++ b/app/(auth)/vendors/[slug]/page.tsx @@ -4,16 +4,16 @@ import { prisma } from '@/lib/prisma' import { VendorDetailPage } from '@/components/vendor/VendorDetailPage' interface PageProps { - params: { - id: string - } + params: { + slug: string + } } export default async function Page({ params }: PageProps) { - const { id } = params + const { slug } = params const vendor = await prisma.vendor.findUnique({ - where: { id }, + where: { slug }, include: { address: true, events: { diff --git a/app/api/vendors/[id]/route.ts b/app/api/vendors/[id]/route.ts index dd79e2e..0ca4614 100644 --- a/app/api/vendors/[id]/route.ts +++ b/app/api/vendors/[id]/route.ts @@ -2,123 +2,139 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' import { VendorType, VendorStatus } from '@prisma/client' +import { generateUniqueSlug, slugify } from '@/lib/utils/slugify' export async function PUT( - request: NextRequest, - { params }: { params: { id: string } } + request: NextRequest, + { params }: { params: { id: string } } ) { - try { - const id = params.id - const body = await request.json() - - // Check if vendor exists - const existingVendor = await prisma.vendor.findUnique({ - where: { id }, - include: { address: true } - }) - - if (!existingVendor) { - return NextResponse.json( - { error: 'Vendor not found' }, - { status: 404 } - ) - } - - // Parse numeric values - const parseFloatOrNull = (value: any): number | null => { - if (value === null || value === undefined || value === '') return null - const num = parseFloat(value) - return isNaN(num) ? null : num - } - - const parseDateOrNull = (value: any): Date | null => { - if (!value) return null - const date = new Date(value) - return isNaN(date.getTime()) ? null : date - } - - // Handle address update/creation - let addressId = existingVendor.addressId - const hasAddress = body.street && body.city && body.state && body.postalCode - - if (hasAddress) { - if (existingVendor.address) { - // Update existing address - await prisma.address.update({ - where: { id: existingVendor.addressId! }, - data: { - street: body.street, - city: body.city, - state: body.state, - zip: parseInt(body.postalCode), - } + try { + const id = params.id + const body = await request.json() + + // Check if vendor exists + const existingVendor = await prisma.vendor.findUnique({ + where: { id }, + include: { address: true } }) - } else { - // Create new address - const newAddress = await prisma.address.create({ - data: { - street: body.street, - city: body.city, - state: body.state, - zip: parseInt(body.postalCode), - } + + if (!existingVendor) { + return NextResponse.json( + { error: 'Vendor not found' }, + { status: 404 } + ) + } + + let slug = existingVendor.slug + if (body.name && body.name !== existingVendor.name) { + const baseSlug = slugify(body.name) + slug = await generateUniqueSlug( + baseSlug, + async (testSlug) => { + const existing = await prisma.vendor.findUnique({ + where: { slug: testSlug } + }) + return !!existing && existing.id !== id + } + ) + } + + // Parse numeric values + const parseFloatOrNull = (value: any): number | null => { + if (value === null || value === undefined || value === '') return null + const num = parseFloat(value) + return isNaN(num) ? null : num + } + + const parseDateOrNull = (value: any): Date | null => { + if (!value) return null + const date = new Date(value) + return isNaN(date.getTime()) ? null : date + } + + // Handle address update/creation + let addressId = existingVendor.addressId + const hasAddress = body.street && body.city && body.state && body.postalCode + + if (hasAddress) { + if (existingVendor.address) { + // Update existing address + await prisma.address.update({ + where: { id: existingVendor.addressId! }, + data: { + street: body.street, + city: body.city, + state: body.state, + zip: parseInt(body.postalCode), + } + }) + } else { + // Create new address + const newAddress = await prisma.address.create({ + data: { + street: body.street, + city: body.city, + state: body.state, + zip: parseInt(body.postalCode), + } + }) + addressId = newAddress.id + } + } else if (existingVendor.address) { + // Remove address if it existed but now doesn't + await prisma.address.delete({ + where: { id: existingVendor.addressId! } + }) + addressId = null + } + + // Prepare update data + const updateData: any = { + slug, + name: body.name, + type: body.type as VendorType, + description: body.description || null, + website: body.website || null, + contactPerson: body.contactPerson || null, + email: body.email || null, + phone: body.phone || null, + status: (body.status as VendorStatus) || 'CONTACTING', + isBooked: Boolean(body.isBooked), + bookedDate: parseDateOrNull(body.bookedDate), + quotedPrice: parseFloatOrNull(body.quotedPrice), + finalCost: parseFloatOrNull(body.finalCost), + depositPaid: parseFloatOrNull(body.depositPaid), + depositDueDate: parseDateOrNull(body.depositDueDate), + finalPaymentDue: parseDateOrNull(body.finalPaymentDue), + paymentNotes: body.paymentNotes || null, + contractUrl: body.contractUrl || null, + proposalUrl: body.proposalUrl || null, + notes: body.notes || null, + } + + // Only include addressId if we have one + if (addressId) { + updateData.addressId = addressId + } else { + updateData.addressId = null + } + + // Update vendor + const updatedVendor = await prisma.vendor.update({ + where: { id }, + data: updateData, + include: { + address: !!addressId + } }) - addressId = newAddress.id - } - } else if (existingVendor.address) { - // Remove address if it existed but now doesn't - await prisma.address.delete({ - where: { id: existingVendor.addressId! } - }) - addressId = null + + return NextResponse.json(updatedVendor) + + } catch (error: any) { + console.error('Error updating vendor:', error) + return NextResponse.json( + { error: 'Failed to update vendor', details: error.message }, + { status: 500 } + ) } - - // Prepare update data - const updateData: any = { - name: body.name, - type: body.type as VendorType, - description: body.description || null, - website: body.website || null, - contactPerson: body.contactPerson || null, - email: body.email || null, - phone: body.phone || null, - status: (body.status as VendorStatus) || 'CONTACTING', - isBooked: Boolean(body.isBooked), - bookedDate: parseDateOrNull(body.bookedDate), - quotedPrice: parseFloatOrNull(body.quotedPrice), - finalCost: parseFloatOrNull(body.finalCost), - depositPaid: parseFloatOrNull(body.depositPaid), - depositDueDate: parseDateOrNull(body.depositDueDate), - finalPaymentDue: parseDateOrNull(body.finalPaymentDue), - paymentNotes: body.paymentNotes || null, - contractUrl: body.contractUrl || null, - proposalUrl: body.proposalUrl || null, - notes: body.notes || null, - } - - // Only include addressId if we have one - if (addressId) { - updateData.addressId = addressId - } else { - updateData.addressId = null - } - - // Update vendor - const updatedVendor = await prisma.vendor.update({ - where: { id }, - data: updateData, - include: { - address: !!addressId - } - }) - - return NextResponse.json(updatedVendor) - - } catch (error: any) { - console.error('Error updating vendor:', error) - return NextResponse.json( - { error: 'Failed to update vendor', details: error.message }, - { status: 500 } - ) - } } \ No newline at end of file diff --git a/app/api/vendors/create/route.ts b/app/api/vendors/create/route.ts index 526a280..7cc06e5 100644 --- a/app/api/vendors/create/route.ts +++ b/app/api/vendors/create/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { prisma } from '@/lib/prisma' import { VendorType, VendorStatus } from '@prisma/client' +import { generateUniqueSlug, slugify } from '@/lib/utils/slugify' export async function POST(request: NextRequest) { try { @@ -15,6 +16,17 @@ export async function POST(request: NextRequest) { { status: 400 } ) } + + const baseSlug = slugify(body.name) + const uniqueSlug = await generateUniqueSlug( + baseSlug, + async (slug) => { + const existing = await prisma.vendor.findUnique({ + where: { slug } + }) + return !!existing + } + ) // Check if we have address data const hasAddress = body.street && body.city && body.state && body.postalCode @@ -34,6 +46,7 @@ export async function POST(request: NextRequest) { // Prepare vendor data WITHOUT addressId const vendorData: any = { + slug: uniqueSlug, name: body.name, type: body.type as VendorType, description: body.description || null, diff --git a/components/forms/CreateVendorForm.tsx b/components/forms/CreateVendorForm.tsx index c6c6128..1613bbc 100644 --- a/components/forms/CreateVendorForm.tsx +++ b/components/forms/CreateVendorForm.tsx @@ -5,7 +5,6 @@ import { Input } from '../ui/input' import { Button } from '../ui/button' import { Label } from '../ui/label' import { toast } from 'sonner' -import { Address, Vendor } from '@prisma/client' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select' import { Textarea } from '../ui/textarea' import { Checkbox } from '../ui/checkbox' diff --git a/components/tables/VendorsTable.tsx b/components/tables/VendorsTable.tsx index 6852a7a..797296a 100644 --- a/components/tables/VendorsTable.tsx +++ b/components/tables/VendorsTable.tsx @@ -14,6 +14,7 @@ import { useRouter } from 'next/navigation' interface VendorRow { id: string + slug: string name: string type: VendorType description?: string | null @@ -189,7 +190,7 @@ export default function VendorsTable({ initialVendors }: Props) { // Handle row click const handleRowClick = (vendor: VendorRow) => { - router.push(`/vendors/${vendor.id}`) + router.push(`/vendors/${vendor.slug}`) } return ( diff --git a/lib/utils/slugify.ts b/lib/utils/slugify.ts new file mode 100644 index 0000000..2e552c8 --- /dev/null +++ b/lib/utils/slugify.ts @@ -0,0 +1,30 @@ +export function slugify(text: string): string { + return text + .toString() + .toLowerCase() + .trim() + .replace(/\s+/g, '-') + .replace(/[^\w\-]+/g, '') + .replace(/\-\-+/g, '-') + .replace(/^-+/, '') + .replace(/-+$/, '') +} + +export async function generateUniqueSlug( + baseSlug: string, + checkUnique: (slug: string) => Promise, + maxAttempts = 10 +): Promise { + let slug = baseSlug + let attempt = 1 + + while (await checkUnique(slug)) { + if (attempt >= maxAttempts) { + throw new Error(`Could not generate unique slug after ${maxAttempts} attempts`) + } + slug = `${baseSlug}-${attempt}` + attempt++ + } + + return slug +} \ No newline at end of file diff --git a/prisma/migrations/20260127213236_add_slug_to_vendor/migration.sql b/prisma/migrations/20260127213236_add_slug_to_vendor/migration.sql new file mode 100644 index 0000000..691b9dd --- /dev/null +++ b/prisma/migrations/20260127213236_add_slug_to_vendor/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - A unique constraint covering the columns `[slug]` on the table `Vendor` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "Vendor" ADD COLUMN "slug" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "Vendor_slug_key" ON "Vendor"("slug"); diff --git a/prisma/migrations/20260127213325_add_slug_to_vendor_required/migration.sql b/prisma/migrations/20260127213325_add_slug_to_vendor_required/migration.sql new file mode 100644 index 0000000..0fb4e4d --- /dev/null +++ b/prisma/migrations/20260127213325_add_slug_to_vendor_required/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Made the column `slug` on table `Vendor` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "Vendor" ALTER COLUMN "slug" SET NOT NULL; diff --git a/prisma/migrations/20260127213920_add_indexes_to_vendor/migration.sql b/prisma/migrations/20260127213920_add_indexes_to_vendor/migration.sql new file mode 100644 index 0000000..8c38567 --- /dev/null +++ b/prisma/migrations/20260127213920_add_indexes_to_vendor/migration.sql @@ -0,0 +1,35 @@ +-- CreateIndex +CREATE INDEX "Vendor_slug_idx" ON "Vendor"("slug"); + +-- CreateIndex +CREATE INDEX "Vendor_name_idx" ON "Vendor"("name"); + +-- CreateIndex +CREATE INDEX "Vendor_type_idx" ON "Vendor"("type"); + +-- CreateIndex +CREATE INDEX "Vendor_status_idx" ON "Vendor"("status"); + +-- CreateIndex +CREATE INDEX "Vendor_isBooked_idx" ON "Vendor"("isBooked"); + +-- CreateIndex +CREATE INDEX "Vendor_bookedDate_idx" ON "Vendor"("bookedDate"); + +-- CreateIndex +CREATE INDEX "Vendor_finalCost_idx" ON "Vendor"("finalCost"); + +-- CreateIndex +CREATE INDEX "Vendor_createdAt_idx" ON "Vendor"("createdAt"); + +-- CreateIndex +CREATE INDEX "Vendor_type_status_idx" ON "Vendor"("type", "status"); + +-- CreateIndex +CREATE INDEX "Vendor_isBooked_type_idx" ON "Vendor"("isBooked", "type"); + +-- CreateIndex +CREATE INDEX "Vendor_depositDueDate_idx" ON "Vendor"("depositDueDate"); + +-- CreateIndex +CREATE INDEX "Vendor_finalPaymentDue_idx" ON "Vendor"("finalPaymentDue"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a546a0a..21a8728 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -148,6 +148,7 @@ model Vendor { id String @id @default(cuid()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + slug String @unique name String type VendorType @@ -178,6 +179,19 @@ model Vendor { events Event[] categories Category[] + + @@index([slug]) + @@index([name]) + @@index([type]) + @@index([status]) + @@index([isBooked]) + @@index([bookedDate]) + @@index([finalCost]) + @@index([createdAt]) + @@index([type, status]) + @@index([isBooked, type]) + @@index([depositDueDate]) + @@index([finalPaymentDue]) } model Address {