Allow saving listings as drafts
This commit is contained in:
parent
dc37c521d8
commit
0f9699a0ec
6 changed files with 69 additions and 30 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue