lomavuokraus/app/api/listings/route.ts
Tero Halla-aho cb92a17f1d
Some checks failed
CI / checks (push) Has been cancelled
CI / checks (pull_request) Has been cancelled
Add on-site EV charging amenity
2025-12-17 13:40:47 +02:00

413 lines
18 KiB
TypeScript

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<T extends { locale: string }>(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<string, boolean>();
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 });
}
}