From 0bb709d9c504a601c8740c8315c8ed8b9944dd8b Mon Sep 17 00:00:00 2001 From: Tero Halla-aho Date: Wed, 4 Feb 2026 12:43:03 +0200 Subject: [PATCH] chore: fix audit alerts and formatting --- .forgejo/workflows/ci.yml | 2 +- .sops.yaml | 2 +- AGENTS.md | 1 + PROGRESS.md | 11 +- app/about/page.tsx | 47 +- app/admin/monitor/page.tsx | 512 +++- app/admin/pending/page.tsx | 221 +- app/admin/settings/page.tsx | 78 +- app/admin/users/page.tsx | 144 +- app/api/admin/listings/approve/route.ts | 41 +- app/api/admin/monitor/route.ts | 33 +- app/api/admin/pending/count/route.ts | 33 +- app/api/admin/pending/route.ts | 40 +- app/api/admin/settings/route.ts | 40 +- app/api/admin/users/approve/route.ts | 47 +- app/api/admin/users/reject/route.ts | 38 +- app/api/admin/users/remove/route.ts | 40 +- app/api/admin/users/role/route.ts | 30 +- app/api/admin/users/route.ts | 31 +- app/api/auth/forgot/route.ts | 27 +- app/api/auth/login/route.ts | 59 +- app/api/auth/logout/route.ts | 6 +- app/api/auth/me/route.ts | 22 +- app/api/auth/register/route.ts | 41 +- app/api/auth/reset/route.ts | 58 +- app/api/auth/verify/route.ts | 28 +- app/api/health/route.ts | 9 +- app/api/images/[id]/route.ts | 19 +- app/api/integrations/billing/verify/route.ts | 84 +- app/api/listings/[id]/availability/route.ts | 42 +- .../listings/[id]/images/[imageId]/route.ts | 62 +- app/api/listings/[id]/route.ts | 382 ++- app/api/listings/check-slug/route.ts | 8 +- app/api/listings/mine/route.ts | 25 +- app/api/listings/remove/route.ts | 30 +- app/api/listings/route.ts | 397 ++- app/api/listings/translate/route.ts | 96 +- app/api/me/billing/route.ts | 174 +- app/api/me/route.ts | 48 +- app/auth/forgot/page.tsx | 46 +- app/auth/login/page.tsx | 58 +- app/auth/register/page.tsx | 71 +- app/auth/reset/page.tsx | 73 +- app/components/AvailabilityCalendar.tsx | 179 +- app/components/I18nProvider.tsx | 30 +- app/components/NavBar.tsx | 227 +- app/components/SiteFooter.tsx | 26 +- app/globals.css | 83 +- app/layout.tsx | 14 +- app/listings/[slug]/page.tsx | 412 ++- app/listings/edit/[id]/page.tsx | 1054 +++++-- app/listings/mine/page.tsx | 101 +- app/listings/new/page.tsx | 895 ++++-- app/listings/page.tsx | 523 ++-- app/me/page.tsx | 429 ++- app/page.tsx | 117 +- app/pricing/page.tsx | 55 +- app/privacy/page.tsx | 62 +- app/verify/page.tsx | 36 +- creds/kubeconfig.enc.yaml | 70 +- deploy/README.md | 13 +- docs/architecture.html | 113 +- docs/build.html | 98 +- docs/git-workflow.html | 102 +- docs/git-workflow.md | 27 +- docs/index.html | 21 +- docs/infra.html | 154 +- docs/logging.md | 2 + docs/redmine.html | 72 +- docs/secrets.md | 10 + docs/security.html | 85 +- docs/sequences.html | 15 +- docs/style.css | 12 +- forgejo/README.md | 25 +- forgejo/docker-compose.yml | 2 +- k8s/app.yaml | 10 +- k8s/deployment.yaml | 1 - lib/apiKeys.ts | 8 +- lib/auth.ts | 7 +- lib/billing.ts | 33 +- lib/calendar.ts | 67 +- lib/i18n.ts | 1769 ++++++------ lib/jwt.ts | 47 +- lib/listings.ts | 86 +- lib/loadSecrets.ts | 27 +- lib/mailer.ts | 27 +- lib/monitoring.ts | 157 +- lib/prisma.ts | 19 +- lib/sampleListing.ts | 24 +- lib/settings.ts | 24 +- lib/tokens.ts | 4 +- middleware.ts | 31 +- next-env.d.ts | 3 +- next.config.mjs | 6 +- package-lock.json | 2475 +++++------------ package.json | 11 +- prisma.config.ts | 14 +- prisma/seed.js | 549 ++-- scripts/redmine-report.js | 88 +- scripts/reset-admin-password.js | 24 +- tsconfig.json | 17 +- types/nodemailer.d.ts | 15 + 102 files changed, 8408 insertions(+), 5455 deletions(-) create mode 100644 types/nodemailer.d.ts diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 6a5e4b5..1596adf 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: "20" - run: npm ci - run: npm run lint - run: npm run type-check diff --git a/.sops.yaml b/.sops.yaml index 2e38471..1988a9a 100644 --- a/.sops.yaml +++ b/.sops.yaml @@ -5,4 +5,4 @@ creation_rules: - age: - age1hkehkc2rryjl975c2mg5cghmjr54n4wjshncl292h2eg5l394fhs4uydrh - age1ducvqxdzdhhluftu5hv4f2xsppmn803uh8tnnqj92v4n7nf6lprq9h3dqp - encrypted_regex: '^(AUTH_SECRET|DATABASE_URL|DB_.*|APP_URL|SMTP_.*|DKIM_.*|AUTO_APPROVE_LISTINGS|OPENAI_.*|N8N_BILLING_API_KEY|H(ETZNER|CLOUD)_TOKEN|JOKER_DYNDNS_.*|REGISTRY_.*|NETDATA_.*|ADMIN_.*)$' + encrypted_regex: "^(AUTH_SECRET|DATABASE_URL|DB_.*|APP_URL|SMTP_.*|DKIM_.*|AUTO_APPROVE_LISTINGS|OPENAI_.*|N8N_BILLING_API_KEY|H(ETZNER|CLOUD)_TOKEN|JOKER_DYNDNS_.*|REGISTRY_.*|NETDATA_.*|ADMIN_.*)$" diff --git a/AGENTS.md b/AGENTS.md index 40d2465..c90f5b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,7 @@ - Always assume work should continue on a feature or fix branch; if unsure whether to reuse a previous branch (which might already be pushed and deleted), ask before proceeding. ## Ongoing reminders + - If needed, render diagrams locally from `docs/plantuml` or `docs/drawio` for reference. - Keep registry health in mind; pushes now work but monitor for regressions. - Future app work ideas: polish translations, add listing fields, expand admin tooling, or harden registry. diff --git a/PROGRESS.md b/PROGRESS.md index 243417a..9a48f80 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -15,7 +15,7 @@ - k3s v1.33.5 installed; kubeconfig in `k3s.yaml` (git-ignored) and `~/.kube/config` - Namespaces: `lomavuokraus-prod`, `lomavuokraus-staging` - cert-manager v1.15.3 installed; ClusterIssuers `letsencrypt-prod`/`staging` - - App deployed to both namespaces; ingress host rules in place via Traefik +- App deployed to both namespaces; ingress host rules in place via Traefik - DNS: `lomavuokraus.fi`, `staging.lomavuokraus.fi`, `api.lomavuokraus.fi` all A -> `157.180.66.64` (updated via Joker DYNDNS). - Registry issue (open): - Builds succeed and image `registry.halla-aho.net:443/thalla/lomavuokraus-web:1763823196` exists locally and was imported into k3s via `ctr import`. @@ -26,6 +26,7 @@ - `creds/` and `k3s.yaml` are git-ignored; contains joker DYNDNS creds and registry auth. ## 2025-11-24 — Lomavuokraus app progress + - New testing DB (`lomavuokraus_testing`) holds the previous staging/prod data; the main `lomavuokraus` DB was recreated clean with only the seeded admin user. Migration history was copied, and a schema snapshot lives at `docs/db-schema.sql`. - Testing environment wiring added: dedicated namespace (`lomavuokraus-test`), deploy wrapper (`deploy/deploy-test.sh`), API host support, and a DNS updater for `test.lomavuokraus.fi` / `apitest.lomavuokraus.fi`. - 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). @@ -41,6 +42,7 @@ - Security: `npm audit --audit-level=high` runs in build (warnings only). Trivy scan run; remaining CVEs mostly in tooling (cross-spawn, glob) and base OS Debian 12.10. Further reduction would require eslint-config-next 16.x and base image updates when available. ## 2025-11-24 — Recent changes + - Public browse/search page with map, address filters, and EV charging amenity; listings now store street address and geocoordinates. - Amenities expanded: electric vehicle charging (free/paid) and air conditioning; cover image selectable per listing and used in cards. - Home page shows a rolling feed of latest listings; navbar + CTA link to browse. @@ -71,6 +73,7 @@ - Mermaid docs fixed: all sequence diagrams declare their participants and avoid “->” inside message text; the listing creation diagram message was rewritten to prevent parse errors. Use mermaid.live or browser console to debug future syntax issues (errors flag the offending line/column). ## 2025-11-27 — Availability & filters + - Availability calendars: listings can store iCal URLs, merged into a combined availability calendar on detail pages; availability filtering added to search along with amenity filters; new migration `20251127_calendar_urls`. - Browse amenity filters now show the same icons as listing detail; image `registry.halla-aho.net/thalla/lomavuokraus-web:e95d9e0` built/pushed and rolled out to staging. - Home hero cleaned up (removed sample/browse CTAs), hero FI text updated, and health check link moved to About page runtime section. @@ -82,10 +85,12 @@ - Security hardening: npm audit now passes cleanly after upgrading Prisma patch release and pinning `glob@10.5.0` via overrides to eliminate the glob CLI injection advisory in eslint tooling. ## 2025-12-06 — Pricing & amenities + - Listings now capture separate weekday/weekend prices and new amenities (microwave, free parking) across schema, API, UI, and seeds. - Deployed pricing/amenity update image `registry.halla-aho.net/thalla/lomavuokraus-web:bee691e` to staging and production. ## 2025-12-17 — Accessibility & admin UX + - New amenities added: kitchen, dishwasher, washing machine, barbecue; API/UI/i18n updated and seeds randomized to populate missing prices/amenities. Prisma migration `20250210_more_amenities` applied to shared DB; registry pull secret added to k8s Deployment to avoid image pull errors in prod. - Added About and Pricing pages (FI/EN), moved highlights/runtime config to About, and linked footer navigation. - Added site favicon generated from the updated logo (`public/favicon.ico`). @@ -100,10 +105,14 @@ - User nav: consolidated profile/my listings/new listing/logout under an email dropdown with chevron; admin button also shows a chevron indicator. ## 2025-12-20 — Migration history repair + - Restored missing migration `20251212_agent_billing` (agent billing columns + listing billing settings table) so Prisma history matches the DB. - Reconciled test DB migration history: aligned checksum for `20251212_agent_billing` and marked `20260310_site_settings` and `20260311_billing_preferences` as applied to stop Prisma errors and surface listings again. - Applied the same agent billing schema to staging/prod DB (`lomavuokraus`) and marked the migration as applied; Prisma status now clean there too. - Deploy script now runs a Prisma migration status preflight using DATABASE_URL from env or in-cluster secret and fails fast on drift before applying manifests. ## 2026-02-04 — Documentation + - Expanded Logical Architecture doc with a Next.js App Router walkthrough: routing layout, server/client component split, data fetching patterns, mutations, auth middleware, and asset handling. +- Security/maintenance: bumped Next.js to 15.5.11, Prisma stack to 7.3.0, removed @types/nodemailer (local typings) to clear high npm audit alerts; `npm audit --audit-level=high` now passes. +- QA: fixed lint/type regressions from the upgrades (async headers/cookies, Link navigation), added local nodemailer typings, and formatted the repo with Prettier (`format:check` clean). diff --git a/app/about/page.tsx b/app/about/page.tsx index 6435973..563a8c5 100644 --- a/app/about/page.tsx +++ b/app/about/page.tsx @@ -1,29 +1,31 @@ -'use client'; +"use client"; -import Link from 'next/link'; -import { useI18n } from '../components/I18nProvider'; +import Link from "next/link"; +import { useI18n } from "../components/I18nProvider"; const highlights = [ - { keyTitle: 'highlightQualityTitle', keyBody: 'highlightQualityBody' }, - { keyTitle: 'highlightLocalTitle', keyBody: 'highlightLocalBody' }, - { keyTitle: 'highlightApiTitle', keyBody: 'highlightApiBody' }, + { keyTitle: "highlightQualityTitle", keyBody: "highlightQualityBody" }, + { keyTitle: "highlightLocalTitle", keyBody: "highlightLocalBody" }, + { keyTitle: "highlightApiTitle", keyBody: "highlightApiBody" }, ]; export default function AboutPage() { const { t } = useI18n(); - const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'; - const apiBase = process.env.NEXT_PUBLIC_API_BASE || 'http://localhost:3000/api'; - const appEnv = process.env.APP_ENV || 'local'; - const appVersion = process.env.NEXT_PUBLIC_VERSION || 'dev'; + const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000"; + const apiBase = + process.env.NEXT_PUBLIC_API_BASE || "http://localhost:3000/api"; + const appEnv = process.env.APP_ENV || "local"; + const appVersion = process.env.NEXT_PUBLIC_VERSION || "dev"; return (
- {t('homeCrumb')} / {t('aboutTitle')} + {t("homeCrumb")} /{" "} + {t("aboutTitle")}
-

{t('aboutTitle')}

-

{t('aboutLead')}

+

{t("aboutTitle")}

+

{t("aboutLead")}

@@ -36,25 +38,30 @@ export default function AboutPage() {
-

{t('runtimeConfigTitle')}

-

{t('runtimeConfigLead')}

+

{t("runtimeConfigTitle")}

+

{t("runtimeConfigLead")}

- {t('runtimeAppEnv')} {appEnv} + {t("runtimeAppEnv")} {appEnv} - {t('runtimeSiteUrl')} {siteUrl} + {t("runtimeSiteUrl")} {siteUrl} - {t('runtimeApiBase')} {apiBase} + {t("runtimeApiBase")} {apiBase} Version {appVersion}
- - {t('ctaHealth')} + + {t("ctaHealth")}
diff --git a/app/admin/monitor/page.tsx b/app/admin/monitor/page.tsx index d7d4747..75af576 100644 --- a/app/admin/monitor/page.tsx +++ b/app/admin/monitor/page.tsx @@ -1,7 +1,7 @@ -'use client'; +"use client"; -import { useEffect, useMemo, useState } from 'react'; -import { useI18n } from '../../components/I18nProvider'; +import { useEffect, useMemo, useState } from "react"; +import { useI18n } from "../../components/I18nProvider"; type HetznerServer = { id: number; @@ -15,7 +15,12 @@ type HetznerServer = { }; type MonitorResponse = { - hetzner?: { ok: boolean; error?: string; missingToken?: boolean; servers?: HetznerServer[] }; + hetzner?: { + ok: boolean; + error?: string; + missingToken?: boolean; + servers?: HetznerServer[]; + }; k8s?: { ok: boolean; error?: string; @@ -42,16 +47,29 @@ type MonitorResponse = { hostIP?: string | null; podIP?: string | null; startedAt?: string | null; - containers: { name: string; ready: boolean; restartCount: number; state: string; lastState?: string | null }[]; + containers: { + name: string; + ready: boolean; + restartCount: number; + state: string; + lastState?: string | null; + }[]; }[]; }; - db?: { ok: boolean; error?: string; serverTime?: string; recovery?: boolean; databaseSizeBytes?: number; connections?: { state: string; count: number }[] }; + db?: { + ok: boolean; + error?: string; + serverTime?: string; + recovery?: boolean; + databaseSizeBytes?: number; + connections?: { state: string; count: number }[]; + }; }; const REFRESH_MS = 30000; function formatBytes(bytes?: number) { - if (!bytes || Number.isNaN(bytes)) return '—'; + if (!bytes || Number.isNaN(bytes)) return "—"; if (bytes < 1024) return `${bytes} B`; const kb = bytes / 1024; if (kb < 1024) return `${kb.toFixed(1)} KB`; @@ -62,12 +80,12 @@ function formatBytes(bytes?: number) { } function formatDurationFrom(dateStr?: string | null) { - if (!dateStr) return '—'; + if (!dateStr) return "—"; const started = new Date(dateStr).getTime(); const diff = Date.now() - started; - if (Number.isNaN(diff) || diff < 0) return '—'; + if (Number.isNaN(diff) || diff < 0) return "—"; const mins = Math.floor(diff / 60000); - if (mins < 1) return '<1m'; + if (mins < 1) return "<1m"; if (mins < 60) return `${mins}m`; const hours = Math.floor(mins / 60); if (hours < 24) return `${hours}h`; @@ -79,12 +97,12 @@ function statusPill(label: string, ok: boolean) { return ( {label} @@ -99,23 +117,26 @@ export default function MonitorPage() { const [error, setError] = useState(null); const [lastUpdated, setLastUpdated] = useState(null); - const hasData = useMemo(() => Boolean(data?.hetzner || data?.k8s || data?.db), [data]); + const hasData = useMemo( + () => Boolean(data?.hetzner || data?.k8s || data?.db), + [data], + ); async function load() { setLoading(true); setError(null); try { - const res = await fetch('/api/admin/monitor', { cache: 'no-store' }); + const res = await fetch("/api/admin/monitor", { cache: "no-store" }); const json = (await res.json()) as MonitorResponse & { error?: string }; if (!res.ok) { - setError(json.error || t('monitorLoadFailed')); + setError(json.error || t("monitorLoadFailed")); setLoading(false); return; } setData(json); setLastUpdated(Date.now()); } catch (err) { - setError(t('monitorLoadFailed')); + setError(t("monitorLoadFailed")); } finally { setLoading(false); } @@ -129,85 +150,188 @@ export default function MonitorPage() { }, []); return ( -
-
+
+
-

{t('monitorTitle')}

-

{t('monitorLead')}

+

{t("monitorTitle")}

+

{t("monitorLead")}

-
- - - {t('monitorLastUpdated')}: {lastUpdated ? new Date(lastUpdated).toLocaleTimeString() : '—'} + + {t("monitorLastUpdated")}:{" "} + {lastUpdated ? new Date(lastUpdated).toLocaleTimeString() : "—"}
- {error ?

{error}

: null} - {!hasData && !loading ?

{t('monitorNoData')}

: null} + {error ?

{error}

: null} + {!hasData && !loading ? ( +

{t("monitorNoData")}

+ ) : null} -
-
-

{t('monitorHetznerTitle')}

- {data?.hetzner?.ok ? statusPill(t('monitorHealthy'), true) : statusPill(t('monitorAttention'), false)} +
+
+

{t("monitorHetznerTitle")}

+ {data?.hetzner?.ok + ? statusPill(t("monitorHealthy"), true) + : statusPill(t("monitorAttention"), false)}
{!data?.hetzner ? ( -

{t('monitorNoData')}

+

{t("monitorNoData")}

) : data.hetzner.missingToken ? ( -

{t('monitorHetznerMissingToken')}

+

{t("monitorHetznerMissingToken")}

) : data.hetzner.ok && (data.hetzner.servers?.length ?? 0) > 0 ? ( -
    +
      {data.hetzner.servers!.map((s) => ( -
    • -
      +
    • +
      - {s.name} — {s.type ?? 'server'} ({s.datacenter ?? 'dc'}) + {s.name} — {s.type ?? "server"} ( + {s.datacenter ?? "dc"})
      - {statusPill(s.status, s.status?.toLowerCase() === 'running')} + {statusPill(s.status, s.status?.toLowerCase() === "running")}
      -
      - Public IP: {s.publicIp ?? '—'} - Private IP: {s.privateIp ?? '—'} - {t('monitorCreated')} {s.created ? new Date(s.created).toLocaleString() : '—'} +
      + Public IP: {s.publicIp ?? "—"} + Private IP: {s.privateIp ?? "—"} + + {t("monitorCreated")}{" "} + {s.created ? new Date(s.created).toLocaleString() : "—"} +
    • ))}
    ) : ( -

    {data.hetzner.error || t('monitorHetznerEmpty')}

    +

    + {data.hetzner.error || t("monitorHetznerEmpty")} +

    )}
-
-
-

{t('monitorK8sTitle')}

- {data?.k8s?.ok ? statusPill(t('monitorHealthy'), true) : statusPill(t('monitorAttention'), false)} +
+
+

{t("monitorK8sTitle")}

+ {data?.k8s?.ok + ? statusPill(t("monitorHealthy"), true) + : statusPill(t("monitorAttention"), false)}
{!data?.k8s ? ( -

{t('monitorNoData')}

+

{t("monitorNoData")}

) : !data.k8s.ok ? ( -

{data.k8s.error ?? t('monitorLoadFailed')}

+

+ {data.k8s.error ?? t("monitorLoadFailed")} +

) : ( <>
-

{t('monitorNodesTitle')}

-
+

{t("monitorNodesTitle")}

+
{(data.k8s.nodes ?? []).map((n) => ( -
-
+
+
{n.name} -
{n.roles.length ? n.roles.join(', ') : 'node'}
+
+ {n.roles.length ? n.roles.join(", ") : "node"} +
{statusPill(n.status, n.ready)}
-
-
IP: {n.internalIp ?? '—'}
-
{n.kubeletVersion ?? 'kubelet'} · {n.osImage ?? ''}
-
- {t('monitorLastReady')}: {n.lastReadyTransition ? new Date(n.lastReadyTransition).toLocaleString() : '—'} +
+
IP: {n.internalIp ?? "—"}
+
+ {n.kubeletVersion ?? "kubelet"} · {n.osImage ?? ""} +
+
+ {t("monitorLastReady")}:{" "} + {n.lastReadyTransition + ? new Date(n.lastReadyTransition).toLocaleString() + : "—"}
@@ -216,43 +340,153 @@ export default function MonitorPage() {
-

{t('monitorPodsTitle')}

+

{t("monitorPodsTitle")}

{(data.k8s.pods ?? []).length === 0 ? ( -

{t('monitorNoPods')}

+

{t("monitorNoPods")}

) : ( -
- +
+
- - - - - - - + + + + + + + {(data.k8s.pods ?? []).map((p) => ( - - + - - - + + + - - ))} @@ -264,39 +498,111 @@ export default function MonitorPage() { )} -
-
-

{t('monitorDbTitle')}

- {data?.db?.ok ? statusPill(t('monitorHealthy'), true) : statusPill(t('monitorAttention'), false)} +
+
+

{t("monitorDbTitle")}

+ {data?.db?.ok + ? statusPill(t("monitorHealthy"), true) + : statusPill(t("monitorAttention"), false)}
{!data?.db ? ( -

{t('monitorNoData')}

+

{t("monitorNoData")}

) : !data.db.ok ? ( -

{data.db.error ?? t('monitorLoadFailed')}

+

+ {data.db.error ?? t("monitorLoadFailed")} +

) : ( <> -
-
-
{t('monitorServerTime')}
-
{data.db.serverTime ? new Date(data.db.serverTime).toLocaleString() : '—'}
+
+
+
+ {t("monitorServerTime")} +
+
+ {data.db.serverTime + ? new Date(data.db.serverTime).toLocaleString() + : "—"} +
-
-
{t('monitorDbSize')}
-
{formatBytes(data.db.databaseSizeBytes)}
+
+
+ {t("monitorDbSize")} +
+
+ {formatBytes(data.db.databaseSizeBytes)} +
-
-
{t('monitorDbRecovery')}
-
{data.db.recovery ? t('yes') : t('no')}
+
+
+ {t("monitorDbRecovery")} +
+
+ {data.db.recovery ? t("yes") : t("no")} +
-

{t('monitorConnections')}

+

{t("monitorConnections")}

{(data.db.connections ?? []).length === 0 ? ( -

{t('monitorNoData')}

+

{t("monitorNoData")}

) : ( -
    +
      {data.db.connections!.map((c) => ( -
    • +
    • {c.count} {c.state}
    • ))} diff --git a/app/admin/pending/page.tsx b/app/admin/pending/page.tsx index 168e4a0..05d2ed0 100644 --- a/app/admin/pending/page.tsx +++ b/app/admin/pending/page.tsx @@ -1,9 +1,16 @@ -'use client'; +"use client"; -import { useEffect, useState } from 'react'; -import { useI18n } from '../../components/I18nProvider'; +import { useEffect, useState } from "react"; +import { useI18n } from "../../components/I18nProvider"; -type PendingUser = { id: string; email: string; status: string; emailVerifiedAt: string | null; approvedAt: string | null; role: string }; +type PendingUser = { + id: string; + email: string; + status: string; + emailVerifiedAt: string | null; + approvedAt: string | null; + role: string; +}; type PendingListing = { id: string; status: string; @@ -27,74 +34,81 @@ export default function PendingAdminPage() { setMessage(null); setError(null); try { - const res = await fetch('/api/admin/pending', { cache: 'no-store' }); + const res = await fetch("/api/admin/pending", { cache: "no-store" }); const data = await res.json(); if (!res.ok) { - setError(data.error || 'Failed to load'); + setError(data.error || "Failed to load"); return; } setRole(data.role ?? null); setPendingUsers(data.users ?? []); setPendingListings(data.listings ?? []); } catch (e) { - setError('Failed to load'); + setError("Failed to load"); } } useEffect(() => { - fetch('/api/auth/me', { cache: 'no-store' }) + fetch("/api/auth/me", { cache: "no-store" }) .then((res) => res.json()) .then((data) => { setRole(data.user?.role ?? null); if (!data.user?.role) { - setError(t('adminRequired')); + setError(t("adminRequired")); return; } - if (['ADMIN', 'USER_MODERATOR', 'LISTING_MODERATOR'].includes(data.user.role)) { + if ( + ["ADMIN", "USER_MODERATOR", "LISTING_MODERATOR"].includes( + data.user.role, + ) + ) { loadPending(); return; } - setError(t('adminRequired')); + setError(t("adminRequired")); }) - .catch(() => setError(t('adminRequired'))); + .catch(() => setError(t("adminRequired"))); }, [t]); async function approveUser(userId: string, makeAdmin: boolean) { setMessage(null); setError(null); - const res = await fetch('/api/admin/users/approve', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const res = await fetch("/api/admin/users/approve", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userId, makeAdmin }), }); const data = await res.json(); if (!res.ok) { - setError(data.error || 'Failed to approve user'); + setError(data.error || "Failed to approve user"); } else { - setMessage(t('userUpdated')); + setMessage(t("userUpdated")); loadPending(); } } - async function approveListing(listingId: string, action: 'approve' | 'reject' | 'remove') { + async function approveListing( + listingId: string, + action: "approve" | "reject" | "remove", + ) { setMessage(null); setError(null); const reason = - action === 'reject' - ? window.prompt(`${t('reject')}? (optional)`) - : action === 'remove' - ? window.prompt(`${t('remove')}? (optional)`) + action === "reject" + ? window.prompt(`${t("reject")}? (optional)`) + : action === "remove" + ? window.prompt(`${t("remove")}? (optional)`) : null; - const res = await fetch('/api/admin/listings/approve', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const res = await fetch("/api/admin/listings/approve", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ listingId, action, reason }), }); const data = await res.json(); if (!res.ok) { - setError(data.error || 'Failed to update listing'); + setError(data.error || "Failed to update listing"); } else { - setMessage(t('approvalsMessage')); + setMessage(t("approvalsMessage")); loadPending(); } } @@ -102,45 +116,68 @@ export default function PendingAdminPage() { async function rejectUser(userId: string) { setMessage(null); setError(null); - const reason = window.prompt(`${t('reject')}? (optional)`); - const res = await fetch('/api/admin/users/reject', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const reason = window.prompt(`${t("reject")}? (optional)`); + const res = await fetch("/api/admin/users/reject", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userId, reason }), }); const data = await res.json(); if (!res.ok) { - setError(data.error || 'Failed to reject user'); + setError(data.error || "Failed to reject user"); } else { - setMessage(t('userUpdated')); + setMessage(t("userUpdated")); loadPending(); } } return ( -
      -

      {t('pendingAdminTitle')}

      -
      +
      +

      {t("pendingAdminTitle")}

      +
      -

      {t('pendingUsersTitle')}

      +

      {t("pendingUsersTitle")}

      {pendingUsers.length === 0 ? ( -

      {t('noPendingUsers')}

      +

      {t("noPendingUsers")}

      ) : ( -
        +
          {pendingUsers.map((u) => ( -
        • +
        • - {u.email} — {t('statusLabel')}: {u.status} — {t('verifiedLabel')}: {u.emailVerifiedAt ? t('yes') : t('no')} + {u.email} — {t("statusLabel")}: {u.status}{" "} + — {t("verifiedLabel")}:{" "} + {u.emailVerifiedAt ? t("yes") : t("no")}
          -
          - - -
        • @@ -149,33 +186,79 @@ export default function PendingAdminPage() { )}
      -

      {t('pendingListingsTitle')}

      +

      {t("pendingListingsTitle")}

      {pendingListings.length === 0 ? ( -

      {t('noPendingListings')}

      +

      {t("noPendingListings")}

      ) : ( -
        +
          {pendingListings.map((l) => ( -
        • +
        • - {l.translations[0]?.title ?? 'Listing'} — owner: {l.owner.email} + {l.translations[0]?.title ?? "Listing"} — + owner: {l.owner.email}
          -
          - {l.evChargingOnSite ? {t('amenityEvOnSite')} : null} - {l.evChargingAvailable && !l.evChargingOnSite ? {t('amenityEvNearby')} : null} - {l.wheelchairAccessible ? {t('amenityWheelchairAccessible')} : null} +
          + {l.evChargingOnSite ? ( + {t("amenityEvOnSite")} + ) : null} + {l.evChargingAvailable && !l.evChargingOnSite ? ( + {t("amenityEvNearby")} + ) : null} + {l.wheelchairAccessible ? ( + + {t("amenityWheelchairAccessible")} + + ) : null}
          -
          - {t('slugsLabel')}: {l.translations.map((t) => `${t.slug} (${t.locale})`).join(', ')} +
          + {t("slugsLabel")}:{" "} + {l.translations + .map((t) => `${t.slug} (${t.locale})`) + .join(", ")}
          -
          - - -
        • @@ -184,8 +267,10 @@ export default function PendingAdminPage() { )}
      - {message ?

      {message}

      : null} - {error ?

      {error}

      : null} + {message ? ( +

      {message}

      + ) : null} + {error ?

      {error}

      : null}
      ); } diff --git a/app/admin/settings/page.tsx b/app/admin/settings/page.tsx index 4e21d4a..d7ca360 100644 --- a/app/admin/settings/page.tsx +++ b/app/admin/settings/page.tsx @@ -1,7 +1,7 @@ -'use client'; +"use client"; -import { useEffect, useState } from 'react'; -import { useI18n } from '../../components/I18nProvider'; +import { useEffect, useState } from "react"; +import { useI18n } from "../../components/I18nProvider"; type SiteSettings = { requireLoginForContactDetails: boolean; @@ -21,25 +21,25 @@ export default function AdminSettingsPage() { setLoading(true); setError(null); try { - const meRes = await fetch('/api/auth/me', { cache: 'no-store' }); + const meRes = await fetch("/api/auth/me", { cache: "no-store" }); const me = await meRes.json(); - if (me.user?.role !== 'ADMIN') { - setError(t('adminRequired')); + if (me.user?.role !== "ADMIN") { + setError(t("adminRequired")); setLoading(false); return; } setIsAdmin(true); - const res = await fetch('/api/admin/settings', { cache: 'no-store' }); + const res = await fetch("/api/admin/settings", { cache: "no-store" }); const data = await res.json(); if (!res.ok) { - setError(data.error || 'Failed to load settings'); + setError(data.error || "Failed to load settings"); } else { setSettings(data.settings); } } catch (err) { console.error(err); - setError('Failed to load settings'); + setError("Failed to load settings"); } finally { setLoading(false); } @@ -54,21 +54,21 @@ export default function AdminSettingsPage() { setMessage(null); setError(null); try { - const res = await fetch('/api/admin/settings', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const res = await fetch("/api/admin/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify(next), }); const data = await res.json(); if (!res.ok) { - setError(data.error || 'Failed to save settings'); + setError(data.error || "Failed to save settings"); } else { setSettings(data.settings); - setMessage(t('settingsSaved')); + setMessage(t("settingsSaved")); } } catch (err) { console.error(err); - setError('Failed to save settings'); + setError("Failed to save settings"); } finally { setSaving(false); } @@ -76,42 +76,56 @@ export default function AdminSettingsPage() { function toggleRequireLoginForContactDetails() { if (!settings) return; - save({ requireLoginForContactDetails: !settings.requireLoginForContactDetails }); + save({ + requireLoginForContactDetails: !settings.requireLoginForContactDetails, + }); } return ( -
      -

      {t('adminSettingsTitle')}

      -

      {t('adminSettingsLead')}

      - {loading ?

      {t('loading')}

      : null} - {message ?

      {message}

      : null} - {error ?

      {error}

      : null} +
      +

      {t("adminSettingsTitle")}

      +

      {t("adminSettingsLead")}

      + {loading ?

      {t("loading")}

      : null} + {message ?

      {message}

      : null} + {error ?

      {error}

      : null} {!loading && settings ? ( -
      +
      -

      {t('settingContactVisibilityTitle')}

      -

      {t('settingContactVisibilityHelp')}

      +

      + {t("settingContactVisibilityTitle")} +

      +

      + {t("settingContactVisibilityHelp")} +

      -
      diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx index 9c04cea..94e2717 100644 --- a/app/admin/users/page.tsx +++ b/app/admin/users/page.tsx @@ -1,7 +1,7 @@ -'use client'; +"use client"; -import { useEffect, useState } from 'react'; -import { useI18n } from '../../components/I18nProvider'; +import { useEffect, useState } from "react"; +import { useI18n } from "../../components/I18nProvider"; type UserRow = { id: string; @@ -13,7 +13,7 @@ type UserRow = { approvedAt: string | null; }; -const roleOptions = ['USER', 'USER_MODERATOR', 'LISTING_MODERATOR', 'ADMIN']; +const roleOptions = ["USER", "USER_MODERATOR", "LISTING_MODERATOR", "ADMIN"]; export default function AdminUsersPage() { const { t } = useI18n(); @@ -26,30 +26,30 @@ export default function AdminUsersPage() { async function load() { setError(null); try { - const res = await fetch('/api/admin/users', { cache: 'no-store' }); + const res = await fetch("/api/admin/users", { cache: "no-store" }); const data = await res.json(); if (!res.ok) { - setError(data.error || 'Failed to load users'); + setError(data.error || "Failed to load users"); } else { setUsers(data.users ?? []); } } catch (e) { - setError('Failed to load users'); + setError("Failed to load users"); } } useEffect(() => { - fetch('/api/auth/me', { cache: 'no-store' }) + fetch("/api/auth/me", { cache: "no-store" }) .then((res) => res.json()) .then((data) => { setRole(data.user?.role ?? null); - if (data.user?.role === 'ADMIN') { + if (data.user?.role === "ADMIN") { load(); } else { - setError(t('adminRequired')); + setError(t("adminRequired")); } }) - .catch(() => setError(t('adminRequired'))); + .catch(() => setError(t("adminRequired"))); }, [t]); async function setUserRole(userId: string, role: string) { @@ -57,20 +57,20 @@ export default function AdminUsersPage() { setError(null); setLoading(true); try { - const res = await fetch('/api/admin/users/role', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const res = await fetch("/api/admin/users/role", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userId, role }), }); const data = await res.json(); if (!res.ok) { - setError(data.error || 'Failed to update role'); + setError(data.error || "Failed to update role"); } else { - setMessage(t('userUpdated')); + setMessage(t("userUpdated")); load(); } } catch (e) { - setError('Failed to update role'); + setError("Failed to update role"); } finally { setLoading(false); } @@ -81,20 +81,20 @@ export default function AdminUsersPage() { setError(null); setLoading(true); try { - const res = await fetch('/api/admin/users/approve', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const res = await fetch("/api/admin/users/approve", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userId }), }); const data = await res.json(); if (!res.ok) { - setError(data.error || 'Failed to approve user'); + setError(data.error || "Failed to approve user"); } else { - setMessage(t('userUpdated')); + setMessage(t("userUpdated")); load(); } } catch (e) { - setError('Failed to approve user'); + setError("Failed to approve user"); } finally { setLoading(false); } @@ -105,21 +105,21 @@ export default function AdminUsersPage() { setError(null); setLoading(true); try { - const reason = window.prompt('Reason for rejection? (optional)'); - const res = await fetch('/api/admin/users/reject', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const reason = window.prompt("Reason for rejection? (optional)"); + const res = await fetch("/api/admin/users/reject", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userId, reason }), }); const data = await res.json(); if (!res.ok) { - setError(data.error || 'Failed to reject user'); + setError(data.error || "Failed to reject user"); } else { - setMessage(t('userUpdated')); + setMessage(t("userUpdated")); load(); } } catch (e) { - setError('Failed to reject user'); + setError("Failed to reject user"); } finally { setLoading(false); } @@ -130,49 +130,61 @@ export default function AdminUsersPage() { setError(null); setLoading(true); try { - const reason = window.prompt('Reason for removal? (optional)'); - const res = await fetch('/api/admin/users/remove', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const reason = window.prompt("Reason for removal? (optional)"); + const res = await fetch("/api/admin/users/remove", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userId, reason }), }); const data = await res.json(); if (!res.ok) { - setError(data.error || 'Failed to remove user'); + setError(data.error || "Failed to remove user"); } else { - setMessage(t('userUpdated')); + setMessage(t("userUpdated")); load(); } } catch (e) { - setError('Failed to remove user'); + setError("Failed to remove user"); } finally { setLoading(false); } } return ( -
      -

      {t('adminUsersTitle')}

      -

      {t('adminUsersLead')}

      - {message ?

      {message}

      : null} - {error ?

      {error}

      : null} -
NamespacePodReady{t('monitorRestarts')}Phase{t('monitorAge')}Node + Namespace + + Pod + + Ready + + {t("monitorRestarts")} + + Phase + + {t("monitorAge")} + + Node +
{p.namespace} + + {p.namespace} +
{p.name}
-
- {p.containers.map((c) => `${c.name} (${c.state}${c.lastState ? `, ${c.lastState}` : ''})`).join('; ')} +
+ {p.containers + .map( + (c) => + `${c.name} (${c.state}${c.lastState ? `, ${c.lastState}` : ""})`, + ) + .join("; ")}
+ {p.readyCount}/{p.totalContainers} {p.restarts} - {p.phase} - {p.reason ? ` (${p.reason})` : ''} + + {p.restarts} + + {p.phase} + {p.reason ? ` (${p.reason})` : ""} + + {formatDurationFrom(p.startedAt)} + + {p.nodeName ?? "—"} {formatDurationFrom(p.startedAt)}{p.nodeName ?? '—'}
+
+

{t("adminUsersTitle")}

+

{t("adminUsersLead")}

+ {message ?

{message}

: null} + {error ?

{error}

: null} +
- - - - - - + + + + + + {users.map((u) => ( - + - - + + diff --git a/app/api/admin/listings/approve/route.ts b/app/api/admin/listings/approve/route.ts index 682afeb..9732f1f 100644 --- a/app/api/admin/listings/approve/route.ts +++ b/app/api/admin/listings/approve/route.ts @@ -1,29 +1,34 @@ -import { NextResponse } from 'next/server'; -import { ListingStatus } from '@prisma/client'; -import { prisma } from '../../../../../lib/prisma'; -import { requireAuth } from '../../../../../lib/jwt'; -import { Role } from '@prisma/client'; +import { NextResponse } from "next/server"; +import { ListingStatus } from "@prisma/client"; +import { prisma } from "../../../../../lib/prisma"; +import { requireAuth } from "../../../../../lib/jwt"; +import { Role } from "@prisma/client"; export async function POST(req: Request) { try { const auth = await requireAuth(req); - const canModerate = auth.role === Role.ADMIN || auth.role === Role.LISTING_MODERATOR; + const canModerate = + auth.role === Role.ADMIN || auth.role === Role.LISTING_MODERATOR; if (!canModerate) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } const body = await req.json(); - const listingId = String(body.listingId ?? ''); - const action = body.action ?? 'approve'; + const listingId = String(body.listingId ?? ""); + const action = body.action ?? "approve"; const reason = body.reason ? String(body.reason).slice(0, 500) : null; if (!listingId) { - return NextResponse.json({ error: 'listingId is required' }, { status: 400 }); + return NextResponse.json( + { error: "listingId is required" }, + { status: 400 }, + ); } let status: ListingStatus; - if (action === 'reject') status = ListingStatus.REJECTED; - else if (action === 'remove') status = ListingStatus.REMOVED; - else if (action === 'publish' || action === 'approve') status = ListingStatus.PUBLISHED; + if (action === "reject") status = ListingStatus.REJECTED; + else if (action === "remove") status = ListingStatus.REMOVED; + else if (action === "publish" || action === "approve") + status = ListingStatus.PUBLISHED; else status = ListingStatus.PENDING; const updated = await prisma.listing.update({ @@ -45,11 +50,11 @@ export async function POST(req: Request) { return NextResponse.json({ ok: true, listing: updated }); } catch (error) { - if (String(error).includes('Unauthorized')) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + if (String(error).includes("Unauthorized")) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - console.error('Admin listing approval error', error); - return NextResponse.json({ error: 'Approval failed' }, { status: 500 }); + console.error("Admin listing approval error", error); + return NextResponse.json({ error: "Approval failed" }, { status: 500 }); } } -export const dynamic = 'force-dynamic'; +export const dynamic = "force-dynamic"; diff --git a/app/api/admin/monitor/route.ts b/app/api/admin/monitor/route.ts index cfd1669..db4004e 100644 --- a/app/api/admin/monitor/route.ts +++ b/app/api/admin/monitor/route.ts @@ -1,24 +1,35 @@ -import { NextResponse } from 'next/server'; -import { Role } from '@prisma/client'; -import { requireAuth } from '../../../../lib/jwt'; -import { fetchDbStatus, fetchHetznerServers, fetchKubernetesStatus } from '../../../../lib/monitoring'; +import { NextResponse } from "next/server"; +import { Role } from "@prisma/client"; +import { requireAuth } from "../../../../lib/jwt"; +import { + fetchDbStatus, + fetchHetznerServers, + fetchKubernetesStatus, +} from "../../../../lib/monitoring"; export async function GET(req: Request) { try { const auth = await requireAuth(req); if (auth.role !== Role.ADMIN) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } - const [hetzner, k8s, db] = await Promise.all([fetchHetznerServers(), fetchKubernetesStatus(), fetchDbStatus()]); + const [hetzner, k8s, db] = await Promise.all([ + fetchHetznerServers(), + fetchKubernetesStatus(), + fetchDbStatus(), + ]); return NextResponse.json({ hetzner, k8s, db }); } catch (error) { - if (String(error).includes('Unauthorized')) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + if (String(error).includes("Unauthorized")) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - console.error('Monitoring endpoint error', error); - return NextResponse.json({ error: 'Failed to load monitoring data' }, { status: 500 }); + console.error("Monitoring endpoint error", error); + return NextResponse.json( + { error: "Failed to load monitoring data" }, + { status: 500 }, + ); } } -export const dynamic = 'force-dynamic'; +export const dynamic = "force-dynamic"; diff --git a/app/api/admin/pending/count/route.ts b/app/api/admin/pending/count/route.ts index 7db5dfc..c4c8298 100644 --- a/app/api/admin/pending/count/route.ts +++ b/app/api/admin/pending/count/route.ts @@ -1,7 +1,7 @@ -import { NextResponse } from 'next/server'; -import { prisma } from '../../../../../lib/prisma'; -import { requireAuth } from '../../../../../lib/jwt'; -import { ListingStatus, Role, UserStatus } from '@prisma/client'; +import { NextResponse } from "next/server"; +import { prisma } from "../../../../../lib/prisma"; +import { requireAuth } from "../../../../../lib/jwt"; +import { ListingStatus, Role, UserStatus } from "@prisma/client"; export async function GET(req: Request) { try { @@ -10,24 +10,33 @@ export async function GET(req: Request) { const canUserMod = auth.role === Role.USER_MODERATOR; const canListingMod = auth.role === Role.LISTING_MODERATOR; if (!isAdmin && !canUserMod && !canListingMod) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } const wantsUsers = isAdmin || canUserMod; const wantsListings = isAdmin || canListingMod; const [users, listings] = await Promise.all([ - wantsUsers ? prisma.user.count({ where: { status: UserStatus.PENDING } }) : Promise.resolve(0), - wantsListings ? prisma.listing.count({ where: { status: ListingStatus.PENDING, removedAt: null } }) : Promise.resolve(0), + wantsUsers + ? prisma.user.count({ where: { status: UserStatus.PENDING } }) + : Promise.resolve(0), + wantsListings + ? prisma.listing.count({ + where: { status: ListingStatus.PENDING, removedAt: null }, + }) + : Promise.resolve(0), ]); return NextResponse.json({ users, listings, total: users + listings }); } catch (error) { - if (String(error).includes('Unauthorized')) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + if (String(error).includes("Unauthorized")) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - console.error('Pending count error', error); - return NextResponse.json({ error: 'Failed to load count' }, { status: 500 }); + console.error("Pending count error", error); + return NextResponse.json( + { error: "Failed to load count" }, + { status: 500 }, + ); } } -export const dynamic = 'force-dynamic'; +export const dynamic = "force-dynamic"; diff --git a/app/api/admin/pending/route.ts b/app/api/admin/pending/route.ts index 9b7b666..4f064aa 100644 --- a/app/api/admin/pending/route.ts +++ b/app/api/admin/pending/route.ts @@ -1,7 +1,7 @@ -import { NextResponse } from 'next/server'; -import { prisma } from '../../../../lib/prisma'; -import { requireAuth } from '../../../../lib/jwt'; -import { Role, ListingStatus, UserStatus } from '@prisma/client'; +import { NextResponse } from "next/server"; +import { prisma } from "../../../../lib/prisma"; +import { requireAuth } from "../../../../lib/jwt"; +import { Role, ListingStatus, UserStatus } from "@prisma/client"; export async function GET(req: Request) { try { @@ -10,7 +10,7 @@ export async function GET(req: Request) { const canUserMod = auth.role === Role.USER_MODERATOR; const canListingMod = auth.role === Role.LISTING_MODERATOR; if (!isAdmin && !canUserMod && !canListingMod) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } const wantsUsers = isAdmin || canUserMod; @@ -20,8 +20,15 @@ export async function GET(req: Request) { wantsUsers ? prisma.user.findMany({ where: { status: UserStatus.PENDING }, - select: { id: true, email: true, status: true, emailVerifiedAt: true, approvedAt: true, role: true }, - orderBy: { createdAt: 'asc' }, + select: { + id: true, + email: true, + status: true, + emailVerifiedAt: true, + approvedAt: true, + role: true, + }, + orderBy: { createdAt: "asc" }, take: 50, }) : Promise.resolve([]), @@ -36,9 +43,11 @@ export async function GET(req: Request) { evChargingOnSite: true, wheelchairAccessible: true, owner: { select: { email: true } }, - translations: { select: { title: true, slug: true, locale: true } }, + translations: { + select: { title: true, slug: true, locale: true }, + }, }, - orderBy: { createdAt: 'asc' }, + orderBy: { createdAt: "asc" }, take: 50, }) : Promise.resolve([]), @@ -46,11 +55,14 @@ export async function GET(req: Request) { return NextResponse.json({ users, listings, role: auth.role }); } catch (error) { - console.error('List pending error', error); - if (String(error).includes('Unauthorized')) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + console.error("List pending error", error); + if (String(error).includes("Unauthorized")) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - return NextResponse.json({ error: 'Failed to load pending items' }, { status: 500 }); + return NextResponse.json( + { error: "Failed to load pending items" }, + { status: 500 }, + ); } } -export const dynamic = 'force-dynamic'; +export const dynamic = "force-dynamic"; diff --git a/app/api/admin/settings/route.ts b/app/api/admin/settings/route.ts index de29f4b..4a3751d 100644 --- a/app/api/admin/settings/route.ts +++ b/app/api/admin/settings/route.ts @@ -1,23 +1,26 @@ -import { NextResponse } from 'next/server'; -import { Role } from '@prisma/client'; -import { requireAuth } from '../../../../lib/jwt'; -import { getSiteSettings, updateSiteSettings } from '../../../../lib/settings'; +import { NextResponse } from "next/server"; +import { Role } from "@prisma/client"; +import { requireAuth } from "../../../../lib/jwt"; +import { getSiteSettings, updateSiteSettings } from "../../../../lib/settings"; export async function GET(req: Request) { try { const auth = await requireAuth(req); if (auth.role !== Role.ADMIN) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } const settings = await getSiteSettings(); return NextResponse.json({ settings }); } catch (error) { - if (String(error).includes('Unauthorized')) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + if (String(error).includes("Unauthorized")) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - console.error('Load settings error', error); - return NextResponse.json({ error: 'Failed to load settings' }, { status: 500 }); + console.error("Load settings error", error); + return NextResponse.json( + { error: "Failed to load settings" }, + { status: 500 }, + ); } } @@ -25,24 +28,29 @@ export async function POST(req: Request) { try { const auth = await requireAuth(req); if (auth.role !== Role.ADMIN) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } const body = await req.json(); const payload: Parameters[0] = {}; if (body.requireLoginForContactDetails !== undefined) { - payload.requireLoginForContactDetails = Boolean(body.requireLoginForContactDetails); + payload.requireLoginForContactDetails = Boolean( + body.requireLoginForContactDetails, + ); } const settings = await updateSiteSettings(payload); return NextResponse.json({ settings }); } catch (error) { - if (String(error).includes('Unauthorized')) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + if (String(error).includes("Unauthorized")) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - console.error('Save settings error', error); - return NextResponse.json({ error: 'Failed to save settings' }, { status: 500 }); + console.error("Save settings error", error); + return NextResponse.json( + { error: "Failed to save settings" }, + { status: 500 }, + ); } } -export const dynamic = 'force-dynamic'; +export const dynamic = "force-dynamic"; diff --git a/app/api/admin/users/approve/route.ts b/app/api/admin/users/approve/route.ts index 1e26743..ea32f9d 100644 --- a/app/api/admin/users/approve/route.ts +++ b/app/api/admin/users/approve/route.ts @@ -1,7 +1,7 @@ -import { NextResponse } from 'next/server'; -import { prisma } from '../../../../../lib/prisma'; -import { requireAuth } from '../../../../../lib/jwt'; -import { Role, UserStatus } from '@prisma/client'; +import { NextResponse } from "next/server"; +import { prisma } from "../../../../../lib/prisma"; +import { requireAuth } from "../../../../../lib/jwt"; +import { Role, UserStatus } from "@prisma/client"; export async function POST(req: Request) { try { @@ -9,22 +9,39 @@ export async function POST(req: Request) { const isAdmin = auth.role === Role.ADMIN; const canApprove = isAdmin || auth.role === Role.USER_MODERATOR; if (!canApprove) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } const body = await req.json(); - const userId = String(body.userId ?? ''); + const userId = String(body.userId ?? ""); const makeAdmin = Boolean(body.makeAdmin); const newRole = body.newRole as Role | undefined; if (!userId) { - return NextResponse.json({ error: 'userId is required' }, { status: 400 }); + return NextResponse.json( + { error: "userId is required" }, + { status: 400 }, + ); } - if (!isAdmin && (makeAdmin || newRole === Role.ADMIN || newRole === Role.USER_MODERATOR || newRole === Role.LISTING_MODERATOR)) { - return NextResponse.json({ error: 'Only admins can change roles' }, { status: 403 }); + if ( + !isAdmin && + (makeAdmin || + newRole === Role.ADMIN || + newRole === Role.USER_MODERATOR || + newRole === Role.LISTING_MODERATOR) + ) { + return NextResponse.json( + { error: "Only admins can change roles" }, + { status: 403 }, + ); } - const roleUpdate = isAdmin && newRole ? { role: newRole } : makeAdmin && isAdmin ? { role: Role.ADMIN } : undefined; + const roleUpdate = + isAdmin && newRole + ? { role: newRole } + : makeAdmin && isAdmin + ? { role: Role.ADMIN } + : undefined; const updated = await prisma.user.update({ where: { id: userId }, @@ -43,11 +60,11 @@ export async function POST(req: Request) { return NextResponse.json({ ok: true, user: updated }); } catch (error) { - if (String(error).includes('Unauthorized')) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + if (String(error).includes("Unauthorized")) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - console.error('Admin approve user error', error); - return NextResponse.json({ error: 'Approval failed' }, { status: 500 }); + console.error("Admin approve user error", error); + return NextResponse.json({ error: "Approval failed" }, { status: 500 }); } } -export const dynamic = 'force-dynamic'; +export const dynamic = "force-dynamic"; diff --git a/app/api/admin/users/reject/route.ts b/app/api/admin/users/reject/route.ts index 6ed62a1..7b2fc5b 100644 --- a/app/api/admin/users/reject/route.ts +++ b/app/api/admin/users/reject/route.ts @@ -1,22 +1,26 @@ -import { NextResponse } from 'next/server'; -import { prisma } from '../../../../../lib/prisma'; -import { requireAuth } from '../../../../../lib/jwt'; -import { Role, UserStatus } from '@prisma/client'; +import { NextResponse } from "next/server"; +import { prisma } from "../../../../../lib/prisma"; +import { requireAuth } from "../../../../../lib/jwt"; +import { Role, UserStatus } from "@prisma/client"; export async function POST(req: Request) { try { const auth = await requireAuth(req); - const canReject = auth.role === Role.ADMIN || auth.role === Role.USER_MODERATOR; + const canReject = + auth.role === Role.ADMIN || auth.role === Role.USER_MODERATOR; if (!canReject) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } const body = await req.json(); - const userId = String(body.userId ?? ''); + const userId = String(body.userId ?? ""); const reason = body.reason ? String(body.reason).slice(0, 500) : null; if (!userId) { - return NextResponse.json({ error: 'userId is required' }, { status: 400 }); + return NextResponse.json( + { error: "userId is required" }, + { status: 400 }, + ); } const updated = await prisma.user.update({ @@ -27,16 +31,22 @@ export async function POST(req: Request) { rejectedReason: reason, approvedAt: null, }, - select: { id: true, role: true, status: true, rejectedAt: true, rejectedReason: true }, + select: { + id: true, + role: true, + status: true, + rejectedAt: true, + rejectedReason: true, + }, }); return NextResponse.json({ ok: true, user: updated }); } catch (error) { - if (String(error).includes('Unauthorized')) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + if (String(error).includes("Unauthorized")) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - console.error('Admin reject user error', error); - return NextResponse.json({ error: 'Reject failed' }, { status: 500 }); + console.error("Admin reject user error", error); + return NextResponse.json({ error: "Reject failed" }, { status: 500 }); } } -export const dynamic = 'force-dynamic'; +export const dynamic = "force-dynamic"; diff --git a/app/api/admin/users/remove/route.ts b/app/api/admin/users/remove/route.ts index 63d50fb..1c3cb7d 100644 --- a/app/api/admin/users/remove/route.ts +++ b/app/api/admin/users/remove/route.ts @@ -1,25 +1,31 @@ -import { NextResponse } from 'next/server'; -import { prisma } from '../../../../../lib/prisma'; -import { requireAuth } from '../../../../../lib/jwt'; -import { Role, UserStatus } from '@prisma/client'; +import { NextResponse } from "next/server"; +import { prisma } from "../../../../../lib/prisma"; +import { requireAuth } from "../../../../../lib/jwt"; +import { Role, UserStatus } from "@prisma/client"; export async function POST(req: Request) { try { const auth = await requireAuth(req); if (auth.role !== Role.ADMIN) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } const body = await req.json(); - const userId = String(body.userId ?? ''); + const userId = String(body.userId ?? ""); const reason = body.reason ? String(body.reason).slice(0, 500) : null; if (!userId) { - return NextResponse.json({ error: 'userId is required' }, { status: 400 }); + return NextResponse.json( + { error: "userId is required" }, + { status: 400 }, + ); } if (userId === auth.userId) { - return NextResponse.json({ error: 'Cannot remove your own account' }, { status: 400 }); + return NextResponse.json( + { error: "Cannot remove your own account" }, + { status: 400 }, + ); } const updated = await prisma.user.update({ @@ -30,16 +36,22 @@ export async function POST(req: Request) { removedById: auth.userId, removedReason: reason, }, - select: { id: true, role: true, status: true, removedAt: true, removedReason: true }, + select: { + id: true, + role: true, + status: true, + removedAt: true, + removedReason: true, + }, }); return NextResponse.json({ ok: true, user: updated }); } catch (error) { - if (String(error).includes('Unauthorized')) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + if (String(error).includes("Unauthorized")) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - console.error('Admin remove user error', error); - return NextResponse.json({ error: 'Remove failed' }, { status: 500 }); + console.error("Admin remove user error", error); + return NextResponse.json({ error: "Remove failed" }, { status: 500 }); } } -export const dynamic = 'force-dynamic'; +export const dynamic = "force-dynamic"; diff --git a/app/api/admin/users/role/route.ts b/app/api/admin/users/role/route.ts index 89e2b0a..ed69d26 100644 --- a/app/api/admin/users/role/route.ts +++ b/app/api/admin/users/role/route.ts @@ -1,20 +1,23 @@ -import { NextResponse } from 'next/server'; -import { prisma } from '../../../../../lib/prisma'; -import { requireAuth } from '../../../../../lib/jwt'; -import { Role } from '@prisma/client'; +import { NextResponse } from "next/server"; +import { prisma } from "../../../../../lib/prisma"; +import { requireAuth } from "../../../../../lib/jwt"; +import { Role } from "@prisma/client"; export async function POST(req: Request) { try { const auth = await requireAuth(req); if (auth.role !== Role.ADMIN) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } const body = await req.json(); - const userId = String(body.userId ?? ''); + const userId = String(body.userId ?? ""); const role = body.role as Role | undefined; if (!userId || !role) { - return NextResponse.json({ error: 'userId and role are required' }, { status: 400 }); + return NextResponse.json( + { error: "userId and role are required" }, + { status: 400 }, + ); } const updated = await prisma.user.update({ @@ -25,11 +28,14 @@ export async function POST(req: Request) { return NextResponse.json({ ok: true, user: updated }); } catch (error) { - if (String(error).includes('Unauthorized')) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + if (String(error).includes("Unauthorized")) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - console.error('Update role error', error); - return NextResponse.json({ error: 'Failed to update role' }, { status: 500 }); + console.error("Update role error", error); + return NextResponse.json( + { error: "Failed to update role" }, + { status: 500 }, + ); } } -export const dynamic = 'force-dynamic'; +export const dynamic = "force-dynamic"; diff --git a/app/api/admin/users/route.ts b/app/api/admin/users/route.ts index 548ab11..17bdccd 100644 --- a/app/api/admin/users/route.ts +++ b/app/api/admin/users/route.ts @@ -1,13 +1,13 @@ -import { NextResponse } from 'next/server'; -import { prisma } from '../../../../lib/prisma'; -import { requireAuth } from '../../../../lib/jwt'; -import { Role, UserStatus } from '@prisma/client'; +import { NextResponse } from "next/server"; +import { prisma } from "../../../../lib/prisma"; +import { requireAuth } from "../../../../lib/jwt"; +import { Role, UserStatus } from "@prisma/client"; export async function GET(req: Request) { try { const auth = await requireAuth(req); if (auth.role !== Role.ADMIN) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } const users = await prisma.user.findMany({ @@ -21,17 +21,24 @@ export async function GET(req: Request) { approvedAt: true, createdAt: true, }, - orderBy: { createdAt: 'asc' }, + orderBy: { createdAt: "asc" }, take: 200, }); - return NextResponse.json({ users, roles: Object.values(Role), statuses: Object.values(UserStatus) }); + return NextResponse.json({ + users, + roles: Object.values(Role), + statuses: Object.values(UserStatus), + }); } catch (error) { - if (String(error).includes('Unauthorized')) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + if (String(error).includes("Unauthorized")) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - console.error('List users error', error); - return NextResponse.json({ error: 'Failed to load users' }, { status: 500 }); + console.error("List users error", error); + return NextResponse.json( + { error: "Failed to load users" }, + { status: 500 }, + ); } } -export const dynamic = 'force-dynamic'; +export const dynamic = "force-dynamic"; diff --git a/app/api/auth/forgot/route.ts b/app/api/auth/forgot/route.ts index d15535a..bf31db2 100644 --- a/app/api/auth/forgot/route.ts +++ b/app/api/auth/forgot/route.ts @@ -1,26 +1,31 @@ -import { NextResponse } from 'next/server'; -import { prisma } from '../../../../lib/prisma'; -import { randomToken, addHours } from '../../../../lib/tokens'; -import { sendPasswordResetEmail } from '../../../../lib/mailer'; +import { NextResponse } from "next/server"; +import { prisma } from "../../../../lib/prisma"; +import { randomToken, addHours } from "../../../../lib/tokens"; +import { sendPasswordResetEmail } from "../../../../lib/mailer"; -const APP_URL = process.env.APP_URL || 'http://localhost:3000'; +const APP_URL = process.env.APP_URL || "http://localhost:3000"; export async function POST(req: Request) { try { const body = await req.json(); - const email = String(body.email ?? '').trim().toLowerCase(); + const email = String(body.email ?? "") + .trim() + .toLowerCase(); if (!email) { - return NextResponse.json({ error: 'Email is required' }, { status: 400 }); + return NextResponse.json({ error: "Email is required" }, { status: 400 }); } - const user = await prisma.user.findUnique({ where: { email }, select: { id: true, emailVerifiedAt: true } }); + const user = await prisma.user.findUnique({ + where: { email }, + select: { id: true, emailVerifiedAt: true }, + }); if (user) { const token = randomToken(); await prisma.verificationToken.create({ data: { userId: user.id, token, - type: 'password_reset', + type: "password_reset", expiresAt: addHours(2), }, }); @@ -30,9 +35,9 @@ export async function POST(req: Request) { return NextResponse.json({ ok: true }); } catch (error) { - console.error('Forgot password error', error); + console.error("Forgot password error", error); return NextResponse.json({ ok: true }); } } -export const dynamic = 'force-dynamic'; +export const dynamic = "force-dynamic"; diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts index 235bae5..aedabf3 100644 --- a/app/api/auth/login/route.ts +++ b/app/api/auth/login/route.ts @@ -1,50 +1,71 @@ -import { NextResponse } from 'next/server'; -import { UserStatus } from '@prisma/client'; -import { prisma } from '../../../../lib/prisma'; -import { verifyPassword } from '../../../../lib/auth'; -import { signAccessToken, buildSessionCookie, clearSessionCookie } from '../../../../lib/jwt'; +import { NextResponse } from "next/server"; +import { UserStatus } from "@prisma/client"; +import { prisma } from "../../../../lib/prisma"; +import { verifyPassword } from "../../../../lib/auth"; +import { + signAccessToken, + buildSessionCookie, + clearSessionCookie, +} from "../../../../lib/jwt"; export async function POST(req: Request) { try { const body = await req.json(); - const email = String(body.email ?? '').trim().toLowerCase(); - const password = String(body.password ?? ''); + const email = String(body.email ?? "") + .trim() + .toLowerCase(); + const password = String(body.password ?? ""); if (!email || !password) { - return NextResponse.json({ error: 'Email and password are required' }, { status: 400 }); + return NextResponse.json( + { error: "Email and password are required" }, + { status: 400 }, + ); } const user = await prisma.user.findUnique({ where: { email } }); if (!user) { - return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 }); + return NextResponse.json( + { error: "Invalid credentials" }, + { status: 401 }, + ); } const valid = await verifyPassword(password, user.passwordHash); if (!valid) { - return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 }); + return NextResponse.json( + { error: "Invalid credentials" }, + { status: 401 }, + ); } if (!user.emailVerifiedAt) { - return NextResponse.json({ error: 'Email not verified yet' }, { status: 403 }); + return NextResponse.json( + { error: "Email not verified yet" }, + { status: 403 }, + ); } if (!user.approvedAt || user.status !== UserStatus.ACTIVE) { const statusMessage = user.status === UserStatus.REJECTED - ? 'User access was rejected' + ? "User access was rejected" : user.status === UserStatus.REMOVED - ? 'User has been removed' - : 'User is not approved yet'; + ? "User has been removed" + : "User is not approved yet"; return NextResponse.json({ error: statusMessage }, { status: 403 }); } const token = await signAccessToken({ userId: user.id, role: user.role }); - const res = NextResponse.json({ token, user: { id: user.id, role: user.role, email: user.email } }); - res.headers.append('Set-Cookie', buildSessionCookie(token)); + const res = NextResponse.json({ + token, + user: { id: user.id, role: user.role, email: user.email }, + }); + res.headers.append("Set-Cookie", buildSessionCookie(token)); return res; } catch (error) { - console.error('Login error', error); - const res = NextResponse.json({ error: 'Login failed' }, { status: 500 }); - res.headers.append('Set-Cookie', clearSessionCookie()); + console.error("Login error", error); + const res = NextResponse.json({ error: "Login failed" }, { status: 500 }); + res.headers.append("Set-Cookie", clearSessionCookie()); return res; } } diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts index d195693..3a50f62 100644 --- a/app/api/auth/logout/route.ts +++ b/app/api/auth/logout/route.ts @@ -1,8 +1,8 @@ -import { NextResponse } from 'next/server'; -import { clearSessionCookie } from '../../../../lib/jwt'; +import { NextResponse } from "next/server"; +import { clearSessionCookie } from "../../../../lib/jwt"; export async function POST() { const res = NextResponse.json({ ok: true }); - res.headers.append('Set-Cookie', clearSessionCookie()); + res.headers.append("Set-Cookie", clearSessionCookie()); return res; } diff --git a/app/api/auth/me/route.ts b/app/api/auth/me/route.ts index ff4c959..f2f7218 100644 --- a/app/api/auth/me/route.ts +++ b/app/api/auth/me/route.ts @@ -1,19 +1,29 @@ -import { NextResponse } from 'next/server'; -import { prisma } from '../../../../lib/prisma'; -import { requireAuth } from '../../../../lib/jwt'; +import { NextResponse } from "next/server"; +import { prisma } from "../../../../lib/prisma"; +import { requireAuth } from "../../../../lib/jwt"; export async function GET(req: Request) { try { const session = await requireAuth(req); const user = await prisma.user.findUnique({ where: { id: session.userId }, - select: { id: true, email: true, role: true, status: true, emailVerifiedAt: true, approvedAt: true, name: true, phone: true }, + select: { + id: true, + email: true, + role: true, + status: true, + emailVerifiedAt: true, + approvedAt: true, + name: true, + phone: true, + }, }); - if (!user) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + if (!user) + return NextResponse.json({ error: "Not found" }, { status: 404 }); return NextResponse.json({ user }); } catch (error) { return NextResponse.json({ user: null }, { status: 200 }); } } -export const dynamic = 'force-dynamic'; +export const dynamic = "force-dynamic"; diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts index 2cb520f..f6e9e40 100644 --- a/app/api/auth/register/route.ts +++ b/app/api/auth/register/route.ts @@ -1,29 +1,40 @@ -import { NextResponse } from 'next/server'; -import { Role, UserStatus } from '@prisma/client'; -import { prisma } from '../../../../lib/prisma'; -import { hashPassword } from '../../../../lib/auth'; -import { randomToken, addHours } from '../../../../lib/tokens'; -import { sendVerificationEmail } from '../../../../lib/mailer'; +import { NextResponse } from "next/server"; +import { Role, UserStatus } from "@prisma/client"; +import { prisma } from "../../../../lib/prisma"; +import { hashPassword } from "../../../../lib/auth"; +import { randomToken, addHours } from "../../../../lib/tokens"; +import { sendVerificationEmail } from "../../../../lib/mailer"; -const APP_URL = process.env.APP_URL || 'http://localhost:3000'; +const APP_URL = process.env.APP_URL || "http://localhost:3000"; export async function POST(req: Request) { try { const body = await req.json(); - const email = String(body.email ?? '').trim().toLowerCase(); - const password = String(body.password ?? ''); + const email = String(body.email ?? "") + .trim() + .toLowerCase(); + const password = String(body.password ?? ""); const name = body.name ? String(body.name).trim() : null; if (!email || !password) { - return NextResponse.json({ error: 'Email and password are required' }, { status: 400 }); + return NextResponse.json( + { error: "Email and password are required" }, + { status: 400 }, + ); } if (password.length < 8) { - return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 400 }); + return NextResponse.json( + { error: "Password must be at least 8 characters" }, + { status: 400 }, + ); } const existing = await prisma.user.findUnique({ where: { email } }); if (existing) { - return NextResponse.json({ error: 'Email already registered' }, { status: 409 }); + return NextResponse.json( + { error: "Email already registered" }, + { status: 409 }, + ); } const passwordHash = await hashPassword(password); @@ -43,7 +54,7 @@ export async function POST(req: Request) { data: { userId: user.id, token, - type: 'email_verify', + type: "email_verify", expiresAt: addHours(24), }, }); @@ -53,7 +64,7 @@ export async function POST(req: Request) { return NextResponse.json({ ok: true }); } catch (error) { - console.error('Register error', error); - return NextResponse.json({ error: 'Registration failed' }, { status: 500 }); + console.error("Register error", error); + return NextResponse.json({ error: "Registration failed" }, { status: 500 }); } } diff --git a/app/api/auth/reset/route.ts b/app/api/auth/reset/route.ts index a1db232..0eaa2e8 100644 --- a/app/api/auth/reset/route.ts +++ b/app/api/auth/reset/route.ts @@ -1,37 +1,63 @@ -import { NextResponse } from 'next/server'; -import { prisma } from '../../../../lib/prisma'; -import { hashPassword } from '../../../../lib/auth'; -import { addHours } from '../../../../lib/tokens'; +import { NextResponse } from "next/server"; +import { prisma } from "../../../../lib/prisma"; +import { hashPassword } from "../../../../lib/auth"; +import { addHours } from "../../../../lib/tokens"; export async function POST(req: Request) { try { const body = await req.json(); - const token = String(body.token ?? '').trim(); - const password = String(body.password ?? ''); + const token = String(body.token ?? "").trim(); + const password = String(body.password ?? ""); if (!token || !password) { - return NextResponse.json({ error: 'Missing token or password' }, { status: 400 }); + return NextResponse.json( + { error: "Missing token or password" }, + { status: 400 }, + ); } if (password.length < 8) { - return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 400 }); + return NextResponse.json( + { error: "Password must be at least 8 characters" }, + { status: 400 }, + ); } - const record = await prisma.verificationToken.findUnique({ where: { token }, include: { user: true } }); - if (!record || record.type !== 'password_reset' || record.consumedAt || record.expiresAt < new Date()) { - return NextResponse.json({ error: 'Invalid or expired token' }, { status: 400 }); + const record = await prisma.verificationToken.findUnique({ + where: { token }, + include: { user: true }, + }); + if ( + !record || + record.type !== "password_reset" || + record.consumedAt || + record.expiresAt < new Date() + ) { + return NextResponse.json( + { error: "Invalid or expired token" }, + { status: 400 }, + ); } const passwordHash = await hashPassword(password); await prisma.$transaction([ - prisma.user.update({ where: { id: record.userId }, data: { passwordHash } }), - prisma.verificationToken.update({ where: { id: record.id }, data: { consumedAt: new Date(), expiresAt: addHours(-1) } }), + prisma.user.update({ + where: { id: record.userId }, + data: { passwordHash }, + }), + prisma.verificationToken.update({ + where: { id: record.id }, + data: { consumedAt: new Date(), expiresAt: addHours(-1) }, + }), ]); return NextResponse.json({ ok: true }); } catch (error) { - console.error('Password reset error', error); - return NextResponse.json({ error: 'Failed to reset password' }, { status: 500 }); + console.error("Password reset error", error); + return NextResponse.json( + { error: "Failed to reset password" }, + { status: 500 }, + ); } } -export const dynamic = 'force-dynamic'; +export const dynamic = "force-dynamic"; diff --git a/app/api/auth/verify/route.ts b/app/api/auth/verify/route.ts index f2a02d5..19a353a 100644 --- a/app/api/auth/verify/route.ts +++ b/app/api/auth/verify/route.ts @@ -1,23 +1,29 @@ -import { NextResponse } from 'next/server'; -import { prisma } from '../../../../lib/prisma'; +import { NextResponse } from "next/server"; +import { prisma } from "../../../../lib/prisma"; export async function POST(req: Request) { try { const body = await req.json(); - const token = String(body.token ?? '').trim(); + const token = String(body.token ?? "").trim(); if (!token) { - return NextResponse.json({ error: 'Token is required' }, { status: 400 }); + return NextResponse.json({ error: "Token is required" }, { status: 400 }); } - const record = await prisma.verificationToken.findUnique({ where: { token }, include: { user: true } }); + const record = await prisma.verificationToken.findUnique({ + where: { token }, + include: { user: true }, + }); if (!record) { - return NextResponse.json({ error: 'Invalid token' }, { status: 400 }); + return NextResponse.json({ error: "Invalid token" }, { status: 400 }); } if (record.consumedAt) { - return NextResponse.json({ error: 'Token already used' }, { status: 400 }); + return NextResponse.json( + { error: "Token already used" }, + { status: 400 }, + ); } if (record.expiresAt < new Date()) { - return NextResponse.json({ error: 'Token expired' }, { status: 400 }); + return NextResponse.json({ error: "Token expired" }, { status: 400 }); } await prisma.$transaction([ @@ -33,9 +39,9 @@ export async function POST(req: Request) { return NextResponse.json({ ok: true }); } catch (error) { - console.error('Verify error', error); - return NextResponse.json({ error: 'Verification failed' }, { status: 500 }); + console.error("Verify error", error); + return NextResponse.json({ error: "Verification failed" }, { status: 500 }); } } -export const dynamic = 'force-dynamic'; +export const dynamic = "force-dynamic"; diff --git a/app/api/health/route.ts b/app/api/health/route.ts index ab3ad80..a1bb6de 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -1,7 +1,10 @@ -import { NextResponse } from 'next/server'; +import { NextResponse } from "next/server"; -export const dynamic = 'force-dynamic'; +export const dynamic = "force-dynamic"; export async function GET() { - return NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() }); + return NextResponse.json({ + status: "ok", + timestamp: new Date().toISOString(), + }); } diff --git a/app/api/images/[id]/route.ts b/app/api/images/[id]/route.ts index 0772ab4..e5c4d2a 100644 --- a/app/api/images/[id]/route.ts +++ b/app/api/images/[id]/route.ts @@ -1,26 +1,29 @@ -import { NextResponse } from 'next/server'; -import { prisma } from '../../../../lib/prisma'; +import { NextResponse } from "next/server"; +import { prisma } from "../../../../lib/prisma"; -export async function GET(_req: Request, { params }: { params: { id: string } }) { +export async function GET( + _req: Request, + { params }: { params: { id: string } }, +) { const image = await prisma.listingImage.findUnique({ where: { id: params.id }, select: { data: true, mimeType: true, url: true, updatedAt: true }, }); if (!image) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }); + return NextResponse.json({ error: "Not found" }, { status: 404 }); } if (image.data) { const res = new NextResponse(image.data, { status: 200, headers: { - 'Content-Type': image.mimeType || 'application/octet-stream', - 'Cache-Control': 'public, max-age=86400', + "Content-Type": image.mimeType || "application/octet-stream", + "Cache-Control": "public, max-age=86400", }, }); if (image.updatedAt) { - res.headers.set('Last-Modified', image.updatedAt.toUTCString()); + res.headers.set("Last-Modified", image.updatedAt.toUTCString()); } return res; } @@ -29,5 +32,5 @@ export async function GET(_req: Request, { params }: { params: { id: string } }) return NextResponse.redirect(image.url, { status: 302 }); } - return NextResponse.json({ error: 'Image missing' }, { status: 404 }); + return NextResponse.json({ error: "Image missing" }, { status: 404 }); } diff --git a/app/api/integrations/billing/verify/route.ts b/app/api/integrations/billing/verify/route.ts index a53b76e..f122f15 100644 --- a/app/api/integrations/billing/verify/route.ts +++ b/app/api/integrations/billing/verify/route.ts @@ -1,18 +1,23 @@ -import { NextResponse } from 'next/server'; -import { ListingStatus, UserStatus } from '@prisma/client'; -import { prisma } from '../../../../../lib/prisma'; -import { loadN8nBillingApiKey } from '../../../../../lib/apiKeys'; -import { resolveBillingDetails } from '../../../../../lib/billing'; +import { NextResponse } from "next/server"; +import { ListingStatus, UserStatus } from "@prisma/client"; +import { prisma } from "../../../../../lib/prisma"; +import { loadN8nBillingApiKey } from "../../../../../lib/apiKeys"; +import { resolveBillingDetails } from "../../../../../lib/billing"; function pickTranslation(translations: { slug: string; locale: string }[]) { - return translations.find((t) => t.locale === 'en') || translations.find((t) => t.locale === 'fi') || translations[0] || null; + return ( + translations.find((t) => t.locale === "en") || + translations.find((t) => t.locale === "fi") || + translations[0] || + null + ); } function extractApiKey(req: Request) { - const headerKey = req.headers.get('x-api-key'); + const headerKey = req.headers.get("x-api-key"); if (headerKey) return headerKey.trim(); - const auth = req.headers.get('authorization'); - if (auth && auth.toLowerCase().startsWith('bearer ')) { + const auth = req.headers.get("authorization"); + if (auth && auth.toLowerCase().startsWith("bearer ")) { return auth.slice(7).trim(); } return null; @@ -21,27 +26,36 @@ function extractApiKey(req: Request) { export async function POST(req: Request) { const expectedKey = loadN8nBillingApiKey(); if (!expectedKey) { - return NextResponse.json({ error: 'Billing API key missing' }, { status: 500 }); + return NextResponse.json( + { error: "Billing API key missing" }, + { status: 500 }, + ); } const providedKey = extractApiKey(req); if (!providedKey || providedKey !== expectedKey) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } let body: any; try { body = await req.json(); } catch { - return NextResponse.json({ error: 'Invalid payload' }, { status: 400 }); + return NextResponse.json({ error: "Invalid payload" }, { status: 400 }); } - const listingId = typeof body.listingId === 'string' ? body.listingId : undefined; - const listingSlug = typeof body.listingSlug === 'string' ? body.listingSlug.trim() : undefined; - const ownerEmailRaw = typeof body.ownerEmail === 'string' ? body.ownerEmail.trim() : undefined; + const listingId = + typeof body.listingId === "string" ? body.listingId : undefined; + const listingSlug = + typeof body.listingSlug === "string" ? body.listingSlug.trim() : undefined; + const ownerEmailRaw = + typeof body.ownerEmail === "string" ? body.ownerEmail.trim() : undefined; const ownerEmail = ownerEmailRaw ? ownerEmailRaw.toLowerCase() : undefined; if (!listingId && !listingSlug && !ownerEmail) { - return NextResponse.json({ error: 'Provide listingId, listingSlug, or ownerEmail' }, { status: 400 }); + return NextResponse.json( + { error: "Provide listingId, listingSlug, or ownerEmail" }, + { status: 400 }, + ); } let listing: any = null; @@ -73,21 +87,24 @@ export async function POST(req: Request) { }); if (listingSlug && listings.length > 1) { - return NextResponse.json({ error: 'Listing slug is ambiguous; provide listingId' }, { status: 400 }); + return NextResponse.json( + { error: "Listing slug is ambiguous; provide listingId" }, + { status: 400 }, + ); } listing = listings[0] ?? null; if (!listing && listingId) { - return NextResponse.json({ error: 'Listing not found' }, { status: 404 }); + return NextResponse.json({ error: "Listing not found" }, { status: 404 }); } if (!listing && listingSlug) { - return NextResponse.json({ error: 'Listing not found' }, { status: 404 }); + return NextResponse.json({ error: "Listing not found" }, { status: 404 }); } } let owner = listing?.owner ?? null; if (!owner && ownerEmail) { owner = await prisma.user.findFirst({ - where: { email: { equals: ownerEmail, mode: 'insensitive' } }, + where: { email: { equals: ownerEmail, mode: "insensitive" } }, select: { id: true, email: true, @@ -106,19 +123,27 @@ export async function POST(req: Request) { return NextResponse.json({ enabled: false }); } - const ownerReady = owner.status === UserStatus.ACTIVE && owner.approvedAt && owner.emailVerifiedAt; + const ownerReady = + owner.status === UserStatus.ACTIVE && + owner.approvedAt && + owner.emailVerifiedAt; if (!ownerReady || !owner.billingEmailsEnabled) { - return NextResponse.json({ enabled: false, owner: { id: owner.id, email: owner.email } }); + return NextResponse.json({ + enabled: false, + owner: { id: owner.id, email: owner.email }, + }); } const billing = resolveBillingDetails(owner, listing ?? undefined); const enabled = Boolean(billing.accountName && billing.iban); const listingHasOverride = listing && - (listing.billingAccountName !== null && listing.billingAccountName !== undefined || - listing.billingIban !== null && listing.billingIban !== undefined || - listing.billingIncludeVatLine !== null && listing.billingIncludeVatLine !== undefined); - const source = listingHasOverride ? 'listing' : 'user'; + ((listing.billingAccountName !== null && + listing.billingAccountName !== undefined) || + (listing.billingIban !== null && listing.billingIban !== undefined) || + (listing.billingIncludeVatLine !== null && + listing.billingIncludeVatLine !== undefined)); + const source = listingHasOverride ? "listing" : "user"; return NextResponse.json({ enabled, @@ -126,7 +151,10 @@ export async function POST(req: Request) { listing: listing ? { id: listing.id, - slug: pickTranslation(listing.translations)?.slug ?? listing.translations[0]?.slug ?? null, + slug: + pickTranslation(listing.translations)?.slug ?? + listing.translations[0]?.slug ?? + null, status: listing.status, } : null, @@ -134,4 +162,4 @@ export async function POST(req: Request) { }); } -export const dynamic = 'force-dynamic'; +export const dynamic = "force-dynamic"; diff --git a/app/api/listings/[id]/availability/route.ts b/app/api/listings/[id]/availability/route.ts index 366b696..7d16fde 100644 --- a/app/api/listings/[id]/availability/route.ts +++ b/app/api/listings/[id]/availability/route.ts @@ -1,20 +1,38 @@ -import { NextResponse } from 'next/server'; -import { prisma } from '../../../../../lib/prisma'; -import { expandBlockedDates, getCalendarRanges } from '../../../../../lib/calendar'; +import { NextResponse } from "next/server"; +import { prisma } from "../../../../../lib/prisma"; +import { + expandBlockedDates, + getCalendarRanges, +} from "../../../../../lib/calendar"; export async function GET(_: Request, { params }: { params: { id: string } }) { - const monthParam = Number(new URL(_.url).searchParams.get('month') ?? new Date().getUTCMonth()); - const yearParam = Number(new URL(_.url).searchParams.get('year') ?? new Date().getUTCFullYear()); - const monthsParam = Math.min(Number(new URL(_.url).searchParams.get('months') ?? 1), 12); - const forceRefresh = new URL(_.url).searchParams.get('refresh') === '1'; + const monthParam = Number( + new URL(_.url).searchParams.get("month") ?? new Date().getUTCMonth(), + ); + const yearParam = Number( + new URL(_.url).searchParams.get("year") ?? new Date().getUTCFullYear(), + ); + const monthsParam = Math.min( + Number(new URL(_.url).searchParams.get("months") ?? 1), + 12, + ); + const forceRefresh = new URL(_.url).searchParams.get("refresh") === "1"; - const month = Number.isFinite(monthParam) ? monthParam : new Date().getUTCMonth(); - const year = Number.isFinite(yearParam) ? yearParam : new Date().getUTCFullYear(); - const months = Number.isFinite(monthsParam) && monthsParam > 0 ? monthsParam : 1; + const month = Number.isFinite(monthParam) + ? monthParam + : new Date().getUTCMonth(); + const year = Number.isFinite(yearParam) + ? yearParam + : new Date().getUTCFullYear(); + const months = + Number.isFinite(monthsParam) && monthsParam > 0 ? monthsParam : 1; - const listing = await prisma.listing.findUnique({ where: { id: params.id }, select: { calendarUrls: true } }); + const listing = await prisma.listing.findUnique({ + where: { id: params.id }, + select: { calendarUrls: true }, + }); if (!listing) { - return NextResponse.json({ error: 'Listing not found' }, { status: 404 }); + return NextResponse.json({ error: "Listing not found" }, { status: 404 }); } const urls = (listing.calendarUrls ?? []).filter(Boolean); diff --git a/app/api/listings/[id]/images/[imageId]/route.ts b/app/api/listings/[id]/images/[imageId]/route.ts index 10fc59c..9c04dd5 100644 --- a/app/api/listings/[id]/images/[imageId]/route.ts +++ b/app/api/listings/[id]/images/[imageId]/route.ts @@ -1,9 +1,12 @@ -import { Role } from '@prisma/client'; -import { NextResponse } from 'next/server'; -import { prisma } from '../../../../../../lib/prisma'; -import { requireAuth } from '../../../../../../lib/jwt'; +import { Role } from "@prisma/client"; +import { NextResponse } from "next/server"; +import { prisma } from "../../../../../../lib/prisma"; +import { requireAuth } from "../../../../../../lib/jwt"; -export async function DELETE(req: Request, { params }: { params: { id: string; imageId: string } }) { +export async function DELETE( + req: Request, + { params }: { params: { id: string; imageId: string } }, +) { try { const auth = await requireAuth(req); const listing = await prisma.listing.findUnique({ @@ -12,38 +15,48 @@ export async function DELETE(req: Request, { params }: { params: { id: string; i id: true, ownerId: true, status: true, - images: { orderBy: { order: 'asc' }, select: { id: true, isCover: true, order: true } }, + images: { + orderBy: { order: "asc" }, + select: { id: true, isCover: true, order: true }, + }, }, }); if (!listing) { - return NextResponse.json({ error: 'Listing not found' }, { status: 404 }); + return NextResponse.json({ error: "Listing not found" }, { status: 404 }); } const isOwner = listing.ownerId === auth.userId; const isAdmin = auth.role === Role.ADMIN; if (!isOwner && !isAdmin) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } const targetImage = listing.images.find((img) => img.id === params.imageId); if (!targetImage) { - return NextResponse.json({ error: 'Image not found' }, { status: 404 }); + return NextResponse.json({ error: "Image not found" }, { status: 404 }); } if (listing.images.length <= 1) { - return NextResponse.json({ error: 'At least one image is required' }, { status: 400 }); + return NextResponse.json( + { error: "At least one image is required" }, + { status: 400 }, + ); } const remaining = listing.images.filter((img) => img.id !== params.imageId); - const newCoverId = remaining.find((img) => img.isCover)?.id ?? remaining[0]?.id ?? null; + const newCoverId = + remaining.find((img) => img.isCover)?.id ?? remaining[0]?.id ?? null; await prisma.$transaction([ prisma.listingImage.delete({ where: { id: params.imageId } }), ...remaining.map((img, idx) => prisma.listingImage.update({ where: { id: img.id }, - data: { order: idx + 1, isCover: newCoverId ? img.id === newCoverId : img.isCover }, + data: { + order: idx + 1, + isCover: newCoverId ? img.id === newCoverId : img.isCover, + }, }), ), ]); @@ -52,20 +65,31 @@ export async function DELETE(req: Request, { params }: { params: { id: string; i where: { id: listing.id }, select: { images: { - orderBy: { order: 'asc' }, - select: { id: true, url: true, altText: true, order: true, isCover: true, size: true, mimeType: true }, + orderBy: { order: "asc" }, + select: { + id: true, + url: true, + altText: true, + order: true, + isCover: true, + size: true, + mimeType: true, + }, }, }, }); return NextResponse.json({ ok: true, images: updated?.images ?? [] }); } catch (error: any) { - console.error('Delete listing image error', error); - if (String(error).includes('Unauthorized')) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + console.error("Delete listing image error", error); + if (String(error).includes("Unauthorized")) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - return NextResponse.json({ error: 'Failed to delete image' }, { status: 500 }); + return NextResponse.json( + { error: "Failed to delete image" }, + { status: 500 }, + ); } } -export const dynamic = 'force-dynamic'; +export const dynamic = "force-dynamic"; diff --git a/app/api/listings/[id]/route.ts b/app/api/listings/[id]/route.ts index 30d1be1..59acf7f 100644 --- a/app/api/listings/[id]/route.ts +++ b/app/api/listings/[id]/route.ts @@ -1,40 +1,65 @@ -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 +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 } }) { +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 } }, + 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({ 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 }); + 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 } }) { +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 }); + 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({ @@ -43,81 +68,151 @@ export async function PUT(req: Request, { params }: { params: { id: string } }) }); if (!existing) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }); + 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(); + const baseSlug = String(body.slug ?? existing.translations[0]?.slug ?? "") + .trim() + .toLowerCase(); if (!baseSlug) { - return NextResponse.json({ error: 'Missing slug' }, { status: 400 }); + 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 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 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 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(), + 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(); + 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)) { + if ( + translationsInput.length === 0 && + (fallbackTranslationTitle || saveDraft) && + (fallbackTranslationDescription || saveDraft) + ) { translationsInput.push({ locale: fallbackLocale, - title: fallbackTranslationTitle ?? '', - description: fallbackTranslationDescription ?? '', + title: fallbackTranslationTitle ?? "", + description: fallbackTranslationDescription ?? "", teaser: fallbackTranslationTeaser, slug: baseSlug, }); } - const imagesBody = Array.isArray(body.images) ? body.images.slice(0, MAX_IMAGES) : null; + 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'); + 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 (!hasImagesIncoming && existing.images.length === 0) + missing.push("images"); if (missing.length) { - return NextResponse.json({ error: `Missing required fields: ${missing.join(', ')}` }, { status: 400 }); + 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); + const autoApprove = + !saveDraft && + (process.env.AUTO_APPROVE_LISTINGS === "true" || + auth.role === Role.ADMIN); if (saveDraft) { status = ListingStatus.DRAFT; } else if (existing.status === ListingStatus.PUBLISHED) { @@ -137,14 +232,21 @@ export async function PUT(req: Request, { params }: { params: { id: string } }) }[] = []; if (imagesBody) { - const coverImageIndex = Math.min(Math.max(Number(body.coverImageIndex ?? 1), 1), imagesBody.length || 1); + 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; + 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; @@ -152,15 +254,20 @@ export async function PUT(req: Request, { params }: { params: { id: string } }) const dataUrlMatch = rawData.match(/^data:(.*?);base64,(.*)$/); if (dataUrlMatch) { mimeType = mimeType || dataUrlMatch[1] || null; - buffer = Buffer.from(dataUrlMatch[2], 'base64'); + buffer = Buffer.from(dataUrlMatch[2], "base64"); } else { - buffer = Buffer.from(rawData, 'base64'); + 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 }); + return NextResponse.json( + { + error: `Image ${idx + 1} is too large (max ${Math.floor(MAX_IMAGE_BYTES / 1024 / 1024)}MB)`, + }, + { status: 400 }, + ); } if (!buffer && !rawUrl) { @@ -169,7 +276,7 @@ export async function PUT(req: Request, { params }: { params: { id: string } }) parsedImages.push({ data: buffer ?? undefined, - mimeType: mimeType || 'image/jpeg', + mimeType: mimeType || "image/jpeg", size, url: buffer ? null : rawUrl, altText, @@ -184,44 +291,107 @@ export async function PUT(req: Request, { params }: { params: { id: string } }) } const incomingEvChargingOnSite = - body.evChargingOnSite === undefined ? existing.evChargingOnSite : Boolean(body.evChargingOnSite); + 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; + 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, + 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, + 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), + 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), + body.wheelchairAccessible === undefined + ? existing.wheelchairAccessible + : Boolean(body.wheelchairAccessible), priceWeekdayEuros, priceWeekendEuros, calendarUrls, @@ -236,7 +406,9 @@ export async function PUT(req: Request, { params }: { params: { id: string } }) if (translationsInput && translationsInput.length) { tx.push( - prisma.listingTranslation.deleteMany({ where: { listingId: existing.id } }), + prisma.listingTranslation.deleteMany({ + where: { listingId: existing.id }, + }), prisma.listingTranslation.createMany({ data: translationsInput.map((t) => ({ listingId: existing.id, @@ -251,12 +423,14 @@ export async function PUT(req: Request, { params }: { params: { id: string } }) } if (parsedImages && parsedImages.length) { - tx.push(prisma.listingImage.deleteMany({ where: { listingId: existing.id } })); + 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', + mimeType: img.mimeType || "image/jpeg", size: img.size ?? null, url: img.url ?? null, altText: img.altText ?? null, @@ -268,7 +442,9 @@ export async function PUT(req: Request, { params }: { params: { id: string } }) ); } - tx.unshift(prisma.listing.update({ where: { id: existing.id }, data: updateData })); + tx.unshift( + prisma.listing.update({ where: { id: existing.id }, data: updateData }), + ); await prisma.$transaction(tx); @@ -276,15 +452,29 @@ export async function PUT(req: Request, { params }: { params: { id: string } }) 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 } }, + 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; + 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 }); } } diff --git a/app/api/listings/check-slug/route.ts b/app/api/listings/check-slug/route.ts index ee5e290..f8da5f1 100644 --- a/app/api/listings/check-slug/route.ts +++ b/app/api/listings/check-slug/route.ts @@ -1,12 +1,12 @@ -import { NextResponse } from 'next/server'; -import { prisma } from '../../../../lib/prisma'; +import { NextResponse } from "next/server"; +import { prisma } from "../../../../lib/prisma"; export async function GET(req: Request) { const url = new URL(req.url); - const slug = url.searchParams.get('slug')?.trim().toLowerCase(); + const slug = url.searchParams.get("slug")?.trim().toLowerCase(); if (!slug) { - return NextResponse.json({ error: 'Missing slug' }, { status: 400 }); + return NextResponse.json({ error: "Missing slug" }, { status: 400 }); } const existing = await prisma.listingTranslation.findFirst({ diff --git a/app/api/listings/mine/route.ts b/app/api/listings/mine/route.ts index 683a165..72ebac0 100644 --- a/app/api/listings/mine/route.ts +++ b/app/api/listings/mine/route.ts @@ -1,22 +1,29 @@ -import { NextResponse } from 'next/server'; -import { prisma } from '../../../../lib/prisma'; -import { UserStatus } from '@prisma/client'; -import { requireAuth } from '../../../../lib/jwt'; +import { NextResponse } from "next/server"; +import { prisma } from "../../../../lib/prisma"; +import { UserStatus } from "@prisma/client"; +import { requireAuth } from "../../../../lib/jwt"; export async function GET(req: Request) { try { const session = await requireAuth(req); - const user = await prisma.user.findUnique({ where: { id: session.userId }, select: { status: true } }); + const user = await prisma.user.findUnique({ + where: { id: session.userId }, + select: { status: true }, + }); if (!user || user.status !== UserStatus.ACTIVE) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const listings = await prisma.listing.findMany({ where: { ownerId: session.userId }, - select: { id: true, status: true, translations: { select: { slug: true, title: true, locale: true } } }, - orderBy: { createdAt: 'desc' }, + select: { + id: true, + status: true, + translations: { select: { slug: true, title: true, locale: true } }, + }, + orderBy: { createdAt: "desc" }, }); return NextResponse.json({ listings }); } catch (error) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } } diff --git a/app/api/listings/remove/route.ts b/app/api/listings/remove/route.ts index 1a5af75..1859d1a 100644 --- a/app/api/listings/remove/route.ts +++ b/app/api/listings/remove/route.ts @@ -1,17 +1,20 @@ -import { NextResponse } from 'next/server'; -import { ListingStatus, Role } from '@prisma/client'; -import { prisma } from '../../../../lib/prisma'; -import { requireAuth } from '../../../../lib/jwt'; +import { NextResponse } from "next/server"; +import { ListingStatus, Role } from "@prisma/client"; +import { prisma } from "../../../../lib/prisma"; +import { requireAuth } from "../../../../lib/jwt"; export async function POST(req: Request) { try { const auth = await requireAuth(req); const body = await req.json(); - const listingId = String(body.listingId ?? ''); + const listingId = String(body.listingId ?? ""); const reason = body.reason ? String(body.reason).slice(0, 500) : null; if (!listingId) { - return NextResponse.json({ error: 'listingId is required' }, { status: 400 }); + return NextResponse.json( + { error: "listingId is required" }, + { status: 400 }, + ); } const listing = await prisma.listing.findUnique({ @@ -19,14 +22,14 @@ export async function POST(req: Request) { select: { id: true, ownerId: true, status: true }, }); if (!listing) { - return NextResponse.json({ error: 'Listing not found' }, { status: 404 }); + return NextResponse.json({ error: "Listing not found" }, { status: 404 }); } const isOwner = listing.ownerId === auth.userId; const canModerate = auth.role === Role.ADMIN; if (!isOwner && !canModerate) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } if (listing.status === ListingStatus.REMOVED) { @@ -40,15 +43,18 @@ export async function POST(req: Request) { published: false, removedAt: new Date(), removedById: auth.userId, - removedReason: reason ?? (isOwner ? 'Removed by owner' : null), + removedReason: reason ?? (isOwner ? "Removed by owner" : null), }, select: { id: true, status: true, removedAt: true, removedReason: true }, }); return NextResponse.json({ ok: true, listing: updated }); } catch (error) { - console.error('Remove listing error', error); - return NextResponse.json({ error: 'Failed to remove listing' }, { status: 500 }); + console.error("Remove listing error", error); + return NextResponse.json( + { error: "Failed to remove listing" }, + { status: 500 }, + ); } } -export const dynamic = 'force-dynamic'; +export const dynamic = "force-dynamic"; diff --git a/app/api/listings/route.ts b/app/api/listings/route.ts index 63ccfc7..f72198b 100644 --- a/app/api/listings/route.ts +++ b/app/api/listings/route.ts @@ -1,23 +1,30 @@ -import { NextResponse } from 'next/server'; -import { ListingStatus, UserStatus, Prisma } from '@prisma/client'; -import { prisma } from '../../../lib/prisma'; -import { requireAuth } from '../../../lib/jwt'; -import { resolveLocale } from '../../../lib/i18n'; -import { SAMPLE_LISTING_SLUGS } from '../../../lib/sampleListing'; -import { getCalendarRanges, isRangeAvailable } from '../../../lib/calendar'; +import { NextResponse } from "next/server"; +import { ListingStatus, UserStatus, Prisma } from "@prisma/client"; +import { prisma } from "../../../lib/prisma"; +import { requireAuth } from "../../../lib/jwt"; +import { resolveLocale } from "../../../lib/i18n"; +import { SAMPLE_LISTING_SLUGS } from "../../../lib/sampleListing"; +import { getCalendarRanges, isRangeAvailable } from "../../../lib/calendar"; const MAX_IMAGES = 6; const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5 MB per image -const SAMPLE_EMAIL = 'host@lomavuokraus.fi'; +const SAMPLE_EMAIL = "host@lomavuokraus.fi"; -function resolveImageUrl(img: { id: string; url: string | null; size: number | null }) { +function resolveImageUrl(img: { + id: string; + url: string | null; + size: number | null; +}) { if (img.size && img.size > 0) { return `/api/images/${img.id}`; } return img.url ?? null; } -function pickTranslation(translations: T[], locale: string | null): T | null { +function pickTranslation( + translations: T[], + locale: string | null, +): T | null { if (!translations.length) return null; if (locale) { const exact = translations.find((t) => t.locale === locale); @@ -29,11 +36,11 @@ function pickTranslation(translations: T[], locale export function normalizeCalendarUrls(input: unknown): string[] { if (Array.isArray(input)) { return input - .map((u) => (typeof u === 'string' ? u.trim() : '')) + .map((u) => (typeof u === "string" ? u.trim() : "")) .filter(Boolean) .slice(0, 5); } - if (typeof input === 'string') { + if (typeof input === "string") { return input .split(/\n|,/) .map((u) => u.trim()) @@ -44,7 +51,7 @@ export function normalizeCalendarUrls(input: unknown): string[] { } export function parsePrice(value: unknown): number | null { - if (value === undefined || value === null || value === '') return null; + if (value === undefined || value === null || value === "") return null; const num = Number(value); if (Number.isNaN(num)) return null; return Math.round(num); @@ -53,44 +60,63 @@ export function parsePrice(value: unknown): number | null { export async function GET(req: Request) { const url = new URL(req.url); const searchParams = url.searchParams; - const q = searchParams.get('q')?.trim(); - const city = searchParams.get('city')?.trim(); - const region = searchParams.get('region')?.trim(); - const evChargingParam = searchParams.get('evCharging'); - const evCharging = evChargingParam === 'true' ? true : evChargingParam === 'false' ? false : null; - const evChargingOnSiteParam = searchParams.get('evChargingOnSite'); - const evChargingOnSite = evChargingOnSiteParam === 'true' ? true : evChargingOnSiteParam === 'false' ? false : null; - const startDateParam = searchParams.get('availableStart'); - const endDateParam = searchParams.get('availableEnd'); + const q = searchParams.get("q")?.trim(); + const city = searchParams.get("city")?.trim(); + const region = searchParams.get("region")?.trim(); + const evChargingParam = searchParams.get("evCharging"); + const evCharging = + evChargingParam === "true" + ? true + : evChargingParam === "false" + ? false + : null; + const evChargingOnSiteParam = searchParams.get("evChargingOnSite"); + const evChargingOnSite = + evChargingOnSiteParam === "true" + ? true + : evChargingOnSiteParam === "false" + ? false + : null; + const startDateParam = searchParams.get("availableStart"); + const endDateParam = searchParams.get("availableEnd"); const startDate = startDateParam ? new Date(startDateParam) : null; const endDate = endDateParam ? new Date(endDateParam) : null; - const availabilityFilterActive = Boolean(startDate && endDate && startDate < endDate); - const amenityFilters = searchParams.getAll('amenity').map((a) => a.trim().toLowerCase()); - const locale = resolveLocale({ cookieLocale: null, acceptLanguage: req.headers.get('accept-language') }); - const limit = Math.min(Number(searchParams.get('limit') ?? 40), 100); + const availabilityFilterActive = Boolean( + startDate && endDate && startDate < endDate, + ); + const amenityFilters = searchParams + .getAll("amenity") + .map((a) => a.trim().toLowerCase()); + const locale = resolveLocale({ + cookieLocale: null, + acceptLanguage: req.headers.get("accept-language"), + }); + const limit = Math.min(Number(searchParams.get("limit") ?? 40), 100); const amenityWhere: Prisma.ListingWhereInput = {}; - if (amenityFilters.includes('sauna')) amenityWhere.hasSauna = true; - if (amenityFilters.includes('fireplace')) amenityWhere.hasFireplace = true; - if (amenityFilters.includes('wifi')) amenityWhere.hasWifi = true; - if (amenityFilters.includes('pets')) amenityWhere.petsAllowed = true; - if (amenityFilters.includes('lake')) amenityWhere.byTheLake = true; - if (amenityFilters.includes('ac')) amenityWhere.hasAirConditioning = true; - if (amenityFilters.includes('kitchen')) amenityWhere.hasKitchen = true; - if (amenityFilters.includes('dishwasher')) amenityWhere.hasDishwasher = true; - if (amenityFilters.includes('washer')) amenityWhere.hasWashingMachine = true; - if (amenityFilters.includes('barbecue')) amenityWhere.hasBarbecue = true; - if (amenityFilters.includes('microwave')) amenityWhere.hasMicrowave = true; - if (amenityFilters.includes('parking')) amenityWhere.hasFreeParking = true; - if (amenityFilters.includes('skipass')) amenityWhere.hasSkiPass = true; - if (amenityFilters.includes('accessible')) amenityWhere.wheelchairAccessible = true; - if (amenityFilters.includes('ev-onsite')) amenityWhere.evChargingOnSite = true; + if (amenityFilters.includes("sauna")) amenityWhere.hasSauna = true; + if (amenityFilters.includes("fireplace")) amenityWhere.hasFireplace = true; + if (amenityFilters.includes("wifi")) amenityWhere.hasWifi = true; + if (amenityFilters.includes("pets")) amenityWhere.petsAllowed = true; + if (amenityFilters.includes("lake")) amenityWhere.byTheLake = true; + if (amenityFilters.includes("ac")) amenityWhere.hasAirConditioning = true; + if (amenityFilters.includes("kitchen")) amenityWhere.hasKitchen = true; + if (amenityFilters.includes("dishwasher")) amenityWhere.hasDishwasher = true; + if (amenityFilters.includes("washer")) amenityWhere.hasWashingMachine = true; + if (amenityFilters.includes("barbecue")) amenityWhere.hasBarbecue = true; + if (amenityFilters.includes("microwave")) amenityWhere.hasMicrowave = true; + if (amenityFilters.includes("parking")) amenityWhere.hasFreeParking = true; + if (amenityFilters.includes("skipass")) amenityWhere.hasSkiPass = true; + if (amenityFilters.includes("accessible")) + amenityWhere.wheelchairAccessible = true; + if (amenityFilters.includes("ev-onsite")) + amenityWhere.evChargingOnSite = true; const where: Prisma.ListingWhereInput = { status: ListingStatus.PUBLISHED, removedAt: null, - city: city ? { contains: city, mode: 'insensitive' } : undefined, - region: region ? { contains: region, mode: 'insensitive' } : undefined, + city: city ? { contains: city, mode: "insensitive" } : undefined, + region: region ? { contains: region, mode: "insensitive" } : undefined, evChargingAvailable: evCharging ?? undefined, evChargingOnSite: evChargingOnSite ?? undefined, ...amenityWhere, @@ -98,9 +124,9 @@ export async function GET(req: Request) { ? { some: { OR: [ - { title: { contains: q, mode: 'insensitive' } }, - { description: { contains: q, mode: 'insensitive' } }, - { teaser: { contains: q, mode: 'insensitive' } }, + { title: { contains: q, mode: "insensitive" } }, + { description: { contains: q, mode: "insensitive" } }, + { teaser: { contains: q, mode: "insensitive" } }, ], }, } @@ -110,10 +136,29 @@ export async function GET(req: Request) { const listings = await prisma.listing.findMany({ where, include: { - translations: { select: { id: true, locale: true, title: true, slug: true, teaser: true, description: true } }, - images: { select: { id: true, url: true, altText: true, order: true, isCover: true, size: true }, orderBy: { order: 'asc' } }, + translations: { + select: { + id: true, + locale: true, + title: true, + slug: true, + teaser: true, + description: true, + }, + }, + images: { + select: { + id: true, + url: true, + altText: true, + order: true, + isCover: true, + size: true, + }, + orderBy: { order: "asc" }, + }, }, - orderBy: { createdAt: 'desc' }, + orderBy: { createdAt: "desc" }, take: Number.isNaN(limit) ? 40 : limit, }); @@ -139,7 +184,9 @@ export async function GET(req: Request) { listing.isSample || listing.contactEmail === SAMPLE_EMAIL || SAMPLE_LISTING_SLUGS.includes( - pickTranslation(listing.translations, locale)?.slug ?? listing.translations[0]?.slug ?? '', + pickTranslation(listing.translations, locale)?.slug ?? + listing.translations[0]?.slug ?? + "", ); const translation = pickTranslation(listing.translations, locale); const fallback = listing.translations[0]; @@ -147,16 +194,20 @@ export async function GET(req: Request) { if (!url) return false; try { const parsed = new URL(url); - return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + return parsed.protocol === "http:" || parsed.protocol === "https:"; } catch { return false; } }); return { id: listing.id, - title: translation?.title ?? fallback?.title ?? 'Listing', - slug: translation?.slug ?? fallback?.slug ?? '', - teaser: translation?.teaser ?? translation?.description ?? fallback?.description ?? null, + title: translation?.title ?? fallback?.title ?? "Listing", + slug: translation?.slug ?? fallback?.slug ?? "", + teaser: + translation?.teaser ?? + translation?.description ?? + fallback?.description ?? + null, locale: translation?.locale ?? fallback?.locale ?? locale, country: listing.country, region: listing.region, @@ -187,10 +238,15 @@ export async function GET(req: Request) { bathrooms: listing.bathrooms, priceWeekdayEuros: listing.priceWeekdayEuros, priceWeekendEuros: listing.priceWeekendEuros, - coverImage: resolveImageUrl(listing.images.find((img) => img.isCover) ?? listing.images[0] ?? { id: '', url: null, size: null }), + coverImage: resolveImageUrl( + listing.images.find((img) => img.isCover) ?? + listing.images[0] ?? { id: "", url: null, size: null }, + ), isSample, hasCalendar: Boolean(validCalendarUrls.length), - availableForDates: availabilityFilterActive ? Boolean(availabilityMap.get(listing.id)) : undefined, + availableForDates: availabilityFilterActive + ? Boolean(availabilityMap.get(listing.id)) + : undefined, }; }); @@ -201,68 +257,127 @@ export async function POST(req: Request) { 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 create listings' }, { status: 403 }); + if ( + !user || + !user.emailVerifiedAt || + !user.approvedAt || + user.status !== UserStatus.ACTIVE + ) { + return NextResponse.json( + { error: "User not permitted to create listings" }, + { status: 403 }, + ); } 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(); - const city = String(body.city ?? '').trim(); - const streetAddress = String(body.streetAddress ?? '').trim(); - const contactName = String(body.contactName ?? '').trim(); - const contactEmail = String(body.contactEmail ?? '').trim(); + const slug = String(body.slug ?? "") + .trim() + .toLowerCase(); + const country = String(body.country ?? "").trim(); + const region = String(body.region ?? "").trim(); + const city = String(body.city ?? "").trim(); + const streetAddress = String(body.streetAddress ?? "").trim(); + const contactName = String(body.contactName ?? "").trim(); + const contactEmail = String(body.contactEmail ?? "").trim(); if (!slug) { - return NextResponse.json({ error: 'Missing slug' }, { status: 400 }); + return NextResponse.json({ error: "Missing slug" }, { status: 400 }); } - 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 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); - const translationsInputRaw = Array.isArray(body.translations) ? body.translations : []; - type TranslationInput = { locale: string; title: string; description: string; teaser: string | null; slug: string }; + const translationsInputRaw = Array.isArray(body.translations) + ? body.translations + : []; + type TranslationInput = { + locale: string; + title: string; + description: string; + teaser: string | null; + slug: string; + }; let translationsInput = 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 ?? slug).trim().toLowerCase(), + 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 ?? slug) + .trim() + .toLowerCase(), })) - .filter((t: any) => t.locale && (saveDraft || (t.title && t.description))) || []; + .filter( + (t: any) => t.locale && (saveDraft || (t.title && t.description)), + ) || []; - const fallbackLocale = String(body.locale ?? 'en').toLowerCase(); - const fallbackTranslationTitle = typeof body.title === 'string' ? body.title.trim() : ''; - const fallbackTranslationDescription = typeof body.description === 'string' ? body.description.trim() : ''; - const fallbackTranslationTeaser = typeof body.teaser === 'string' ? body.teaser.trim() : null; + const fallbackLocale = String(body.locale ?? "en").toLowerCase(); + const fallbackTranslationTitle = + typeof body.title === "string" ? body.title.trim() : ""; + const fallbackTranslationDescription = + typeof body.description === "string" ? body.description.trim() : ""; + const fallbackTranslationTeaser = + typeof body.teaser === "string" ? body.teaser.trim() : null; - if (translationsInput.length === 0 && (fallbackTranslationTitle || saveDraft) && (fallbackTranslationDescription || saveDraft)) { + if ( + translationsInput.length === 0 && + (fallbackTranslationTitle || saveDraft) && + (fallbackTranslationDescription || saveDraft) + ) { translationsInput.push({ locale: fallbackLocale, - title: fallbackTranslationTitle ?? '', - description: fallbackTranslationDescription ?? '', + title: fallbackTranslationTitle ?? "", + description: fallbackTranslationDescription ?? "", teaser: fallbackTranslationTeaser, slug, }); } if (!translationsInput.length && !saveDraft) { - return NextResponse.json({ error: 'Missing translation fields (title/description)' }, { status: 400 }); + return NextResponse.json( + { error: "Missing translation fields (title/description)" }, + { status: 400 }, + ); } - const images = Array.isArray(body.images) ? body.images.slice(0, MAX_IMAGES) : []; + const images = Array.isArray(body.images) + ? body.images.slice(0, MAX_IMAGES) + : []; if (Array.isArray(body.images) && body.images.length > MAX_IMAGES) { - return NextResponse.json({ error: `Too many images (max ${MAX_IMAGES})` }, { status: 400 }); + return NextResponse.json( + { error: `Too many images (max ${MAX_IMAGES})` }, + { status: 400 }, + ); } - const coverImageIndex = Math.min(Math.max(Number(body.coverImageIndex ?? 1), 1), images.length || 1); + const coverImageIndex = Math.min( + Math.max(Number(body.coverImageIndex ?? 1), 1), + images.length || 1, + ); const parsedImages: { data?: Buffer; @@ -276,10 +391,14 @@ export async function POST(req: Request) { for (let idx = 0; idx < images.length; idx += 1) { const img = images[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; + 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; @@ -287,15 +406,20 @@ export async function POST(req: Request) { const dataUrlMatch = rawData.match(/^data:(.*?);base64,(.*)$/); if (dataUrlMatch) { mimeType = mimeType || dataUrlMatch[1] || null; - buffer = Buffer.from(dataUrlMatch[2], 'base64'); + buffer = Buffer.from(dataUrlMatch[2], "base64"); } else { - buffer = Buffer.from(rawData, 'base64'); + 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 }); + return NextResponse.json( + { + error: `Image ${idx + 1} is too large (max ${Math.floor(MAX_IMAGE_BYTES / 1024 / 1024)}MB)`, + }, + { status: 400 }, + ); } if (!buffer && !rawUrl) { @@ -304,7 +428,7 @@ export async function POST(req: Request) { parsedImages.push({ data: buffer ?? undefined, - mimeType: mimeType || 'image/jpeg', + mimeType: mimeType || "image/jpeg", size, url: buffer ? null : rawUrl, altText, @@ -319,27 +443,37 @@ export async function POST(req: Request) { if (!saveDraft) { const missingFields: string[] = []; - if (!country) missingFields.push('country'); - if (!region) missingFields.push('region'); - if (!city) missingFields.push('city'); - if (!contactEmail) missingFields.push('contactEmail'); - if (!contactName) missingFields.push('contactName'); - if (!maxGuests) missingFields.push('maxGuests'); - if (!bedrooms && bedrooms !== 0) missingFields.push('bedrooms'); - if (!beds) missingFields.push('beds'); - if (!bathrooms) missingFields.push('bathrooms'); - if (!translationsInput.length) missingFields.push('translations'); - if (!parsedImages.length) missingFields.push('images'); + if (!country) missingFields.push("country"); + if (!region) missingFields.push("region"); + if (!city) missingFields.push("city"); + if (!contactEmail) missingFields.push("contactEmail"); + if (!contactName) missingFields.push("contactName"); + if (!maxGuests) missingFields.push("maxGuests"); + if (!bedrooms && bedrooms !== 0) missingFields.push("bedrooms"); + if (!beds) missingFields.push("beds"); + if (!bathrooms) missingFields.push("bathrooms"); + if (!translationsInput.length) missingFields.push("translations"); + if (!parsedImages.length) missingFields.push("images"); if (missingFields.length) { - return NextResponse.json({ error: `Missing required fields: ${missingFields.join(', ')}` }, { status: 400 }); + return NextResponse.json( + { error: `Missing required fields: ${missingFields.join(", ")}` }, + { 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 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 evChargingOnSite = Boolean(body.evChargingOnSite); - const evChargingAvailable = Boolean(body.evChargingAvailable) || evChargingOnSite; + const evChargingAvailable = + Boolean(body.evChargingAvailable) || evChargingOnSite; const wheelchairAccessible = Boolean(body.wheelchairAccessible); const listing = await prisma.listing.create({ @@ -347,14 +481,24 @@ export async function POST(req: Request) { ownerId: user.id, status, approvedAt: autoApprove ? new Date() : null, - approvedById: autoApprove && auth.role === 'ADMIN' ? user.id : null, + approvedById: autoApprove && auth.role === "ADMIN" ? user.id : null, 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, - longitude: body.longitude !== undefined && body.longitude !== null && body.longitude !== '' ? Number(body.longitude) : null, + latitude: + body.latitude !== undefined && + body.latitude !== null && + body.latitude !== "" + ? Number(body.latitude) + : null, + longitude: + body.longitude !== undefined && + body.longitude !== null && + body.longitude !== "" + ? Number(body.longitude) + : null, maxGuests, bedrooms, beds, @@ -401,13 +545,28 @@ export async function POST(req: Request) { } : undefined, }, - include: { translations: true, images: { select: { id: true, altText: true, order: true, isCover: true, size: true, url: true } } }, + include: { + translations: true, + images: { + select: { + id: true, + altText: true, + order: true, + isCover: true, + size: true, + url: true, + }, + }, + }, }); return NextResponse.json({ ok: true, listing }); } catch (error: any) { - console.error('Create listing error', error); - const message = error?.code === 'P2002' ? 'Slug already exists for this locale' : 'Failed to create listing'; + console.error("Create listing error", error); + const message = + error?.code === "P2002" + ? "Slug already exists for this locale" + : "Failed to create listing"; return NextResponse.json({ error: message }, { status: 400 }); } } diff --git a/app/api/listings/translate/route.ts b/app/api/listings/translate/route.ts index 4d7558e..05660b3 100644 --- a/app/api/listings/translate/route.ts +++ b/app/api/listings/translate/route.ts @@ -1,24 +1,29 @@ -import { NextResponse } from 'next/server'; -import fs from 'fs'; -import path from 'path'; -import { requireAuth } from '../../../../lib/jwt'; -import type { Locale } from '../../../../lib/i18n'; +import { NextResponse } from "next/server"; +import fs from "fs"; +import path from "path"; +import { requireAuth } from "../../../../lib/jwt"; +import type { Locale } from "../../../../lib/i18n"; type LocaleFields = { title: string; teaser: string; description: string }; -const SUPPORTED_LOCALES: Locale[] = ['en', 'fi', 'sv']; +const SUPPORTED_LOCALES: Locale[] = ["en", "fi", "sv"]; function loadApiKey() { - if (process.env.OPENAI_TRANSLATIONS_KEY) return process.env.OPENAI_TRANSLATIONS_KEY; - const newKeyPath = path.join(process.cwd(), 'creds', 'openai-translations.key'); + if (process.env.OPENAI_TRANSLATIONS_KEY) + return process.env.OPENAI_TRANSLATIONS_KEY; + const newKeyPath = path.join( + process.cwd(), + "creds", + "openai-translations.key", + ); try { - return fs.readFileSync(newKeyPath, 'utf8').trim(); + return fs.readFileSync(newKeyPath, "utf8").trim(); } catch { // ignore } if (process.env.OPENAI_API_KEY) return process.env.OPENAI_API_KEY; - const fallbackPath = path.join(process.cwd(), 'creds', 'openai.key'); + const fallbackPath = path.join(process.cwd(), "creds", "openai.key"); try { - return fs.readFileSync(fallbackPath, 'utf8').trim(); + return fs.readFileSync(fallbackPath, "utf8").trim(); } catch { return null; } @@ -28,34 +33,42 @@ export async function POST(req: Request) { try { await requireAuth(req); } catch (err) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const apiKey = loadApiKey(); if (!apiKey) { - return NextResponse.json({ error: 'Missing OpenAI API key' }, { status: 500 }); + return NextResponse.json( + { error: "Missing OpenAI API key" }, + { status: 500 }, + ); } let body: any; try { body = await req.json(); } catch { - return NextResponse.json({ error: 'Invalid request' }, { status: 400 }); + return NextResponse.json({ error: "Invalid request" }, { status: 400 }); } - const incoming = body?.translations as Record | undefined; - const currentLocale = (body?.currentLocale as Locale) ?? 'en'; + const incoming = body?.translations as + | Record + | undefined; + const currentLocale = (body?.currentLocale as Locale) ?? "en"; if (!incoming) { - return NextResponse.json({ error: 'Missing translations' }, { status: 400 }); + return NextResponse.json( + { error: "Missing translations" }, + { status: 400 }, + ); } const payload = SUPPORTED_LOCALES.reduce( (acc, loc) => ({ ...acc, [loc]: { - title: incoming[loc]?.title || '', - teaser: incoming[loc]?.teaser || '', - description: incoming[loc]?.description || '', + title: incoming[loc]?.title || "", + teaser: incoming[loc]?.teaser || "", + description: incoming[loc]?.description || "", }, }), {} as Record, @@ -63,12 +76,12 @@ export async function POST(req: Request) { const messages = [ { - role: 'system', + role: "system", content: - 'You are translating holiday rental listing copy between Finnish, Swedish, and English. Fill in missing locales, keep existing text unchanged, preserve meaning and tone, and respond with JSON only. Suggest localized slugs if missing.', + "You are translating holiday rental listing copy between Finnish, Swedish, and English. Fill in missing locales, keep existing text unchanged, preserve meaning and tone, and respond with JSON only. Suggest localized slugs if missing.", }, { - role: 'user', + role: "user", content: JSON.stringify( { sourceLocale: currentLocale, @@ -81,22 +94,22 @@ export async function POST(req: Request) { ), }, { - role: 'system', + role: "system", content: 'Return JSON with top-level "locales" containing keys en, fi, sv. Each locale has title, teaser, description, and slug. Do not include explanations.', }, ]; - let content = ''; + let content = ""; try { - const res = await fetch('https://api.openai.com/v1/chat/completions', { - method: 'POST', + const res = await fetch("https://api.openai.com/v1/chat/completions", { + method: "POST", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify({ - model: 'gpt-4o-mini', + model: "gpt-4o-mini", temperature: 0.2, messages, }), @@ -104,33 +117,42 @@ export async function POST(req: Request) { if (!res.ok) { const errText = await res.text(); - return NextResponse.json({ error: 'AI request failed', detail: errText }, { status: res.status || 500 }); + return NextResponse.json( + { error: "AI request failed", detail: errText }, + { status: res.status || 500 }, + ); } const data = await res.json(); - content = data?.choices?.[0]?.message?.content ?? ''; + content = data?.choices?.[0]?.message?.content ?? ""; } catch (err: any) { - return NextResponse.json({ error: 'AI request failed', detail: err?.message }, { status: 500 }); + return NextResponse.json( + { error: "AI request failed", detail: err?.message }, + { status: 500 }, + ); } const jsonText = content.match(/\{[\s\S]*\}/)?.[0] ?? content; try { const parsed = JSON.parse(jsonText); const locales = parsed?.locales || parsed; - if (!locales) throw new Error('missing locales'); + if (!locales) throw new Error("missing locales"); const result = SUPPORTED_LOCALES.reduce( (acc, loc) => ({ ...acc, [loc]: { - title: locales[loc]?.title ?? '', - teaser: locales[loc]?.teaser ?? '', - description: locales[loc]?.description ?? '', + title: locales[loc]?.title ?? "", + teaser: locales[loc]?.teaser ?? "", + description: locales[loc]?.description ?? "", }, }), {} as Record, ); return NextResponse.json({ translations: result }); } catch (err) { - return NextResponse.json({ error: 'Could not parse AI response' }, { status: 500 }); + return NextResponse.json( + { error: "Could not parse AI response" }, + { status: 500 }, + ); } } diff --git a/app/api/me/billing/route.ts b/app/api/me/billing/route.ts index 2f69d5d..550b557 100644 --- a/app/api/me/billing/route.ts +++ b/app/api/me/billing/route.ts @@ -1,11 +1,22 @@ -import { NextResponse } from 'next/server'; -import { UserStatus } from '@prisma/client'; -import { prisma } from '../../../../lib/prisma'; -import { requireAuth } from '../../../../lib/jwt'; -import { normalizeIban, normalizeNullableBoolean, normalizeOptionalString } from '../../../../lib/billing'; +import { NextResponse } from "next/server"; +import { UserStatus } from "@prisma/client"; +import { prisma } from "../../../../lib/prisma"; +import { requireAuth } from "../../../../lib/jwt"; +import { + normalizeIban, + normalizeNullableBoolean, + normalizeOptionalString, +} from "../../../../lib/billing"; -function pickTranslation(translations: { title: string; slug: string; locale: string }[]) { - return translations.find((t) => t.locale === 'en') || translations.find((t) => t.locale === 'fi') || translations[0] || null; +function pickTranslation( + translations: { title: string; slug: string; locale: string }[], +) { + return ( + translations.find((t) => t.locale === "en") || + translations.find((t) => t.locale === "fi") || + translations[0] || + null + ); } async function loadState(userId: string) { @@ -30,7 +41,7 @@ async function loadState(userId: string) { billingIncludeVatLine: true, translations: { select: { title: true, slug: true, locale: true } }, }, - orderBy: { createdAt: 'desc' }, + orderBy: { createdAt: "desc" }, }), ]); return { user, listings }; @@ -38,31 +49,43 @@ async function loadState(userId: string) { function validateAccountName(name: string | null | undefined) { if (name && name.length > 120) { - return 'Account owner name is too long'; + return "Account owner name is too long"; } return null; } function validateIban(iban: string | null | undefined) { if (iban && !/^[A-Z0-9]{8,34}$/.test(iban)) { - return 'IBAN must be 8-34 alphanumeric characters'; + return "IBAN must be 8-34 alphanumeric characters"; } return null; } function normalizeListingOverrides(body: any) { - type OverrideInput = { id: string | null; accountName: string | null | undefined; iban: string | null | undefined; includeVatLine: boolean | null | undefined }; - const overridesRaw: any[] = Array.isArray(body?.listings) ? body.listings : []; + type OverrideInput = { + id: string | null; + accountName: string | null | undefined; + iban: string | null | undefined; + includeVatLine: boolean | null | undefined; + }; + const overridesRaw: any[] = Array.isArray(body?.listings) + ? body.listings + : []; const mapped: OverrideInput[] = overridesRaw.map((item: any) => ({ - id: typeof item.id === 'string' ? item.id : null, + id: typeof item.id === "string" ? item.id : null, accountName: normalizeOptionalString(item.accountName), iban: normalizeIban(item.iban), includeVatLine: normalizeNullableBoolean(item.includeVatLine), })); - return mapped.filter((o): o is OverrideInput & { id: string } => Boolean(o.id)); + return mapped.filter((o): o is OverrideInput & { id: string } => + Boolean(o.id), + ); } -function buildResponsePayload(user: NonNullable>['user']>, listings: Awaited>['listings']) { +function buildResponsePayload( + user: NonNullable>["user"]>, + listings: Awaited>["listings"], +) { return { settings: { enabled: user.billingEmailsEnabled, @@ -74,9 +97,9 @@ function buildResponsePayload(user: NonNullable [l.id, l])); const listingUpdates: { id: string; data: Record }[] = []; - listingOverrides.forEach((override: { id: string; accountName: string | null | undefined; iban: string | null | undefined; includeVatLine: boolean | null | undefined }) => { - if (!listingMap.has(override.id!)) return; - const data: Record = {}; - if (override.accountName !== undefined) data.billingAccountName = override.accountName; - if (override.iban !== undefined) data.billingIban = override.iban; - if (override.includeVatLine !== undefined) data.billingIncludeVatLine = override.includeVatLine; - if (Object.keys(data).length) { - listingUpdates.push({ id: override.id!, data }); - } - }); + listingOverrides.forEach( + (override: { + id: string; + accountName: string | null | undefined; + iban: string | null | undefined; + includeVatLine: boolean | null | undefined; + }) => { + if (!listingMap.has(override.id!)) return; + const data: Record = {}; + if (override.accountName !== undefined) + data.billingAccountName = override.accountName; + if (override.iban !== undefined) data.billingIban = override.iban; + if (override.includeVatLine !== undefined) + data.billingIncludeVatLine = override.includeVatLine; + if (Object.keys(data).length) { + listingUpdates.push({ id: override.id!, data }); + } + }, + ); const targetEnabled = enabled ?? user.billingEmailsEnabled; if (targetEnabled) { - const targetAccountName = accountName !== undefined ? accountName : user.billingAccountName; + const targetAccountName = + accountName !== undefined ? accountName : user.billingAccountName; const targetIban = iban !== undefined ? iban : user.billingIban; const hasGlobalDetails = Boolean(targetAccountName && targetIban); @@ -162,9 +209,11 @@ export async function PATCH(req: Request) { const effectiveAccountName = override?.data.billingAccountName !== undefined ? override.data.billingAccountName - : listing.billingAccountName ?? targetAccountName; + : (listing.billingAccountName ?? targetAccountName); const effectiveIban = - override?.data.billingIban !== undefined ? override.data.billingIban : listing.billingIban ?? targetIban; + override?.data.billingIban !== undefined + ? override.data.billingIban + : (listing.billingIban ?? targetIban); if (!effectiveAccountName || !effectiveIban) { const t = pickTranslation(listing.translations); missingFor.push(t?.slug || listing.id); @@ -173,13 +222,18 @@ export async function PATCH(req: Request) { if (!hasGlobalDetails && missingFor.length) { return NextResponse.json( - { error: `Provide billing account name and IBAN globally or per listing (missing for: ${missingFor.join(', ')})` }, + { + error: `Provide billing account name and IBAN globally or per listing (missing for: ${missingFor.join(", ")})`, + }, { status: 400 }, ); } if (!hasGlobalDetails && listings.length === 0) { return NextResponse.json( - { error: 'Add a billing account name and IBAN before enabling the billing assistant.' }, + { + error: + "Add a billing account name and IBAN before enabling the billing assistant.", + }, { status: 400 }, ); } @@ -187,29 +241,43 @@ export async function PATCH(req: Request) { const tx: any[] = []; if (Object.keys(userUpdates).length) { - tx.push(prisma.user.update({ where: { id: user.id }, data: userUpdates })); + tx.push( + prisma.user.update({ where: { id: user.id }, data: userUpdates }), + ); } listingUpdates.forEach((update) => { - tx.push(prisma.listing.update({ where: { id: update.id }, data: update.data })); + tx.push( + prisma.listing.update({ where: { id: update.id }, data: update.data }), + ); }); if (tx.length) { await prisma.$transaction(tx); } else { - return NextResponse.json({ error: 'No updates provided' }, { status: 400 }); + return NextResponse.json( + { error: "No updates provided" }, + { status: 400 }, + ); } - const { user: updatedUser, listings: updatedListings } = await loadState(session.userId); + const { user: updatedUser, listings: updatedListings } = await loadState( + session.userId, + ); if (!updatedUser) { - return NextResponse.json({ error: 'User not found' }, { status: 404 }); + return NextResponse.json({ error: "User not found" }, { status: 404 }); } - return NextResponse.json(buildResponsePayload(updatedUser, updatedListings)); + return NextResponse.json( + buildResponsePayload(updatedUser, updatedListings), + ); } catch (error) { - console.error('Billing settings update failed', error); - const status = (error as any)?.message === 'Unauthorized' ? 401 : 500; - return NextResponse.json({ error: 'Failed to update billing settings' }, { status }); + console.error("Billing settings update failed", error); + const status = (error as any)?.message === "Unauthorized" ? 401 : 500; + return NextResponse.json( + { error: "Failed to update billing settings" }, + { status }, + ); } } -export const dynamic = 'force-dynamic'; +export const dynamic = "force-dynamic"; diff --git a/app/api/me/route.ts b/app/api/me/route.ts index 4adaa5d..fdfc580 100644 --- a/app/api/me/route.ts +++ b/app/api/me/route.ts @@ -1,19 +1,28 @@ -import { NextResponse } from 'next/server'; -import { prisma } from '../../../lib/prisma'; -import { requireAuth } from '../../../lib/jwt'; -import { hashPassword } from '../../../lib/auth'; +import { NextResponse } from "next/server"; +import { prisma } from "../../../lib/prisma"; +import { requireAuth } from "../../../lib/jwt"; +import { hashPassword } from "../../../lib/auth"; export async function PATCH(req: Request) { try { const session = await requireAuth(req); const body = await req.json(); - const name = body.name !== undefined && body.name !== null ? String(body.name).trim() : undefined; - const phone = body.phone !== undefined && body.phone !== null ? String(body.phone).trim() : undefined; + const name = + body.name !== undefined && body.name !== null + ? String(body.name).trim() + : undefined; + const phone = + body.phone !== undefined && body.phone !== null + ? String(body.phone).trim() + : undefined; const password = body.password ? String(body.password) : undefined; if (name === undefined && phone === undefined && !password) { - return NextResponse.json({ error: 'No updates provided' }, { status: 400 }); + return NextResponse.json( + { error: "No updates provided" }, + { status: 400 }, + ); } const data: any = {}; @@ -21,7 +30,10 @@ export async function PATCH(req: Request) { if (phone !== undefined) data.phone = phone || null; if (password) { if (password.length < 8) { - return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 400 }); + return NextResponse.json( + { error: "Password must be at least 8 characters" }, + { status: 400 }, + ); } data.passwordHash = await hashPassword(password); } @@ -29,13 +41,25 @@ export async function PATCH(req: Request) { const user = await prisma.user.update({ where: { id: session.userId }, data, - select: { id: true, email: true, name: true, phone: true, role: true, status: true, emailVerifiedAt: true, approvedAt: true }, + select: { + id: true, + email: true, + name: true, + phone: true, + role: true, + status: true, + emailVerifiedAt: true, + approvedAt: true, + }, }); return NextResponse.json({ ok: true, user }); } catch (error) { - console.error('Profile update error', error); - return NextResponse.json({ error: 'Failed to update profile' }, { status: 500 }); + console.error("Profile update error", error); + return NextResponse.json( + { error: "Failed to update profile" }, + { status: 500 }, + ); } } -export const dynamic = 'force-dynamic'; +export const dynamic = "force-dynamic"; diff --git a/app/auth/forgot/page.tsx b/app/auth/forgot/page.tsx index 3963074..7e5708f 100644 --- a/app/auth/forgot/page.tsx +++ b/app/auth/forgot/page.tsx @@ -1,11 +1,11 @@ -'use client'; +"use client"; -import { useState } from 'react'; -import { useI18n } from '../../components/I18nProvider'; +import { useState } from "react"; +import { useI18n } from "../../components/I18nProvider"; export default function ForgotPasswordPage() { const { t } = useI18n(); - const [email, setEmail] = useState(''); + const [email, setEmail] = useState(""); const [sent, setSent] = useState(false); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); @@ -16,39 +16,49 @@ export default function ForgotPasswordPage() { setSent(false); setLoading(true); try { - const res = await fetch('/api/auth/forgot', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const res = await fetch("/api/auth/forgot", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email }), }); if (!res.ok) { const data = await res.json(); - setError(data.error || t('forgotError')); + setError(data.error || t("forgotError")); } else { setSent(true); } } catch (err) { - setError(t('forgotError')); + setError(t("forgotError")); } finally { setLoading(false); } } return ( -
-

{t('forgotTitle')}

-

{t('forgotLead')}

-
+
+

{t("forgotTitle")}

+

{t("forgotLead")}

+ - {sent ?

{t('forgotSuccess')}

: null} - {error ?

{error}

: null} + {sent ? ( +

{t("forgotSuccess")}

+ ) : null} + {error ?

{error}

: null}
); } diff --git a/app/auth/login/page.tsx b/app/auth/login/page.tsx index fe45f11..90bbe35 100644 --- a/app/auth/login/page.tsx +++ b/app/auth/login/page.tsx @@ -1,12 +1,12 @@ -'use client'; +"use client"; -import { useState } from 'react'; -import { useI18n } from '../../components/I18nProvider'; +import { useState } from "react"; +import { useI18n } from "../../components/I18nProvider"; export default function LoginPage() { const { t } = useI18n(); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); const [success, setSuccess] = useState(false); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); @@ -17,54 +17,66 @@ export default function LoginPage() { setSuccess(false); setLoading(true); try { - const res = await fetch('/api/auth/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }), }); const data = await res.json(); if (!res.ok) { - setError(data.error || 'Login failed'); + setError(data.error || "Login failed"); } else { try { setSuccess(true); - localStorage.setItem('auth_token', data.token); + localStorage.setItem("auth_token", data.token); document.cookie = `auth_token=${data.token}; path=/; SameSite=Lax`; - window.location.href = '/'; + window.location.href = "/"; } catch (err) { // ignore storage errors } } } catch (err) { - setError('Login failed'); + setError("Login failed"); } finally { setLoading(false); } } return ( -
-

{t('loginTitle')}

-
+
+

{t("loginTitle")}

+

- {t('forgotCta')} + {t("forgotCta")}

- {success ?

{t('loginSuccess')}

: null} - {error ?

{error}

: null} + {success ? ( +

{t("loginSuccess")}

+ ) : null} + {error ?

{error}

: null}
); } diff --git a/app/auth/register/page.tsx b/app/auth/register/page.tsx index a24159f..4a5c8e6 100644 --- a/app/auth/register/page.tsx +++ b/app/auth/register/page.tsx @@ -1,14 +1,14 @@ /* eslint-disable react/no-unescaped-entities */ -'use client'; +"use client"; -import { useState } from 'react'; -import { useI18n } from '../../components/I18nProvider'; +import { useState } from "react"; +import { useI18n } from "../../components/I18nProvider"; export default function RegisterPage() { const { t } = useI18n(); - const [email, setEmail] = useState(''); - const [name, setName] = useState(''); - const [password, setPassword] = useState(''); + const [email, setEmail] = useState(""); + const [name, setName] = useState(""); + const [password, setPassword] = useState(""); const [message, setMessage] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); @@ -19,49 +19,66 @@ export default function RegisterPage() { setMessage(null); setLoading(true); try { - const res = await fetch('/api/auth/register', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const res = await fetch("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, name, password }), }); const data = await res.json(); if (!res.ok) { - setError(data.error || 'Registration failed'); + setError(data.error || "Registration failed"); } else { - setMessage(t('registerSuccess')); - setEmail(''); - setPassword(''); + setMessage(t("registerSuccess")); + setEmail(""); + setPassword(""); } } catch (err) { - setError('Registration failed'); + setError("Registration failed"); } finally { setLoading(false); } } return ( -
-

{t('registerTitle')}

-

{t('registerLead')}

-
+
+

{t("registerTitle")}

+

{t("registerLead")}

+ - {message ?

{message}

: null} - {error ?

{error}

: null} + {message ? ( +

{message}

+ ) : null} + {error ?

{error}

: null}
); } diff --git a/app/auth/reset/page.tsx b/app/auth/reset/page.tsx index ad5b81a..713bb96 100644 --- a/app/auth/reset/page.tsx +++ b/app/auth/reset/page.tsx @@ -1,20 +1,20 @@ -'use client'; +"use client"; -import { Suspense, useEffect, useState } from 'react'; -import { useSearchParams } from 'next/navigation'; -import { useI18n } from '../../components/I18nProvider'; +import { Suspense, useEffect, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { useI18n } from "../../components/I18nProvider"; function ResetForm() { const { t } = useI18n(); const searchParams = useSearchParams(); - const [password, setPassword] = useState(''); - const [token, setToken] = useState(''); + const [password, setPassword] = useState(""); + const [token, setToken] = useState(""); const [message, setMessage] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); useEffect(() => { - const tok = searchParams.get('token') || ''; + const tok = searchParams.get("token") || ""; setToken(tok); }, [searchParams]); @@ -23,53 +23,74 @@ function ResetForm() { setMessage(null); setError(null); if (!token) { - setError(t('resetMissingToken')); + setError(t("resetMissingToken")); return; } setLoading(true); try { - const res = await fetch('/api/auth/reset', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const res = await fetch("/api/auth/reset", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token, password }), }); const data = await res.json(); if (!res.ok) { - setError(data.error || t('resetError')); + setError(data.error || t("resetError")); } else { - setMessage(t('resetSuccess')); - setPassword(''); + setMessage(t("resetSuccess")); + setPassword(""); } } catch (err) { - setError(t('resetError')); + setError(t("resetError")); } finally { setLoading(false); } } return ( -
-

{t('resetTitle')}

-

{t('resetLead')}

-
+
+

{t("resetTitle")}

+

{t("resetLead")}

+ - {message ?

{message}

: null} - {error ?

{error}

: null} - {!token ?

{t('resetMissingToken')}

: null} + {message ? ( +

{message}

+ ) : null} + {error ?

{error}

: null} + {!token ? ( +

+ {t("resetMissingToken")} +

+ ) : null}
); } export default function ResetPasswordPage() { return ( -

Loading…

}> + +

Loading…

+
+ } + > ); diff --git a/app/components/AvailabilityCalendar.tsx b/app/components/AvailabilityCalendar.tsx index 856eecf..1f1ccaa 100644 --- a/app/components/AvailabilityCalendar.tsx +++ b/app/components/AvailabilityCalendar.tsx @@ -1,14 +1,19 @@ -'use client'; +"use client"; -import { useEffect, useMemo, useState } from 'react'; -import { useI18n } from './I18nProvider'; +import { useEffect, useMemo, useState } from "react"; +import { useI18n } from "./I18nProvider"; type MonthView = { label: string; days: { label: string; date: string; blocked: boolean; isFiller: boolean }[]; }; -function buildMonths(monthCount: number, blocked: Set, startYear: number, startMonth: number): MonthView[] { +function buildMonths( + monthCount: number, + blocked: Set, + startYear: number, + startMonth: number, +): MonthView[] { const months: MonthView[] = []; const base = new Date(Date.UTC(startYear, startMonth, 1)); @@ -17,20 +22,28 @@ function buildMonths(monthCount: number, blocked: Set, startYear: number monthDate.setUTCMonth(base.getUTCMonth() + i); const year = monthDate.getUTCFullYear(); const month = monthDate.getUTCMonth(); - const label = monthDate.toLocaleDateString(undefined, { month: 'long', year: 'numeric' }); + const label = monthDate.toLocaleDateString(undefined, { + month: "long", + year: "numeric", + }); const firstDay = new Date(Date.UTC(year, month, 1)); const startWeekday = firstDay.getUTCDay(); // 0=Sun const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); - const days: MonthView['days'] = []; + const days: MonthView["days"] = []; for (let f = 0; f < startWeekday; f += 1) { - days.push({ label: '', date: '', blocked: false, isFiller: true }); + days.push({ label: "", date: "", blocked: false, isFiller: true }); } for (let d = 1; d <= daysInMonth; d += 1) { const date = new Date(Date.UTC(year, month, d)); const iso = date.toISOString().slice(0, 10); - days.push({ label: String(d), date: iso, blocked: blocked.has(iso), isFiller: false }); + days.push({ + label: String(d), + date: iso, + blocked: blocked.has(iso), + isFiller: false, + }); } months.push({ label, days }); @@ -41,7 +54,13 @@ function buildMonths(monthCount: number, blocked: Set, startYear: number type AvailabilityResponse = { blockedDates: string[] }; -export default function AvailabilityCalendar({ listingId, hasCalendar }: { listingId: string; hasCalendar: boolean }) { +export default function AvailabilityCalendar({ + listingId, + hasCalendar, +}: { + listingId: string; + hasCalendar: boolean; +}) { const { t, locale } = useI18n(); const today = useMemo(() => new Date(), []); const [month, setMonth] = useState(today.getUTCMonth()); @@ -51,7 +70,10 @@ export default function AvailabilityCalendar({ listingId, hasCalendar }: { listi const [error, setError] = useState(null); const monthCount = 1; const blockedSet = useMemo(() => new Set(blockedDates), [blockedDates]); - const monthViews = useMemo(() => buildMonths(monthCount, blockedSet, year, month), [monthCount, blockedSet, year, month]); + const monthViews = useMemo( + () => buildMonths(monthCount, blockedSet, year, month), + [monthCount, blockedSet, year, month], + ); useEffect(() => { if (!hasCalendar) return; @@ -62,20 +84,28 @@ export default function AvailabilityCalendar({ listingId, hasCalendar }: { listi month: String(month), year: String(year), months: String(monthCount), - refresh: '1', + refresh: "1", }); - fetch(`/api/listings/${listingId}/availability?${params.toString()}`, { cache: 'no-store', signal: controller.signal }) + fetch(`/api/listings/${listingId}/availability?${params.toString()}`, { + cache: "no-store", + signal: controller.signal, + }) .then(async (res) => { const data: AvailabilityResponse = await res.json(); - if (!res.ok) throw new Error((data as any)?.error || 'Failed to load availability'); + if (!res.ok) + throw new Error( + (data as any)?.error || "Failed to load availability", + ); return data; }) .then((data) => { - setBlockedDates(Array.isArray(data.blockedDates) ? data.blockedDates : []); + setBlockedDates( + Array.isArray(data.blockedDates) ? data.blockedDates : [], + ); }) .catch((err) => { - if (err.name === 'AbortError') return; - setError(err.message || 'Failed to load availability'); + if (err.name === "AbortError") return; + setError(err.message || "Failed to load availability"); }) .finally(() => setLoading(false)); @@ -93,7 +123,9 @@ export default function AvailabilityCalendar({ listingId, hasCalendar }: { listi () => Array.from({ length: 12 }, (_, m) => ({ value: m, - label: new Date(Date.UTC(2020, m, 1)).toLocaleString(locale, { month: 'long' }), + label: new Date(Date.UTC(2020, m, 1)).toLocaleString(locale, { + month: "long", + }), })), [locale], ); @@ -103,33 +135,88 @@ export default function AvailabilityCalendar({ listingId, hasCalendar }: { listi }, [today]); return ( -
-
-
- {t('availabilityTitle')} +
+
+
+ {t("availabilityTitle")}
-
- {t('availabilityLegendBooked')} - -
- {error ?
{error}
: null} + {error ? ( +
{error}
+ ) : null} {monthViews.map((monthView) => (
-
- setMonth(Number(e.target.value))} + disabled={!hasCalendar || loading} + style={{ flex: 1 }} + > {monthOptions.map((opt) => ( ))} - setYear(Number(e.target.value))} + disabled={!hasCalendar || loading} + style={{ width: 96 }} + > {yearOptions.map((y) => (
- {['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((d) => ( -
+ {["S", "M", "T", "W", "T", "F", "S"].map((d) => ( +
{d}
))} @@ -157,13 +244,25 @@ export default function AvailabilityCalendar({ listingId, hasCalendar }: { listi style={{ height: 32, borderRadius: 8, - background: day.isFiller ? 'transparent' : day.blocked ? 'rgba(248,113,113,0.2)' : 'rgba(148,163,184,0.1)', - color: day.isFiller ? 'transparent' : day.blocked ? '#ef4444' : '#e2e8f0', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', + background: day.isFiller + ? "transparent" + : day.blocked + ? "rgba(248,113,113,0.2)" + : "rgba(148,163,184,0.1)", + color: day.isFiller + ? "transparent" + : day.blocked + ? "#ef4444" + : "#e2e8f0", + display: "flex", + alignItems: "center", + justifyContent: "center", }} - aria-label={day.date ? `${day.date}${day.blocked ? ' (booked)' : ''}` : undefined} + aria-label={ + day.date + ? `${day.date}${day.blocked ? " (booked)" : ""}` + : undefined + } > {day.label}
diff --git a/app/components/I18nProvider.tsx b/app/components/I18nProvider.tsx index a67d933..0701513 100644 --- a/app/components/I18nProvider.tsx +++ b/app/components/I18nProvider.tsx @@ -1,7 +1,12 @@ -'use client'; +"use client"; -import { createContext, useContext, useEffect, useMemo, useState } from 'react'; -import { Locale, MessageKey, resolveLocale, t as translate } from '../../lib/i18n'; +import { createContext, useContext, useEffect, useMemo, useState } from "react"; +import { + Locale, + MessageKey, + resolveLocale, + t as translate, +} from "../../lib/i18n"; type I18nContextValue = { locale: Locale; @@ -13,14 +18,18 @@ const I18nContext = createContext(null); export function I18nProvider({ children }: { children: React.ReactNode }) { const [locale, setLocale] = useState(() => { - if (typeof window === 'undefined') return 'en'; - const stored = localStorage.getItem('locale'); - if (stored === 'fi' || stored === 'en' || stored === 'sv') return stored as Locale; - return resolveLocale({ cookieLocale: null, acceptLanguage: navigator.language ?? navigator.languages?.[0] ?? null }); + if (typeof window === "undefined") return "en"; + const stored = localStorage.getItem("locale"); + if (stored === "fi" || stored === "en" || stored === "sv") + return stored as Locale; + return resolveLocale({ + cookieLocale: null, + acceptLanguage: navigator.language ?? navigator.languages?.[0] ?? null, + }); }); useEffect(() => { - localStorage.setItem('locale', locale); + localStorage.setItem("locale", locale); document.cookie = `locale=${locale}; path=/; max-age=${60 * 60 * 24 * 365};`; }, [locale]); @@ -28,7 +37,8 @@ export function I18nProvider({ children }: { children: React.ReactNode }) { () => ({ locale, setLocale, - t: (key: MessageKey, vars?: Record) => translate(locale, key, vars) as string, + t: (key: MessageKey, vars?: Record) => + translate(locale, key, vars) as string, }), [locale], ); @@ -39,7 +49,7 @@ export function I18nProvider({ children }: { children: React.ReactNode }) { export function useI18n() { const ctx = useContext(I18nContext); if (!ctx) { - throw new Error('useI18n must be used inside I18nProvider'); + throw new Error("useI18n must be used inside I18nProvider"); } return ctx; } diff --git a/app/components/NavBar.tsx b/app/components/NavBar.tsx index 09f86d9..0395c3f 100644 --- a/app/components/NavBar.tsx +++ b/app/components/NavBar.tsx @@ -1,10 +1,10 @@ -'use client'; +"use client"; -import Link from 'next/link'; -import Image from 'next/image'; -import { useEffect, useRef, useState, type SVGProps } from 'react'; -import { useI18n } from './I18nProvider'; -import logo from '../../img/logo.png'; +import Link from "next/link"; +import Image from "next/image"; +import { useEffect, useRef, useState, type SVGProps } from "react"; +import { useI18n } from "./I18nProvider"; +import logo from "../../img/logo.png"; type SessionUser = { id: string; email: string; role: string; status: string }; @@ -12,21 +12,21 @@ function Icon({ name }: { name: string }) { const common: SVGProps = { width: 16, height: 16, - stroke: 'currentColor', - fill: 'none', + stroke: "currentColor", + fill: "none", strokeWidth: 1.6, - strokeLinecap: 'round', - strokeLinejoin: 'round', + strokeLinecap: "round", + strokeLinejoin: "round", }; switch (name) { - case 'profile': + case "profile": return ( ); - case 'list': + case "list": return ( @@ -37,14 +37,14 @@ function Icon({ name }: { name: string }) { ); - case 'plus': + case "plus": return ( ); - case 'logout': + case "logout": return ( @@ -52,7 +52,7 @@ function Icon({ name }: { name: string }) { ); - case 'login': + case "login": return ( @@ -60,7 +60,7 @@ function Icon({ name }: { name: string }) { ); - case 'users': + case "users": return ( @@ -69,13 +69,13 @@ function Icon({ name }: { name: string }) { ); - case 'check': + case "check": return ( ); - case 'globe': + case "globe": return ( @@ -83,7 +83,7 @@ function Icon({ name }: { name: string }) { ); - case 'monitor': + case "monitor": return ( @@ -91,21 +91,21 @@ function Icon({ name }: { name: string }) { ); - case 'settings': + case "settings": return ( ); - case 'admin': + case "admin": return ( ); - case 'chevron-down': + case "chevron-down": return ( @@ -127,7 +127,7 @@ export default function NavBar() { async function loadUser() { try { - const res = await fetch('/api/auth/me', { cache: 'no-store' }); + const res = await fetch("/api/auth/me", { cache: "no-store" }); const data = await res.json(); if (data.user) setUser(data.user); else setUser(null); @@ -149,31 +149,34 @@ export default function NavBar() { useEffect(() => { const role = user?.role; - const canSeeApprovals = role === 'ADMIN' || role === 'LISTING_MODERATOR' || role === 'USER_MODERATOR'; + const canSeeApprovals = + role === "ADMIN" || + role === "LISTING_MODERATOR" || + role === "USER_MODERATOR"; if (!canSeeApprovals) { setPendingCount(0); return; } - fetch('/api/admin/pending/count', { cache: 'no-store' }) + fetch("/api/admin/pending/count", { cache: "no-store" }) .then((res) => res.json()) .then((data) => { - if (data && typeof data.total === 'number') setPendingCount(data.total); + if (data && typeof data.total === "number") setPendingCount(data.total); }) .catch(() => setPendingCount(0)); }, [user]); async function logout() { try { - await fetch('/api/auth/logout', { method: 'POST' }); + await fetch("/api/auth/logout", { method: "POST" }); } catch (e) { // ignore } setUser(null); } - const isAdmin = user?.role === 'ADMIN'; - const isListingMod = user?.role === 'LISTING_MODERATOR'; - const isUserMod = user?.role === 'USER_MODERATOR'; + const isAdmin = user?.role === "ADMIN"; + const isListingMod = user?.role === "LISTING_MODERATOR"; + const isUserMod = user?.role === "USER_MODERATOR"; const showApprovals = Boolean(user && (isAdmin || isListingMod || isUserMod)); const showAdminMenu = Boolean(user && (showApprovals || isAdmin)); @@ -181,37 +184,54 @@ export default function NavBar() { if (!userMenuOpen && !adminMenuOpen) return; const onMouseDown = (e: MouseEvent) => { const target = e.target as Node | null; - const insideAdmin = adminMenuRef.current && target && adminMenuRef.current.contains(target); - const insideUser = userMenuRef.current && target && userMenuRef.current.contains(target); + const insideAdmin = + adminMenuRef.current && target && adminMenuRef.current.contains(target); + const insideUser = + userMenuRef.current && target && userMenuRef.current.contains(target); if (!insideAdmin) setAdminMenuOpen(false); if (!insideUser) setUserMenuOpen(false); }; const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') { + if (e.key === "Escape") { setAdminMenuOpen(false); setUserMenuOpen(false); } }; - document.addEventListener('mousedown', onMouseDown); - document.addEventListener('keydown', onKeyDown); + document.addEventListener("mousedown", onMouseDown); + document.addEventListener("keydown", onKeyDown); return () => { - document.removeEventListener('mousedown', onMouseDown); - document.removeEventListener('keydown', onKeyDown); + document.removeEventListener("mousedown", onMouseDown); + document.removeEventListener("keydown", onKeyDown); }; }, [adminMenuOpen, userMenuOpen]); return ( -
-
+
+
- - {t('brand')} + + {t("brand")} - {t('navBrowse')} + {t("navBrowse")}
-
{t('tableEmail')}{t('tableRole')}{t('tableStatus')}{t('tableVerified')}{t('tableApproved')}Actions{t("tableEmail")}{t("tableRole")} + {t("tableStatus")} + + {t("tableVerified")} + + {t("tableApproved")} + Actions
{u.email} - setUserRole(u.id, e.target.value)} + disabled={loading || role !== "ADMIN"} + > {roleOptions.map((r) => ( {u.status}{u.emailVerifiedAt ? 'yes' : 'no'}{u.approvedAt ? 'yes' : 'no'}{u.emailVerifiedAt ? "yes" : "no"}{u.approvedAt ? "yes" : "no"} -
+
{u.approvedAt ? null : ( - )} - -