import { NextResponse } from "next/server"; import { ListingStatus, UserStatus, Prisma } from "@prisma/client"; import { prisma } from "../../../lib/prisma"; import { requireAuth } from "../../../lib/jwt"; import { resolveLocale } from "../../../lib/i18n"; import { SAMPLE_LISTING_SLUGS } from "../../../lib/sampleListing"; import { getCalendarRanges, isRangeAvailable } from "../../../lib/calendar"; const MAX_IMAGES = 6; const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5 MB per image const SAMPLE_EMAIL = "host@lomavuokraus.fi"; function resolveImageUrl(img: { id: string; url: string | null; size: number | null; }) { if (img.size && img.size > 0) { return `/api/images/${img.id}`; } return img.url ?? null; } function pickTranslation( translations: T[], locale: string | null, ): T | null { if (!translations.length) return null; if (locale) { const exact = translations.find((t) => t.locale === locale); if (exact) return exact; } return translations[0]; } export function normalizeCalendarUrls(input: unknown): string[] { if (Array.isArray(input)) { return input .map((u) => (typeof u === "string" ? u.trim() : "")) .filter(Boolean) .slice(0, 5); } if (typeof input === "string") { return input .split(/\n|,/) .map((u) => u.trim()) .filter(Boolean) .slice(0, 5); } return []; } export function parsePrice(value: unknown): number | null { if (value === undefined || value === null || value === "") return null; const num = Number(value); if (Number.isNaN(num)) return null; return Math.round(num); } export async function GET(req: Request) { const url = new URL(req.url); const searchParams = url.searchParams; const q = searchParams.get("q")?.trim(); const city = searchParams.get("city")?.trim(); const region = searchParams.get("region")?.trim(); const evChargingParam = searchParams.get("evCharging"); const evCharging = evChargingParam === "true" ? true : evChargingParam === "false" ? false : null; const evChargingOnSiteParam = searchParams.get("evChargingOnSite"); const evChargingOnSite = evChargingOnSiteParam === "true" ? true : evChargingOnSiteParam === "false" ? false : null; const startDateParam = searchParams.get("availableStart"); const endDateParam = searchParams.get("availableEnd"); const startDate = startDateParam ? new Date(startDateParam) : null; const endDate = endDateParam ? new Date(endDateParam) : null; const availabilityFilterActive = Boolean( startDate && endDate && startDate < endDate, ); const amenityFilters = searchParams .getAll("amenity") .map((a) => a.trim().toLowerCase()); const locale = resolveLocale({ cookieLocale: null, acceptLanguage: req.headers.get("accept-language"), }); const limit = Math.min(Number(searchParams.get("limit") ?? 40), 100); const amenityWhere: Prisma.ListingWhereInput = {}; if (amenityFilters.includes("sauna")) amenityWhere.hasSauna = true; if (amenityFilters.includes("fireplace")) amenityWhere.hasFireplace = true; if (amenityFilters.includes("wifi")) amenityWhere.hasWifi = true; if (amenityFilters.includes("pets")) amenityWhere.petsAllowed = true; if (amenityFilters.includes("lake")) amenityWhere.byTheLake = true; if (amenityFilters.includes("ac")) amenityWhere.hasAirConditioning = true; if (amenityFilters.includes("kitchen")) amenityWhere.hasKitchen = true; if (amenityFilters.includes("dishwasher")) amenityWhere.hasDishwasher = true; if (amenityFilters.includes("washer")) amenityWhere.hasWashingMachine = true; if (amenityFilters.includes("barbecue")) amenityWhere.hasBarbecue = true; if (amenityFilters.includes("microwave")) amenityWhere.hasMicrowave = true; if (amenityFilters.includes("parking")) amenityWhere.hasFreeParking = true; if (amenityFilters.includes("skipass")) amenityWhere.hasSkiPass = true; if (amenityFilters.includes("accessible")) amenityWhere.wheelchairAccessible = true; if (amenityFilters.includes("ev-onsite")) amenityWhere.evChargingOnSite = true; const where: Prisma.ListingWhereInput = { status: ListingStatus.PUBLISHED, removedAt: null, city: city ? { contains: city, mode: "insensitive" } : undefined, region: region ? { contains: region, mode: "insensitive" } : undefined, evChargingAvailable: evCharging ?? undefined, evChargingOnSite: evChargingOnSite ?? undefined, ...amenityWhere, translations: q ? { some: { OR: [ { title: { contains: q, mode: "insensitive" } }, { description: { contains: q, mode: "insensitive" } }, { teaser: { contains: q, mode: "insensitive" } }, ], }, } : undefined, }; const listings = await prisma.listing.findMany({ where, include: { translations: { select: { id: true, locale: true, title: true, slug: true, teaser: true, description: true, }, }, images: { select: { id: true, url: true, altText: true, order: true, isCover: true, size: true, }, orderBy: { order: "asc" }, }, }, orderBy: { createdAt: "desc" }, take: Number.isNaN(limit) ? 40 : limit, }); let filteredListings = listings; let availabilityMap = new Map(); if (availabilityFilterActive) { const checks = await Promise.all( listings.map(async (listing) => { const urls = listing.calendarUrls ?? []; if (!urls.length) return { id: listing.id, available: false }; const ranges = await getCalendarRanges(urls); const available = isRangeAvailable(ranges, startDate!, endDate!); return { id: listing.id, available }; }), ); availabilityMap = new Map(checks.map((c) => [c.id, c.available])); filteredListings = listings.filter((l) => availabilityMap.get(l.id)); } const payload = filteredListings.map((listing) => { const isSample = listing.isSample || listing.contactEmail === SAMPLE_EMAIL || SAMPLE_LISTING_SLUGS.includes( pickTranslation(listing.translations, locale)?.slug ?? listing.translations[0]?.slug ?? "", ); const translation = pickTranslation(listing.translations, locale); const fallback = listing.translations[0]; const validCalendarUrls = (listing.calendarUrls ?? []).filter((url) => { if (!url) return false; try { const parsed = new URL(url); return parsed.protocol === "http:" || parsed.protocol === "https:"; } catch { return false; } }); return { id: listing.id, title: translation?.title ?? fallback?.title ?? "Listing", slug: translation?.slug ?? fallback?.slug ?? "", teaser: translation?.teaser ?? translation?.description ?? fallback?.description ?? null, locale: translation?.locale ?? fallback?.locale ?? locale, country: listing.country, region: listing.region, city: listing.city, streetAddress: listing.streetAddress, addressNote: listing.addressNote, latitude: listing.latitude, longitude: listing.longitude, hasSauna: listing.hasSauna, hasFireplace: listing.hasFireplace, hasWifi: listing.hasWifi, petsAllowed: listing.petsAllowed, byTheLake: listing.byTheLake, hasAirConditioning: listing.hasAirConditioning, hasKitchen: listing.hasKitchen, hasDishwasher: listing.hasDishwasher, hasWashingMachine: listing.hasWashingMachine, hasBarbecue: listing.hasBarbecue, hasMicrowave: listing.hasMicrowave, hasFreeParking: listing.hasFreeParking, hasSkiPass: listing.hasSkiPass, evChargingAvailable: listing.evChargingAvailable, evChargingOnSite: listing.evChargingOnSite, wheelchairAccessible: listing.wheelchairAccessible, maxGuests: listing.maxGuests, bedrooms: listing.bedrooms, beds: listing.beds, bathrooms: listing.bathrooms, priceWeekdayEuros: listing.priceWeekdayEuros, priceWeekendEuros: listing.priceWeekendEuros, coverImage: resolveImageUrl( listing.images.find((img) => img.isCover) ?? listing.images[0] ?? { id: "", url: null, size: null }, ), isSample, hasCalendar: Boolean(validCalendarUrls.length), availableForDates: availabilityFilterActive ? Boolean(availabilityMap.get(listing.id)) : undefined, }; }); return NextResponse.json({ listings: payload }); } export async function POST(req: Request) { 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 create listings" }, { status: 403 }, ); } const body = await req.json(); const saveDraft = Boolean(body.saveDraft); const slug = String(body.slug ?? "") .trim() .toLowerCase(); const country = String(body.country ?? "").trim(); const region = String(body.region ?? "").trim(); const city = String(body.city ?? "").trim(); const streetAddress = String(body.streetAddress ?? "").trim(); const contactName = String(body.contactName ?? "").trim(); const contactEmail = String(body.contactEmail ?? "").trim(); if (!slug) { return NextResponse.json({ error: "Missing slug" }, { status: 400 }); } const maxGuests = body.maxGuests === undefined || body.maxGuests === null || body.maxGuests === "" ? null : Number(body.maxGuests); const bedrooms = body.bedrooms === undefined || body.bedrooms === null || body.bedrooms === "" ? null : Number(body.bedrooms); const beds = body.beds === undefined || body.beds === null || body.beds === "" ? null : Number(body.beds); const bathrooms = body.bathrooms === undefined || body.bathrooms === null || body.bathrooms === "" ? null : Number(body.bathrooms); const priceWeekdayEuros = parsePrice(body.priceWeekdayEuros); const priceWeekendEuros = parsePrice(body.priceWeekendEuros); const calendarUrls = normalizeCalendarUrls(body.calendarUrls); const translationsInputRaw = Array.isArray(body.translations) ? body.translations : []; type TranslationInput = { locale: string; title: string; description: string; teaser: string | null; slug: string; }; let translationsInput = 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 ?? slug) .trim() .toLowerCase(), })) .filter( (t: any) => t.locale && (saveDraft || (t.title && t.description)), ) || []; const fallbackLocale = String(body.locale ?? "en").toLowerCase(); const fallbackTranslationTitle = typeof body.title === "string" ? body.title.trim() : ""; const fallbackTranslationDescription = typeof body.description === "string" ? body.description.trim() : ""; const fallbackTranslationTeaser = typeof body.teaser === "string" ? body.teaser.trim() : null; if ( translationsInput.length === 0 && (fallbackTranslationTitle || saveDraft) && (fallbackTranslationDescription || saveDraft) ) { translationsInput.push({ locale: fallbackLocale, title: fallbackTranslationTitle ?? "", description: fallbackTranslationDescription ?? "", teaser: fallbackTranslationTeaser, slug, }); } if (!translationsInput.length && !saveDraft) { return NextResponse.json( { error: "Missing translation fields (title/description)" }, { status: 400 }, ); } const images = Array.isArray(body.images) ? body.images.slice(0, MAX_IMAGES) : []; if (Array.isArray(body.images) && body.images.length > MAX_IMAGES) { return NextResponse.json( { error: `Too many images (max ${MAX_IMAGES})` }, { status: 400 }, ); } const coverImageIndex = Math.min( Math.max(Number(body.coverImageIndex ?? 1), 1), images.length || 1, ); const parsedImages: { data?: Buffer; mimeType?: string | null; size?: number | null; url?: string | null; altText?: string | null; order: number; isCover: boolean; }[] = []; for (let idx = 0; idx < images.length; idx += 1) { const img = images[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; } if (!saveDraft) { const missingFields: string[] = []; if (!country) missingFields.push("country"); if (!region) missingFields.push("region"); if (!city) missingFields.push("city"); if (!contactEmail) missingFields.push("contactEmail"); if (!contactName) missingFields.push("contactName"); if (!maxGuests) missingFields.push("maxGuests"); if (!bedrooms && bedrooms !== 0) missingFields.push("bedrooms"); if (!beds) missingFields.push("beds"); if (!bathrooms) missingFields.push("bathrooms"); if (!translationsInput.length) missingFields.push("translations"); if (!parsedImages.length) missingFields.push("images"); if (missingFields.length) { return NextResponse.json( { error: `Missing required fields: ${missingFields.join(", ")}` }, { status: 400 }, ); } } const autoApprove = !saveDraft && (process.env.AUTO_APPROVE_LISTINGS === "true" || auth.role === "ADMIN"); const status = saveDraft ? ListingStatus.DRAFT : autoApprove ? ListingStatus.PUBLISHED : ListingStatus.PENDING; const isSample = (contactEmail || "").toLowerCase() === SAMPLE_EMAIL; const evChargingOnSite = Boolean(body.evChargingOnSite); const evChargingAvailable = Boolean(body.evChargingAvailable) || evChargingOnSite; const wheelchairAccessible = Boolean(body.wheelchairAccessible); const listing = await prisma.listing.create({ data: { ownerId: user.id, status, approvedAt: autoApprove ? new Date() : null, approvedById: autoApprove && auth.role === "ADMIN" ? user.id : null, country: country || null, region: region || null, city: city || null, streetAddress: streetAddress || null, addressNote: body.addressNote ?? null, latitude: body.latitude !== undefined && body.latitude !== null && body.latitude !== "" ? Number(body.latitude) : null, longitude: body.longitude !== undefined && body.longitude !== null && body.longitude !== "" ? Number(body.longitude) : null, maxGuests, bedrooms, beds, bathrooms, hasSauna: Boolean(body.hasSauna), hasFireplace: Boolean(body.hasFireplace), hasWifi: Boolean(body.hasWifi), petsAllowed: Boolean(body.petsAllowed), byTheLake: Boolean(body.byTheLake), hasAirConditioning: Boolean(body.hasAirConditioning), hasKitchen: Boolean(body.hasKitchen), hasDishwasher: Boolean(body.hasDishwasher), hasWashingMachine: Boolean(body.hasWashingMachine), hasBarbecue: Boolean(body.hasBarbecue), hasMicrowave: Boolean(body.hasMicrowave), hasFreeParking: Boolean(body.hasFreeParking), hasSkiPass: Boolean(body.hasSkiPass), evChargingAvailable, evChargingOnSite, wheelchairAccessible, priceWeekdayEuros, priceWeekendEuros, calendarUrls, contactName: contactName || null, contactEmail: contactEmail || null, contactPhone: body.contactPhone ?? null, externalUrl: body.externalUrl ?? null, published: status === ListingStatus.PUBLISHED, isSample, translations: translationsInput.length ? { create: translationsInput.map((t: TranslationInput) => ({ locale: t.locale, slug: t.slug || slug, title: t.title, description: t.description, teaser: t.teaser ?? null, })), } : undefined, images: parsedImages.length ? { create: parsedImages, } : undefined, }, include: { translations: true, images: { select: { id: true, altText: true, order: true, isCover: true, size: true, url: true, }, }, }, }); return NextResponse.json({ ok: true, listing }); } catch (error: any) { console.error("Create listing error", error); const message = error?.code === "P2002" ? "Slug already exists for this locale" : "Failed to create listing"; return NextResponse.json({ error: message }, { status: 400 }); } }