import { ListingStatus, Role, UserStatus } from "@prisma/client"; import { NextResponse } from "next/server"; import { prisma } from "../../../../lib/prisma"; import { requireAuth } from "../../../../lib/jwt"; import { parsePrice, normalizeCalendarUrls } from "../route"; // reuse helpers const MAX_IMAGES = 6; const MAX_IMAGE_BYTES = 5 * 1024 * 1024; export async function GET( _req: Request, { params }: { params: { id: string } }, ) { try { const auth = await requireAuth(_req); const listing = await prisma.listing.findFirst({ where: { id: params.id, ownerId: auth.userId, removedAt: null }, include: { translations: true, images: { orderBy: { order: "asc" }, select: { id: true, altText: true, order: true, isCover: true, size: true, url: true, mimeType: true, }, }, }, }); if (!listing) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } return NextResponse.json({ listing }); } catch (err: any) { const status = err?.message === "Unauthorized" ? 401 : 500; return NextResponse.json({ error: "Failed to load listing" }, { status }); } } export async function PUT( req: Request, { params }: { params: { id: string } }, ) { try { const auth = await requireAuth(req); const user = await prisma.user.findUnique({ where: { id: auth.userId } }); if ( !user || !user.emailVerifiedAt || !user.approvedAt || user.status !== UserStatus.ACTIVE ) { return NextResponse.json( { error: "User not permitted to edit listings" }, { status: 403 }, ); } const existing = await prisma.listing.findFirst({ where: { id: params.id, ownerId: auth.userId, removedAt: null }, include: { translations: true, images: true }, }); if (!existing) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } const body = await req.json(); const saveDraft = Boolean(body.saveDraft); const baseSlug = String(body.slug ?? existing.translations[0]?.slug ?? "") .trim() .toLowerCase(); if (!baseSlug) { return NextResponse.json({ error: "Missing slug" }, { status: 400 }); } const country = String(body.country ?? existing.country ?? "").trim(); const region = String(body.region ?? existing.region ?? "").trim(); const city = String(body.city ?? existing.city ?? "").trim(); const streetAddress = String( body.streetAddress ?? existing.streetAddress ?? "", ).trim(); const contactName = String( body.contactName ?? existing.contactName ?? "", ).trim(); const contactEmail = String( body.contactEmail ?? existing.contactEmail ?? "", ).trim(); const maxGuests = body.maxGuests === undefined || body.maxGuests === null || body.maxGuests === "" ? existing.maxGuests : Number(body.maxGuests); const bedrooms = body.bedrooms === undefined || body.bedrooms === null || body.bedrooms === "" ? existing.bedrooms : Number(body.bedrooms); const beds = body.beds === undefined || body.beds === null || body.beds === "" ? existing.beds : Number(body.beds); const bathrooms = body.bathrooms === undefined || body.bathrooms === null || body.bathrooms === "" ? existing.bathrooms : Number(body.bathrooms); const priceWeekdayEuros = body.priceWeekdayEuros === undefined ? existing.priceWeekdayEuros : parsePrice(body.priceWeekdayEuros); const priceWeekendEuros = body.priceWeekendEuros === undefined ? existing.priceWeekendEuros : parsePrice(body.priceWeekendEuros); const calendarUrls = normalizeCalendarUrls( body.calendarUrls ?? existing.calendarUrls, ); const translationsInputRaw = Array.isArray(body.translations) ? body.translations : null; type TranslationInput = { locale: string; title: string; description: string; teaser: string | null; slug: string; }; const translationsInput: TranslationInput[] = translationsInputRaw?.map((item: any) => ({ locale: String(item.locale ?? "").toLowerCase(), title: typeof item.title === "string" ? item.title.trim() : "", description: typeof item.description === "string" ? item.description.trim() : "", teaser: typeof item.teaser === "string" ? item.teaser.trim() : null, slug: String(item.slug ?? baseSlug) .trim() .toLowerCase(), })) || []; const fallbackTranslationTitle = typeof body.title === "string" ? body.title.trim() : (existing.translations[0]?.title ?? ""); const fallbackTranslationDescription = typeof body.description === "string" ? body.description.trim() : (existing.translations[0]?.description ?? ""); const fallbackTranslationTeaser = typeof body.teaser === "string" ? body.teaser.trim() : (existing.translations[0]?.teaser ?? null); const fallbackLocale = String( body.locale ?? existing.translations[0]?.locale ?? "en", ).toLowerCase(); if ( translationsInput.length === 0 && (fallbackTranslationTitle || saveDraft) && (fallbackTranslationDescription || saveDraft) ) { translationsInput.push({ locale: fallbackLocale, title: fallbackTranslationTitle ?? "", description: fallbackTranslationDescription ?? "", teaser: fallbackTranslationTeaser, slug: baseSlug, }); } const imagesBody = Array.isArray(body.images) ? body.images.slice(0, MAX_IMAGES) : null; if (!saveDraft) { const missing: string[] = []; if (!country) missing.push("country"); if (!region) missing.push("region"); if (!city) missing.push("city"); if (!contactEmail) missing.push("contactEmail"); if (!contactName) missing.push("contactName"); if (!maxGuests) missing.push("maxGuests"); if (!bedrooms && bedrooms !== 0) missing.push("bedrooms"); if (!beds) missing.push("beds"); if (!bathrooms) missing.push("bathrooms"); if (!translationsInput.length && !existing.translations.length) missing.push("translations"); const hasImagesIncoming = imagesBody && imagesBody.length > 0; if (!hasImagesIncoming && existing.images.length === 0) missing.push("images"); if (missing.length) { return NextResponse.json( { error: `Missing required fields: ${missing.join(", ")}` }, { status: 400 }, ); } } let status = existing.status; const autoApprove = !saveDraft && (process.env.AUTO_APPROVE_LISTINGS === "true" || auth.role === Role.ADMIN); if (saveDraft) { status = ListingStatus.DRAFT; } else if (existing.status === ListingStatus.PUBLISHED) { status = ListingStatus.PUBLISHED; } else { status = autoApprove ? ListingStatus.PUBLISHED : ListingStatus.PENDING; } const parsedImages: { data?: Buffer; mimeType?: string | null; size?: number | null; url?: string | null; altText?: string | null; order: number; isCover: boolean; }[] = []; if (imagesBody) { const coverImageIndex = Math.min( Math.max(Number(body.coverImageIndex ?? 1), 1), imagesBody.length || 1, ); for (let idx = 0; idx < imagesBody.length; idx += 1) { const img = imagesBody[idx]; const altText = typeof img.altText === "string" && img.altText.trim() ? img.altText.trim() : null; const rawMime = typeof img.mimeType === "string" ? img.mimeType : null; const rawData = typeof img.data === "string" ? img.data : null; const rawUrl = typeof img.url === "string" && img.url.trim() ? img.url.trim() : null; let mimeType = rawMime; let buffer: Buffer | null = null; if (rawData) { const dataUrlMatch = rawData.match(/^data:(.*?);base64,(.*)$/); if (dataUrlMatch) { mimeType = mimeType || dataUrlMatch[1] || null; buffer = Buffer.from(dataUrlMatch[2], "base64"); } else { buffer = Buffer.from(rawData, "base64"); } } const size = buffer ? buffer.length : null; if (size && size > MAX_IMAGE_BYTES) { return NextResponse.json( { error: `Image ${idx + 1} is too large (max ${Math.floor(MAX_IMAGE_BYTES / 1024 / 1024)}MB)`, }, { status: 400 }, ); } if (!buffer && !rawUrl) { continue; } parsedImages.push({ data: buffer ?? undefined, mimeType: mimeType || "image/jpeg", size, url: buffer ? null : rawUrl, altText, order: idx + 1, isCover: coverImageIndex === idx + 1, }); } if (parsedImages.length && !parsedImages.some((img) => img.isCover)) { parsedImages[0].isCover = true; } } const incomingEvChargingOnSite = body.evChargingOnSite === undefined ? existing.evChargingOnSite : Boolean(body.evChargingOnSite); const incomingEvChargingAvailable = body.evChargingAvailable === undefined ? existing.evChargingAvailable : Boolean(body.evChargingAvailable); const evChargingAvailable = incomingEvChargingOnSite ? true : incomingEvChargingAvailable; const evChargingOnSite = evChargingAvailable ? incomingEvChargingOnSite : false; const updateData: any = { status, approvedAt: status === ListingStatus.PUBLISHED ? (existing.approvedAt ?? new Date()) : null, approvedById: status === ListingStatus.PUBLISHED && auth.role === Role.ADMIN ? auth.userId : existing.approvedById, country: country || null, region: region || null, city: city || null, streetAddress: streetAddress || null, addressNote: body.addressNote ?? existing.addressNote ?? null, latitude: body.latitude !== undefined && body.latitude !== null && body.latitude !== "" ? Number(body.latitude) : existing.latitude, longitude: body.longitude !== undefined && body.longitude !== null && body.longitude !== "" ? Number(body.longitude) : existing.longitude, maxGuests, bedrooms, beds, bathrooms, hasSauna: body.hasSauna === undefined ? existing.hasSauna : Boolean(body.hasSauna), hasFireplace: body.hasFireplace === undefined ? existing.hasFireplace : Boolean(body.hasFireplace), hasWifi: body.hasWifi === undefined ? existing.hasWifi : Boolean(body.hasWifi), petsAllowed: body.petsAllowed === undefined ? existing.petsAllowed : Boolean(body.petsAllowed), byTheLake: body.byTheLake === undefined ? existing.byTheLake : Boolean(body.byTheLake), hasAirConditioning: body.hasAirConditioning === undefined ? existing.hasAirConditioning : Boolean(body.hasAirConditioning), hasKitchen: body.hasKitchen === undefined ? existing.hasKitchen : Boolean(body.hasKitchen), hasDishwasher: body.hasDishwasher === undefined ? existing.hasDishwasher : Boolean(body.hasDishwasher), hasWashingMachine: body.hasWashingMachine === undefined ? existing.hasWashingMachine : Boolean(body.hasWashingMachine), hasBarbecue: body.hasBarbecue === undefined ? existing.hasBarbecue : Boolean(body.hasBarbecue), hasMicrowave: body.hasMicrowave === undefined ? existing.hasMicrowave : Boolean(body.hasMicrowave), hasFreeParking: body.hasFreeParking === undefined ? existing.hasFreeParking : Boolean(body.hasFreeParking), hasSkiPass: body.hasSkiPass === undefined ? existing.hasSkiPass : Boolean(body.hasSkiPass), evChargingAvailable, evChargingOnSite, wheelchairAccessible: body.wheelchairAccessible === undefined ? existing.wheelchairAccessible : Boolean(body.wheelchairAccessible), priceWeekdayEuros, priceWeekendEuros, calendarUrls, contactName: contactName || null, contactEmail: contactEmail || null, contactPhone: body.contactPhone ?? existing.contactPhone ?? null, externalUrl: body.externalUrl ?? existing.externalUrl ?? null, published: status === ListingStatus.PUBLISHED, }; const tx: any[] = []; if (translationsInput && translationsInput.length) { tx.push( prisma.listingTranslation.deleteMany({ where: { listingId: existing.id }, }), prisma.listingTranslation.createMany({ data: translationsInput.map((t) => ({ listingId: existing.id, locale: t.locale, slug: t.slug || baseSlug, title: t.title, description: t.description, teaser: t.teaser ?? null, })), }), ); } if (parsedImages && parsedImages.length) { tx.push( prisma.listingImage.deleteMany({ where: { listingId: existing.id } }), ); tx.push( prisma.listingImage.createMany({ data: parsedImages.map((img) => ({ listingId: existing.id, mimeType: img.mimeType || "image/jpeg", size: img.size ?? null, url: img.url ?? null, altText: img.altText ?? null, order: img.order, isCover: img.isCover, data: img.data ?? null, })), }), ); } tx.unshift( prisma.listing.update({ where: { id: existing.id }, data: updateData }), ); await prisma.$transaction(tx); const updated = await prisma.listing.findUnique({ where: { id: existing.id }, include: { translations: true, images: { orderBy: { order: "asc" }, select: { id: true, url: true, altText: true, order: true, isCover: true, size: true, mimeType: true, }, }, }, }); return NextResponse.json({ ok: true, listing: updated }); } catch (error: any) { console.error("Update listing error", error); const message = error?.code === "P2002" ? "Slug already exists for this locale" : "Failed to update listing"; const status = error?.message === "Unauthorized" ? 401 : 400; return NextResponse.json({ error: message }, { status }); } }