265 lines
10 KiB
TypeScript
265 lines
10 KiB
TypeScript
import { NextResponse } from 'next/server';
|
|
import { ListingStatus, UserStatus, EvCharging, 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';
|
|
|
|
const MAX_IMAGES = 6;
|
|
const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5 MB per image
|
|
const SAMPLE_EMAIL = 'host@lomavuokraus.fi';
|
|
|
|
function normalizeEvCharging(input?: string | null): EvCharging {
|
|
const value = String(input ?? 'NONE').toUpperCase();
|
|
if (value === 'FREE') return EvCharging.FREE;
|
|
if (value === 'PAID') return EvCharging.PAID;
|
|
return EvCharging.NONE;
|
|
}
|
|
|
|
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 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 ? normalizeEvCharging(evChargingParam) : null;
|
|
const locale = resolveLocale({ cookieLocale: null, acceptLanguage: req.headers.get('accept-language') });
|
|
const limit = Math.min(Number(searchParams.get('limit') ?? 40), 100);
|
|
|
|
const where: Prisma.ListingWhereInput = {
|
|
status: ListingStatus.PUBLISHED,
|
|
removedAt: null,
|
|
city: city ? { contains: city, mode: 'insensitive' } : undefined,
|
|
region: region ? { contains: region, mode: 'insensitive' } : undefined,
|
|
evCharging: evCharging ?? undefined,
|
|
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,
|
|
});
|
|
|
|
const payload = listings.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];
|
|
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,
|
|
evCharging: listing.evCharging,
|
|
maxGuests: listing.maxGuests,
|
|
bedrooms: listing.bedrooms,
|
|
beds: listing.beds,
|
|
bathrooms: listing.bathrooms,
|
|
priceHintPerNightEuros: listing.priceHintPerNightEuros,
|
|
coverImage: resolveImageUrl(listing.images.find((img) => img.isCover) ?? listing.images[0] ?? { id: '', url: null, size: null }),
|
|
isSample,
|
|
};
|
|
});
|
|
|
|
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 slug = String(body.slug ?? '').trim().toLowerCase();
|
|
const locale = String(body.locale ?? 'en').toLowerCase();
|
|
const title = String(body.title ?? '').trim();
|
|
const description = String(body.description ?? '').trim();
|
|
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 || !title || !description || !country || !region || !city || !contactEmail || !contactName) {
|
|
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
|
}
|
|
|
|
const maxGuests = Number(body.maxGuests ?? 1);
|
|
const bedrooms = Number(body.bedrooms ?? 1);
|
|
const beds = Number(body.beds ?? 1);
|
|
const bathrooms = Number(body.bathrooms ?? 1);
|
|
const priceHintPerNightEuros = body.priceHintPerNightEuros !== undefined && body.priceHintPerNightEuros !== null && body.priceHintPerNightEuros !== '' ? Math.round(Number(body.priceHintPerNightEuros)) : null;
|
|
|
|
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;
|
|
}
|
|
|
|
const autoApprove = process.env.AUTO_APPROVE_LISTINGS === 'true' || auth.role === 'ADMIN';
|
|
const status = autoApprove ? ListingStatus.PUBLISHED : ListingStatus.PENDING;
|
|
const isSample = contactEmail.toLowerCase() === SAMPLE_EMAIL;
|
|
|
|
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,
|
|
region,
|
|
city,
|
|
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),
|
|
evCharging: normalizeEvCharging(body.evCharging),
|
|
priceHintPerNightEuros,
|
|
contactName,
|
|
contactEmail,
|
|
contactPhone: body.contactPhone ?? null,
|
|
externalUrl: body.externalUrl ?? null,
|
|
published: status === ListingStatus.PUBLISHED,
|
|
isSample,
|
|
translations: {
|
|
create: {
|
|
locale,
|
|
slug,
|
|
title,
|
|
description,
|
|
teaser: body.teaser ?? null,
|
|
},
|
|
},
|
|
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 });
|
|
}
|
|
}
|