480 lines
15 KiB
TypeScript
480 lines
15 KiB
TypeScript
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 });
|
|
}
|
|
}
|