572 lines
19 KiB
TypeScript
572 lines
19 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 });
|
|
}
|
|
}
|