From 0f9699a0ec2b1eba6f206e5723e6cd54372da0c0 Mon Sep 17 00:00:00 2001 From: Tero Halla-aho Date: Sat, 6 Dec 2025 22:54:24 +0200 Subject: [PATCH] Allow saving listings as drafts --- PROGRESS.md | 1 + app/api/listings/route.ts | 40 +++++++++++++------ app/listings/new/page.tsx | 26 ++++++++---- lib/i18n.ts | 3 ++ .../migration.sql | 11 +++++ prisma/schema.prisma | 18 ++++----- 6 files changed, 69 insertions(+), 30 deletions(-) create mode 100644 prisma/migrations/20250301_listing_drafts_nullable_fields/migration.sql diff --git a/PROGRESS.md b/PROGRESS.md index dd23f43..fbe5d78 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -31,6 +31,7 @@ - Access control tightened: middleware now gates admin routes, admin-only pages check session/role, API handlers return proper 401/403, and listing removal is limited to owners/admins (no more moderator overrides). - Security: added OWASP ZAP baseline helper (`scripts/zap-baseline.sh`) and documentation (`docs/security.html`) for quick unauthenticated scans against test/staging/prod. - Added master test suite runner (`scripts/run-test-suite.sh`) that executes npm audit, Trivy scan, and ZAP baseline and writes HTML summaries under `reports/runs/`. +- Listings: added draft saves; backend accepts draft status with nullable listing fields, and the new listing form has a “Save draft” option (publish still enforces required fields + images). - Backend/data: Added Prisma models (User/Listing/ListingTranslation/ListingImage), seed script creates sample listing; DB on Hetzner VM `46.62.203.202`, staging secrets set in `lomavuokraus-web-secrets`. - Auth: Register/login/verify flows; session cookie (`session_token`), NavBar shows email+role badge. Roles: USER, ADMIN, USER_MODERATOR (approve users), LISTING_MODERATOR (approve listings). Admin can change roles at `/admin/users`. - Listing flow: create listing (session required), pending/published with admin/moderator approvals; pages for “My listings,” “New listing,” “Profile.” Quick actions tile removed; all actions in navbar. diff --git a/app/api/listings/route.ts b/app/api/listings/route.ts index 86d5cb9..f8eb5f4 100644 --- a/app/api/listings/route.ts +++ b/app/api/listings/route.ts @@ -195,6 +195,7 @@ export async function POST(req: Request) { } 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(); @@ -203,14 +204,18 @@ export async function POST(req: Request) { const contactName = String(body.contactName ?? '').trim(); const contactEmail = String(body.contactEmail ?? '').trim(); - if (!slug || !country || !region || !city || !contactEmail || !contactName) { + if (!slug) { + return NextResponse.json({ error: 'Missing slug' }, { status: 400 }); + } + + if (!saveDraft && (!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 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); @@ -305,9 +310,18 @@ export async function POST(req: Request) { 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; + if (!saveDraft) { + if (!country || !region || !city || !contactEmail || !contactName || !maxGuests || !bedrooms || !beds || !bathrooms) { + return NextResponse.json({ error: 'Missing required fields for publish' }, { status: 400 }); + } + if (!parsedImages.length) { + return NextResponse.json({ error: 'At least one image is required to publish' }, { 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 listing = await prisma.listing.create({ data: { @@ -315,9 +329,9 @@ export async function POST(req: Request) { status, approvedAt: autoApprove ? new Date() : null, approvedById: autoApprove && auth.role === 'ADMIN' ? user.id : null, - country, - region, - city, + 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, @@ -342,8 +356,8 @@ export async function POST(req: Request) { priceWeekdayEuros, priceWeekendEuros, calendarUrls, - contactName, - contactEmail, + contactName: contactName || null, + contactEmail: contactEmail || null, contactPhone: body.contactPhone ?? null, externalUrl: body.externalUrl ?? null, published: status === ListingStatus.PUBLISHED, diff --git a/app/listings/new/page.tsx b/app/listings/new/page.tsx index 56200bc..0d9e132 100644 --- a/app/listings/new/page.tsx +++ b/app/listings/new/page.tsx @@ -299,8 +299,8 @@ export default function NewListingPage() { } } - async function onSubmit(e: React.FormEvent) { - e.preventDefault(); + async function submitListing(saveDraft: boolean, e?: React.FormEvent) { + if (e) e.preventDefault(); setMessage(null); setError(null); setLoading(true); @@ -318,7 +318,7 @@ export default function NewListingPage() { return; } - if (selectedImages.length === 0) { + if (!saveDraft && selectedImages.length === 0) { setError(t('imagesRequired')); setLoading(false); return; @@ -328,6 +328,7 @@ export default function NewListingPage() { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ + saveDraft, slug, translations: translationEntries.map((t) => ({ ...t, @@ -371,7 +372,11 @@ export default function NewListingPage() { if (!res.ok) { setError(data.error || 'Failed to create listing'); } else { - setMessage(t('createListingSuccess', { id: data.listing.id, status: data.listing.status })); + setMessage( + saveDraft + ? t('createListingSuccess', { id: data.listing.id, status: 'DRAFT' }) + : t('createListingSuccess', { id: data.listing.id, status: data.listing.status }), + ); setSlug(''); setTranslations({ en: { title: '', description: '', teaser: '' }, @@ -427,7 +432,7 @@ export default function NewListingPage() { return (

{t('createListingTitle')}

-
+ submitListing(false, e)} style={{ display: 'grid', gap: 10 }}>
{t('languageTabsLabel')} @@ -733,9 +738,14 @@ export default function NewListingPage() { ))}
) : null} - +
+ + +
{message ?

{message}

: null} {error ?

{error}

: null} diff --git a/lib/i18n.ts b/lib/i18n.ts index a7d097d..8eb1564 100644 --- a/lib/i18n.ts +++ b/lib/i18n.ts @@ -161,6 +161,7 @@ const baseMessages = { aiApplyError: 'Could not read AI response. Please ensure it is valid JSON with a locales object.', aiApplySuccess: 'Translations updated from AI response.', translationMissing: 'Add at least one language with a title and description.', + saveDraft: 'Save draft', loginToCreate: 'Please log in first to create a listing.', slugLabel: 'Slug', slugHelp: 'Unique link name, use lowercase letters and hyphens only (e.g. lake-cabin).', @@ -327,6 +328,7 @@ const baseMessages = { aiApplyError: 'Vastausta ei voitu lukea. Varmista, että se on kelvollista JSONia ja sisältää locales-avaimen.', aiApplySuccess: 'Käännökset päivitetty AI-vastauksesta.', translationMissing: 'Täytä vähintään yhden kielen otsikko ja kuvaus.', + saveDraft: 'Tallenna luonnos', ctaViewSample: 'Katso esimerkkikohde', ctaHealth: 'Tarkista health-päätepiste', ctaBrowse: 'Selaa kohteita', @@ -615,6 +617,7 @@ const svMessages: Record = { aiApplyError: 'Kunde inte läsa AI-svaret. Se till att det är giltig JSON med locales-nyckel.', aiApplySuccess: 'Översättningar uppdaterades från AI-svaret.', translationMissing: 'Lägg till minst ett språk med titel och beskrivning.', + saveDraft: 'Spara utkast', slugChecking: 'Kontrollerar tillgänglighet…', slugAvailable: 'Sluggen är ledig', slugTaken: 'Sluggen används redan', diff --git a/prisma/migrations/20250301_listing_drafts_nullable_fields/migration.sql b/prisma/migrations/20250301_listing_drafts_nullable_fields/migration.sql new file mode 100644 index 0000000..c16fa54 --- /dev/null +++ b/prisma/migrations/20250301_listing_drafts_nullable_fields/migration.sql @@ -0,0 +1,11 @@ +-- Allow draft listings with incomplete details +ALTER TABLE "Listing" + ALTER COLUMN "country" DROP NOT NULL, + ALTER COLUMN "region" DROP NOT NULL, + ALTER COLUMN "city" DROP NOT NULL, + ALTER COLUMN "maxGuests" DROP NOT NULL, + ALTER COLUMN "bedrooms" DROP NOT NULL, + ALTER COLUMN "beds" DROP NOT NULL, + ALTER COLUMN "bathrooms" DROP NOT NULL, + ALTER COLUMN "contactName" DROP NOT NULL, + ALTER COLUMN "contactEmail" DROP NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f15f591..f1997af 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -71,17 +71,17 @@ model Listing { removedAt DateTime? removedById String? removedReason String? - country String - region String - city String + country String? + region String? + city String? streetAddress String? addressNote String? latitude Float? longitude Float? - maxGuests Int - bedrooms Int - beds Int - bathrooms Int + maxGuests Int? + bedrooms Int? + beds Int? + bathrooms Int? hasSauna Boolean @default(false) hasFireplace Boolean @default(false) hasWifi Boolean @default(false) @@ -98,8 +98,8 @@ model Listing { calendarUrls String[] @db.Text @default([]) priceWeekdayEuros Int? priceWeekendEuros Int? - contactName String - contactEmail String + contactName String? + contactEmail String? contactPhone String? externalUrl String? published Boolean @default(true)