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).
- 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.

View file

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

View file

@ -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 (
<main className="panel" style={{ maxWidth: 1100, margin: '40px auto' }}>
<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 style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', marginBottom: 6 }}>
<strong>{t('languageTabsLabel')}</strong>
@ -733,9 +738,14 @@ export default function NewListingPage() {
))}
</div>
) : null}
<button className="button" type="submit" disabled={loading}>
{loading ? t('submittingListing') : t('submitListing')}
</button>
<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}>
{loading ? t('submittingListing') : t('submitListing')}
</button>
</div>
</form>
{message ? <p style={{ marginTop: 12, color: 'green' }}>{message}</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.',
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<keyof typeof baseMessages.en, string> = {
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',

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?
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)