Allow saving listings as drafts

This commit is contained in:
Tero Halla-aho 2025-12-06 22:54:24 +02:00
parent dc37c521d8
commit 0f9699a0ec
6 changed files with 69 additions and 30 deletions

View file

@ -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). - 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. - 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/`. - 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`. - 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`. - 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. - 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.

View file

@ -195,6 +195,7 @@ export async function POST(req: Request) {
} }
const body = await req.json(); const body = await req.json();
const saveDraft = Boolean(body.saveDraft);
const slug = String(body.slug ?? '').trim().toLowerCase(); const slug = String(body.slug ?? '').trim().toLowerCase();
const country = String(body.country ?? '').trim(); const country = String(body.country ?? '').trim();
const region = String(body.region ?? '').trim(); const region = String(body.region ?? '').trim();
@ -203,14 +204,18 @@ export async function POST(req: Request) {
const contactName = String(body.contactName ?? '').trim(); const contactName = String(body.contactName ?? '').trim();
const contactEmail = String(body.contactEmail ?? '').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 }); return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
} }
const maxGuests = Number(body.maxGuests ?? 1); const maxGuests = body.maxGuests === undefined || body.maxGuests === null || body.maxGuests === '' ? null : Number(body.maxGuests);
const bedrooms = Number(body.bedrooms ?? 1); const bedrooms = body.bedrooms === undefined || body.bedrooms === null || body.bedrooms === '' ? null : Number(body.bedrooms);
const beds = Number(body.beds ?? 1); const beds = body.beds === undefined || body.beds === null || body.beds === '' ? null : Number(body.beds);
const bathrooms = Number(body.bathrooms ?? 1); const bathrooms = body.bathrooms === undefined || body.bathrooms === null || body.bathrooms === '' ? null : Number(body.bathrooms);
const priceWeekdayEuros = parsePrice(body.priceWeekdayEuros); const priceWeekdayEuros = parsePrice(body.priceWeekdayEuros);
const priceWeekendEuros = parsePrice(body.priceWeekendEuros); const priceWeekendEuros = parsePrice(body.priceWeekendEuros);
const calendarUrls = normalizeCalendarUrls(body.calendarUrls); const calendarUrls = normalizeCalendarUrls(body.calendarUrls);
@ -305,9 +310,18 @@ export async function POST(req: Request) {
parsedImages[0].isCover = true; parsedImages[0].isCover = true;
} }
const autoApprove = process.env.AUTO_APPROVE_LISTINGS === 'true' || auth.role === 'ADMIN'; if (!saveDraft) {
const status = autoApprove ? ListingStatus.PUBLISHED : ListingStatus.PENDING; if (!country || !region || !city || !contactEmail || !contactName || !maxGuests || !bedrooms || !beds || !bathrooms) {
const isSample = contactEmail.toLowerCase() === SAMPLE_EMAIL; 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({ const listing = await prisma.listing.create({
data: { data: {
@ -315,9 +329,9 @@ export async function POST(req: Request) {
status, status,
approvedAt: autoApprove ? new Date() : null, approvedAt: autoApprove ? new Date() : null,
approvedById: autoApprove && auth.role === 'ADMIN' ? user.id : null, approvedById: autoApprove && auth.role === 'ADMIN' ? user.id : null,
country, country: country || null,
region, region: region || null,
city, city: city || null,
streetAddress: streetAddress || null, streetAddress: streetAddress || null,
addressNote: body.addressNote ?? null, addressNote: body.addressNote ?? null,
latitude: body.latitude !== undefined && body.latitude !== null && body.latitude !== '' ? Number(body.latitude) : 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, priceWeekdayEuros,
priceWeekendEuros, priceWeekendEuros,
calendarUrls, calendarUrls,
contactName, contactName: contactName || null,
contactEmail, contactEmail: contactEmail || null,
contactPhone: body.contactPhone ?? null, contactPhone: body.contactPhone ?? null,
externalUrl: body.externalUrl ?? null, externalUrl: body.externalUrl ?? null,
published: status === ListingStatus.PUBLISHED, published: status === ListingStatus.PUBLISHED,

View file

@ -299,8 +299,8 @@ export default function NewListingPage() {
} }
} }
async function onSubmit(e: React.FormEvent) { async function submitListing(saveDraft: boolean, e?: React.FormEvent) {
e.preventDefault(); if (e) e.preventDefault();
setMessage(null); setMessage(null);
setError(null); setError(null);
setLoading(true); setLoading(true);
@ -318,7 +318,7 @@ export default function NewListingPage() {
return; return;
} }
if (selectedImages.length === 0) { if (!saveDraft && selectedImages.length === 0) {
setError(t('imagesRequired')); setError(t('imagesRequired'));
setLoading(false); setLoading(false);
return; return;
@ -328,6 +328,7 @@ export default function NewListingPage() {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
saveDraft,
slug, slug,
translations: translationEntries.map((t) => ({ translations: translationEntries.map((t) => ({
...t, ...t,
@ -371,7 +372,11 @@ export default function NewListingPage() {
if (!res.ok) { if (!res.ok) {
setError(data.error || 'Failed to create listing'); setError(data.error || 'Failed to create listing');
} else { } 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(''); setSlug('');
setTranslations({ setTranslations({
en: { title: '', description: '', teaser: '' }, en: { title: '', description: '', teaser: '' },
@ -427,7 +432,7 @@ export default function NewListingPage() {
return ( return (
<main className="panel" style={{ maxWidth: 1100, margin: '40px auto' }}> <main className="panel" style={{ maxWidth: 1100, margin: '40px auto' }}>
<h1>{t('createListingTitle')}</h1> <h1>{t('createListingTitle')}</h1>
<form onSubmit={onSubmit} style={{ display: 'grid', gap: 10 }}> <form onSubmit={(e) => submitListing(false, e)} style={{ display: 'grid', gap: 10 }}>
<div> <div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', marginBottom: 6 }}> <div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', marginBottom: 6 }}>
<strong>{t('languageTabsLabel')}</strong> <strong>{t('languageTabsLabel')}</strong>
@ -733,9 +738,14 @@ export default function NewListingPage() {
))} ))}
</div> </div>
) : null} ) : null}
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
<button className="button secondary" type="button" disabled={loading} onClick={(e) => submitListing(true, e)}>
{loading ? t('saving') : t('saveDraft')}
</button>
<button className="button" type="submit" disabled={loading}> <button className="button" type="submit" disabled={loading}>
{loading ? t('submittingListing') : t('submitListing')} {loading ? t('submittingListing') : t('submitListing')}
</button> </button>
</div>
</form> </form>
{message ? <p style={{ marginTop: 12, color: 'green' }}>{message}</p> : null} {message ? <p style={{ marginTop: 12, color: 'green' }}>{message}</p> : null}
{error ? <p style={{ marginTop: 12, color: 'red' }}>{error}</p> : null} {error ? <p style={{ marginTop: 12, color: 'red' }}>{error}</p> : null}

View file

@ -161,6 +161,7 @@ const baseMessages = {
aiApplyError: 'Could not read AI response. Please ensure it is valid JSON with a locales object.', aiApplyError: 'Could not read AI response. Please ensure it is valid JSON with a locales object.',
aiApplySuccess: 'Translations updated from AI response.', aiApplySuccess: 'Translations updated from AI response.',
translationMissing: 'Add at least one language with a title and description.', translationMissing: 'Add at least one language with a title and description.',
saveDraft: 'Save draft',
loginToCreate: 'Please log in first to create a listing.', loginToCreate: 'Please log in first to create a listing.',
slugLabel: 'Slug', slugLabel: 'Slug',
slugHelp: 'Unique link name, use lowercase letters and hyphens only (e.g. lake-cabin).', 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.', aiApplyError: 'Vastausta ei voitu lukea. Varmista, että se on kelvollista JSONia ja sisältää locales-avaimen.',
aiApplySuccess: 'Käännökset päivitetty AI-vastauksesta.', aiApplySuccess: 'Käännökset päivitetty AI-vastauksesta.',
translationMissing: 'Täytä vähintään yhden kielen otsikko ja kuvaus.', translationMissing: 'Täytä vähintään yhden kielen otsikko ja kuvaus.',
saveDraft: 'Tallenna luonnos',
ctaViewSample: 'Katso esimerkkikohde', ctaViewSample: 'Katso esimerkkikohde',
ctaHealth: 'Tarkista health-päätepiste', ctaHealth: 'Tarkista health-päätepiste',
ctaBrowse: 'Selaa kohteita', ctaBrowse: 'Selaa kohteita',
@ -615,6 +617,7 @@ const svMessages: Record<keyof typeof baseMessages.en, string> = {
aiApplyError: 'Kunde inte läsa AI-svaret. Se till att det är giltig JSON med locales-nyckel.', 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.', aiApplySuccess: 'Översättningar uppdaterades från AI-svaret.',
translationMissing: 'Lägg till minst ett språk med titel och beskrivning.', translationMissing: 'Lägg till minst ett språk med titel och beskrivning.',
saveDraft: 'Spara utkast',
slugChecking: 'Kontrollerar tillgänglighet…', slugChecking: 'Kontrollerar tillgänglighet…',
slugAvailable: 'Sluggen är ledig', slugAvailable: 'Sluggen är ledig',
slugTaken: 'Sluggen används redan', slugTaken: 'Sluggen används redan',

View file

@ -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;

View file

@ -71,17 +71,17 @@ model Listing {
removedAt DateTime? removedAt DateTime?
removedById String? removedById String?
removedReason String? removedReason String?
country String country String?
region String region String?
city String city String?
streetAddress String? streetAddress String?
addressNote String? addressNote String?
latitude Float? latitude Float?
longitude Float? longitude Float?
maxGuests Int maxGuests Int?
bedrooms Int bedrooms Int?
beds Int beds Int?
bathrooms Int bathrooms Int?
hasSauna Boolean @default(false) hasSauna Boolean @default(false)
hasFireplace Boolean @default(false) hasFireplace Boolean @default(false)
hasWifi Boolean @default(false) hasWifi Boolean @default(false)
@ -98,8 +98,8 @@ model Listing {
calendarUrls String[] @db.Text @default([]) calendarUrls String[] @db.Text @default([])
priceWeekdayEuros Int? priceWeekdayEuros Int?
priceWeekendEuros Int? priceWeekendEuros Int?
contactName String contactName String?
contactEmail String contactEmail String?
contactPhone String? contactPhone String?
externalUrl String? externalUrl String?
published Boolean @default(true) published Boolean @default(true)