Compare commits
10 commits
90407c4f8d
...
14445a092c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14445a092c | ||
|
|
3a54ba40d3 | ||
|
|
3816e5e2e8 | ||
|
|
50c2664078 | ||
|
|
5ca984a018 | ||
|
|
c22dc09cc7 | ||
|
|
534ad07a73 | ||
|
|
05419e9457 | ||
|
|
4889ead690 | ||
|
|
efe19f0cda |
18 changed files with 488 additions and 106 deletions
31
.env.example
31
.env.example
|
|
@ -1,11 +1,22 @@
|
||||||
# OpenRouteService API Key
|
# ─── Admin Login ────────────────────────────────────────────────────
|
||||||
# Get your free key at https://openrouteservice.org/dev/#/signup
|
ADMIN_EMAIL=admin@example.com
|
||||||
# Free tier: 2,000 requests/day
|
ADMIN_PASSWORD=changeme
|
||||||
OPENROUTE_API_KEY=your_api_key_here
|
|
||||||
|
|
||||||
# Email configuration (for quote requests)
|
# ─── Email (SMTP) ──────────────────────────────────────────────────
|
||||||
# SMTP_HOST=smtp.example.com
|
# Office 365: smtp.office365.com, port 587
|
||||||
# SMTP_PORT=587
|
# Requires SMTP AUTH enabled for the sending account in Exchange Admin Center.
|
||||||
# SMTP_USER=user@example.com
|
# See SETUP.md / OPSÆTNING.md for instructions.
|
||||||
# SMTP_PASS=your_password
|
SMTP_HOST=smtp.office365.com
|
||||||
# EMAIL_TO=info@foamking.dk
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=tilbud@foamking.dk
|
||||||
|
SMTP_PASS=your_password_here
|
||||||
|
EMAIL_FROM_NAME=Foam King Prisberegner
|
||||||
|
EMAIL_TO=info@foamking.dk
|
||||||
|
|
||||||
|
# ─── Base URL ───────────────────────────────────────────────────────
|
||||||
|
NEXT_PUBLIC_BASE_URL=https://beregner.foamking.dk
|
||||||
|
|
||||||
|
# ─── Distance Calculation (Optional) ───────────────────────────────
|
||||||
|
# Without this key, distances are calculated from a built-in postal code table.
|
||||||
|
# Get a free key at https://openrouteservice.org/dev/#/signup (2,000 requests/day)
|
||||||
|
# OPENROUTE_API_KEY=your_api_key_here
|
||||||
|
|
|
||||||
123
OPSÆTNING.md
Normal file
123
OPSÆTNING.md
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
# FoamKing Opsætningsguide
|
||||||
|
|
||||||
|
Komplet vejledning til at sætte FoamKing prisberegneren i drift.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Forudsætninger
|
||||||
|
|
||||||
|
- **Node.js** 18 eller nyere (20 LTS anbefales)
|
||||||
|
- **npm** (følger med Node.js)
|
||||||
|
|
||||||
|
## 2. Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Konfiguration af miljøvariabler
|
||||||
|
|
||||||
|
Kopier eksempelfilen og udfyld med jeres egne værdier:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
Åbn `.env.local` i en teksteditor og konfigurer hver sektion som beskrevet nedenfor.
|
||||||
|
|
||||||
|
### Admin-login (Påkrævet)
|
||||||
|
|
||||||
|
```env
|
||||||
|
ADMIN_EMAIL=admin@example.com
|
||||||
|
ADMIN_PASSWORD=changeme
|
||||||
|
```
|
||||||
|
|
||||||
|
Disse oplysninger bruges til at logge ind på `/intern/login`. Skift begge værdier inden I går i produktion.
|
||||||
|
|
||||||
|
### E-mail — Office 365 SMTP (Påkrævet)
|
||||||
|
|
||||||
|
```env
|
||||||
|
SMTP_HOST=smtp.office365.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=tilbud@foamking.dk
|
||||||
|
SMTP_PASS=your_password_here
|
||||||
|
EMAIL_FROM_NAME=Foam King Prisberegner
|
||||||
|
EMAIL_TO=info@foamking.dk
|
||||||
|
```
|
||||||
|
|
||||||
|
Applikationen sender tilbudsforespørgsler via Office 365. For at det virker, skal **SMTP AUTH være aktiveret** på afsenderpostkassen:
|
||||||
|
|
||||||
|
1. Gå til **Exchange Admin Center** — [admin.exchange.microsoft.com](https://admin.exchange.microsoft.com)
|
||||||
|
2. Gå til **Recipients** → **Mailboxes**
|
||||||
|
3. Vælg postkassen **tilbud@foamking.dk**
|
||||||
|
4. Klik på **Manage email apps settings** (eller Mail flow → Email apps)
|
||||||
|
5. Aktivér **Authenticated SMTP (SMTP AUTH)**
|
||||||
|
6. Gem
|
||||||
|
|
||||||
|
> **Bemærk:** Hvis jeres organisation bruger Security Defaults eller Conditional Access-politikker, der blokerer legacy-godkendelse, kan det være nødvendigt at oprette en undtagelse for denne postkasse. Hvis MFA er aktiveret på kontoen, skal I bruge et App Password i stedet for den almindelige adgangskode.
|
||||||
|
|
||||||
|
### Base-URL (Påkrævet)
|
||||||
|
|
||||||
|
```env
|
||||||
|
NEXT_PUBLIC_BASE_URL=https://beregner.foamking.dk
|
||||||
|
```
|
||||||
|
|
||||||
|
Bruges når der genereres links i udgående e-mails (f.eks. links til tilbudssider).
|
||||||
|
|
||||||
|
### Afstandsberegning (Valgfrit)
|
||||||
|
|
||||||
|
```env
|
||||||
|
OPENROUTE_API_KEY=your_api_key_here
|
||||||
|
```
|
||||||
|
|
||||||
|
Uden denne nøgle bruger applikationen en indbygget afstandstabel baseret på postnumre, hvilket fungerer fint i de fleste tilfælde. Ønsker I mere præcise køreafstande, kan I oprette en gratis API-nøgle på [openrouteservice.org](https://openrouteservice.org/dev/#/signup) (2.000 forespørgsler pr. dag).
|
||||||
|
|
||||||
|
## 4. Opret database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run setup
|
||||||
|
```
|
||||||
|
|
||||||
|
Dette opretter SQLite-databasen og de nødvendige tabeller.
|
||||||
|
|
||||||
|
## 5. Byg og start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Applikationen starter på **port 3001**.
|
||||||
|
|
||||||
|
## 6. Ruteoversigt
|
||||||
|
|
||||||
|
| Rute | Beskrivelse |
|
||||||
|
| -------------------- | ---------------------------------------------- |
|
||||||
|
| `/` | Offentlig prisberegner |
|
||||||
|
| `/tilbud/[id]` | Offentlig tilbudsvisning (link fra e-mail) |
|
||||||
|
| `/intern` | Admin-dashboard — tilbudsstyring |
|
||||||
|
| `/intern/historik` | Admin — tilbudshistorik |
|
||||||
|
| `/intern/beregner` | Admin — detaljeret beregner med prisopbygning |
|
||||||
|
| `/intern/login` | Admin-loginside |
|
||||||
|
|
||||||
|
## 7. Tilpasset autentificering
|
||||||
|
|
||||||
|
Standardopsætningen bruger miljøvariablerne `ADMIN_EMAIL` og `ADMIN_PASSWORD` med sessioner i hukommelsen. Det er enkelt og tilstrækkeligt til en setup med en enkelt administrator.
|
||||||
|
|
||||||
|
Vil I integrere jeres egen autentificering (JWT, OAuth, SSO), skal I redigere **`lib/auth.ts`**. Hele applikationen bruger kun tre funktioner:
|
||||||
|
|
||||||
|
- `checkAuth()` — tjekker om den aktuelle forespørgsel er autentificeret
|
||||||
|
- `login(email, password)` — autentificerer en bruger og opretter en session
|
||||||
|
- `logout()` — afslutter den aktuelle session
|
||||||
|
|
||||||
|
Middlewaren i **`middleware.ts`** tjekker for en session-cookie på alle `/intern/*`-ruter og omdirigerer ikke-autentificerede besøgende til `/intern/login`. Opdater den, hvis I skifter til en anden mekanisme (f.eks. en JWT i en `Authorization`-header).
|
||||||
|
|
||||||
|
## 8. Udvikling
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Start udviklingsserver (port 3001)
|
||||||
|
npm run build # Produktionsbuild
|
||||||
|
npm start # Start produktionsserver
|
||||||
|
npm run lint # Kør ESLint
|
||||||
|
npm run setup # Opret / nulstil database
|
||||||
|
```
|
||||||
123
SETUP.md
Normal file
123
SETUP.md
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
# FoamKing Setup Guide
|
||||||
|
|
||||||
|
Complete setup instructions for deploying the FoamKing flooring price calculator.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Prerequisites
|
||||||
|
|
||||||
|
- **Node.js** 18 or later (20 LTS recommended)
|
||||||
|
- **npm** (included with Node.js)
|
||||||
|
|
||||||
|
## 2. Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Configure Environment
|
||||||
|
|
||||||
|
Copy the example file and fill in your values:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `.env.local` in a text editor and configure each section as described below.
|
||||||
|
|
||||||
|
### Admin Login (Required)
|
||||||
|
|
||||||
|
```env
|
||||||
|
ADMIN_EMAIL=admin@example.com
|
||||||
|
ADMIN_PASSWORD=changeme
|
||||||
|
```
|
||||||
|
|
||||||
|
These credentials are used to sign in at `/intern/login`. Change both values before going to production.
|
||||||
|
|
||||||
|
### Email — Office 365 SMTP (Required)
|
||||||
|
|
||||||
|
```env
|
||||||
|
SMTP_HOST=smtp.office365.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=tilbud@foamking.dk
|
||||||
|
SMTP_PASS=your_password_here
|
||||||
|
EMAIL_FROM_NAME=Foam King Prisberegner
|
||||||
|
EMAIL_TO=info@foamking.dk
|
||||||
|
```
|
||||||
|
|
||||||
|
The application sends quote-request emails through Office 365. For this to work, **SMTP AUTH must be enabled** on the sending mailbox:
|
||||||
|
|
||||||
|
1. Go to **Exchange Admin Center** — [admin.exchange.microsoft.com](https://admin.exchange.microsoft.com)
|
||||||
|
2. Navigate to **Recipients** → **Mailboxes**
|
||||||
|
3. Select the **tilbud@foamking.dk** mailbox
|
||||||
|
4. Click **Manage email apps settings** (or Mail flow → Email apps)
|
||||||
|
5. Enable **Authenticated SMTP (SMTP AUTH)**
|
||||||
|
6. Save
|
||||||
|
|
||||||
|
> **Note:** If your organization uses Security Defaults or Conditional Access policies that block legacy authentication, you may need to create an exclusion for this mailbox. If MFA is enabled on the account, use an App Password instead of the regular password.
|
||||||
|
|
||||||
|
### Base URL (Required)
|
||||||
|
|
||||||
|
```env
|
||||||
|
NEXT_PUBLIC_BASE_URL=https://beregner.foamking.dk
|
||||||
|
```
|
||||||
|
|
||||||
|
Used when generating links in outgoing emails (e.g. links to quote pages).
|
||||||
|
|
||||||
|
### Distance Calculation (Optional)
|
||||||
|
|
||||||
|
```env
|
||||||
|
OPENROUTE_API_KEY=your_api_key_here
|
||||||
|
```
|
||||||
|
|
||||||
|
Without this key the application uses a built-in postal code distance table, which is perfectly fine for most cases. If you want more precise driving distances, sign up for a free API key at [openrouteservice.org](https://openrouteservice.org/dev/#/signup) (2,000 requests per day).
|
||||||
|
|
||||||
|
## 4. Initialize Database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run setup
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates the SQLite database and required tables.
|
||||||
|
|
||||||
|
## 5. Build and Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
The application starts on **port 3001**.
|
||||||
|
|
||||||
|
## 6. Route Overview
|
||||||
|
|
||||||
|
| Route | Description |
|
||||||
|
| -------------------- | -------------------------------------------- |
|
||||||
|
| `/` | Public price calculator |
|
||||||
|
| `/tilbud/[id]` | Public quote view (linked from email) |
|
||||||
|
| `/intern` | Admin dashboard — quote management |
|
||||||
|
| `/intern/historik` | Admin — quote history |
|
||||||
|
| `/intern/beregner` | Admin — detailed calculator with price breakdown |
|
||||||
|
| `/intern/login` | Admin login page |
|
||||||
|
|
||||||
|
## 7. Custom Authentication
|
||||||
|
|
||||||
|
The default authentication uses the `ADMIN_EMAIL` and `ADMIN_PASSWORD` environment variables with in-memory sessions. This is simple and sufficient for a single-admin setup.
|
||||||
|
|
||||||
|
To integrate your own authentication (JWT, OAuth, SSO), edit **`lib/auth.ts`**. The entire application relies on only three functions:
|
||||||
|
|
||||||
|
- `checkAuth()` — checks whether the current request is authenticated
|
||||||
|
- `login(email, password)` — authenticates a user and creates a session
|
||||||
|
- `logout()` — destroys the current session
|
||||||
|
|
||||||
|
The middleware in **`middleware.ts`** checks for a session cookie on all `/intern/*` routes and redirects unauthenticated visitors to `/intern/login`. Update it if you switch to a different mechanism (e.g. a JWT in an `Authorization` header).
|
||||||
|
|
||||||
|
## 8. Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # Start development server (port 3001)
|
||||||
|
npm run build # Production build
|
||||||
|
npm start # Start production server
|
||||||
|
npm run lint # Run ESLint
|
||||||
|
npm run setup # Initialize / reset database
|
||||||
|
```
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
import { NextRequest, NextResponse } from "next/server"
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
import { login } from "@/lib/auth"
|
import { login } from "@/lib/auth"
|
||||||
|
import { rateLimit } from "@/lib/rate-limit"
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
const ip = request.headers.get("x-forwarded-for") || "unknown"
|
||||||
|
if (!rateLimit(ip, 5, 60_000)) {
|
||||||
|
return NextResponse.json({ error: "For mange forsøg. Prøv igen om lidt." }, { status: 429 })
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { email, password } = await request.json()
|
const { email, password } = await request.json()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import nodemailer from "nodemailer"
|
||||||
import { formatPrice, type CalculationDetails } from "@/lib/calculations"
|
import { formatPrice, type CalculationDetails } from "@/lib/calculations"
|
||||||
import { FLOORING_TYPES } from "@/lib/constants"
|
import { FLOORING_TYPES } from "@/lib/constants"
|
||||||
import { saveQuote } from "@/lib/db"
|
import { saveQuote } from "@/lib/db"
|
||||||
|
import { rateLimit } from "@/lib/rate-limit"
|
||||||
|
|
||||||
interface QuoteRequestBody {
|
interface QuoteRequestBody {
|
||||||
customerInfo: {
|
customerInfo: {
|
||||||
|
|
@ -234,6 +235,11 @@ function formatFoamKingEmail(
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
const ip = request.headers.get("x-forwarded-for") || "unknown"
|
||||||
|
if (!rateLimit(ip, 10, 60_000)) {
|
||||||
|
return NextResponse.json({ error: "For mange anmodninger. Prøv igen om lidt." }, { status: 429 })
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body: QuoteRequestBody = await request.json()
|
const body: QuoteRequestBody = await request.json()
|
||||||
const { customerInfo, calculationDetails } = body
|
const { customerInfo, calculationDetails } = body
|
||||||
|
|
@ -296,8 +302,7 @@ export async function POST(request: NextRequest) {
|
||||||
success: true,
|
success: true,
|
||||||
message: "Tak! Vi har modtaget din anmodning og sendt en bekræftelse til din email.",
|
message: "Tak! Vi har modtaget din anmodning og sendt en bekræftelse til din email.",
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error("Quote request error:", error)
|
|
||||||
return NextResponse.json({ error: "Der opstod en fejl. Prøv igen senere." }, { status: 500 })
|
return NextResponse.json({ error: "Der opstod en fejl. Prøv igen senere." }, { status: 500 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,25 @@
|
||||||
import { NextRequest, NextResponse } from "next/server"
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
import { getAllQuotes, updateQuoteStatus, type QuoteStatus } from "@/lib/db"
|
import { getAllQuotes, updateQuoteStatus, type QuoteStatus } from "@/lib/db"
|
||||||
import { getCurrentUser } from "@/lib/auth"
|
import { checkAuth } from "@/lib/auth"
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const user = await getCurrentUser()
|
const { authenticated } = await checkAuth()
|
||||||
if (!user) {
|
if (!authenticated) {
|
||||||
return NextResponse.json({ error: "Ikke autoriseret" }, { status: 401 })
|
return NextResponse.json({ error: "Ikke autoriseret" }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const quotes = getAllQuotes()
|
const quotes = getAllQuotes()
|
||||||
return NextResponse.json({ quotes })
|
return NextResponse.json({ quotes })
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error("Get quotes error:", error)
|
|
||||||
return NextResponse.json({ error: "Der opstod en fejl" }, { status: 500 })
|
return NextResponse.json({ error: "Der opstod en fejl" }, { status: 500 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PATCH(request: NextRequest) {
|
export async function PATCH(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const user = await getCurrentUser()
|
const { authenticated } = await checkAuth()
|
||||||
if (!user) {
|
if (!authenticated) {
|
||||||
return NextResponse.json({ error: "Ikke autoriseret" }, { status: 401 })
|
return NextResponse.json({ error: "Ikke autoriseret" }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,8 +40,7 @@ export async function PATCH(request: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error("Update quote error:", error)
|
|
||||||
return NextResponse.json({ error: "Der opstod en fejl" }, { status: 500 })
|
return NextResponse.json({ error: "Der opstod en fejl" }, { status: 500 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,10 +36,6 @@ export default function AdminPage() {
|
||||||
setIsRequestingQuote(true)
|
setIsRequestingQuote(true)
|
||||||
try {
|
try {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
console.log("Quote request (test mode):", {
|
|
||||||
customerInfo,
|
|
||||||
calculationDetails: calculationResult,
|
|
||||||
})
|
|
||||||
alert("TEST MODE: Tilbudsanmodning ville blive sendt til info@foamking.dk")
|
alert("TEST MODE: Tilbudsanmodning ville blive sendt til info@foamking.dk")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert("Der opstod en fejl. Prøv igen senere.")
|
alert("Der opstod en fejl. Prøv igen senere.")
|
||||||
|
|
@ -40,15 +40,15 @@ export default function HistorikPage() {
|
||||||
const res = await fetch("/api/quotes")
|
const res = await fetch("/api/quotes")
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
router.push("/login")
|
router.push("/intern/login")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
throw new Error("Failed to fetch quotes")
|
throw new Error("Failed to fetch quotes")
|
||||||
}
|
}
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
setQuotes(data.quotes)
|
setQuotes(data.quotes)
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error("Failed to fetch quotes:", error)
|
// fetch failed
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
@ -57,10 +57,10 @@ export default function HistorikPage() {
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
try {
|
try {
|
||||||
await fetch("/api/auth/logout", { method: "POST" })
|
await fetch("/api/auth/logout", { method: "POST" })
|
||||||
router.push("/login")
|
router.push("/intern/login")
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error("Logout failed:", error)
|
// logout failed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,7 +104,7 @@ export default function HistorikPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link href="/dashboard">
|
<Link href="/intern">
|
||||||
<Button variant="ghost" size="sm">
|
<Button variant="ghost" size="sm">
|
||||||
<LayoutDashboard className="mr-2 h-4 w-4" />
|
<LayoutDashboard className="mr-2 h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Dashboard</span>
|
<span className="hidden sm:inline">Dashboard</span>
|
||||||
|
|
@ -35,7 +35,7 @@ export default function LoginPage() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
router.push("/dashboard")
|
router.push("/intern")
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} catch {
|
} catch {
|
||||||
setError("Der opstod en fejl. Prøv igen.")
|
setError("Der opstod en fejl. Prøv igen.")
|
||||||
|
|
@ -38,15 +38,15 @@ export default function DashboardPage() {
|
||||||
const res = await fetch("/api/quotes")
|
const res = await fetch("/api/quotes")
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
router.push("/login")
|
router.push("/intern/login")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
throw new Error("Failed to fetch quotes")
|
throw new Error("Failed to fetch quotes")
|
||||||
}
|
}
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
setQuotes(data.quotes)
|
setQuotes(data.quotes)
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error("Failed to fetch quotes:", error)
|
// fetch failed
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
@ -84,10 +84,10 @@ export default function DashboardPage() {
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
try {
|
try {
|
||||||
await fetch("/api/auth/logout", { method: "POST" })
|
await fetch("/api/auth/logout", { method: "POST" })
|
||||||
router.push("/login")
|
router.push("/intern/login")
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error("Logout failed:", error)
|
// logout failed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -133,7 +133,7 @@ export default function DashboardPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link href="/historik">
|
<Link href="/intern/historik">
|
||||||
<Button variant="ghost" size="sm">
|
<Button variant="ghost" size="sm">
|
||||||
<List className="mr-2 h-4 w-4" />
|
<List className="mr-2 h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Historik</span>
|
<span className="hidden sm:inline">Historik</span>
|
||||||
|
|
@ -37,11 +37,11 @@ const formSchema = z.object({
|
||||||
.length(4, "Postnummer skal være 4 cifre")
|
.length(4, "Postnummer skal være 4 cifre")
|
||||||
.refine(validateDanishPostalCode, "Ugyldigt dansk postnummer"),
|
.refine(validateDanishPostalCode, "Ugyldigt dansk postnummer"),
|
||||||
address: z.string().optional(),
|
address: z.string().optional(),
|
||||||
area: z.coerce
|
area: z
|
||||||
.number()
|
.number()
|
||||||
.min(CONSTRAINTS.MIN_AREA, `Minimum areal er ${CONSTRAINTS.MIN_AREA} m²`)
|
.min(CONSTRAINTS.MIN_AREA, `Minimum areal er ${CONSTRAINTS.MIN_AREA} m²`)
|
||||||
.max(CONSTRAINTS.MAX_AREA, `Maximum areal er ${CONSTRAINTS.MAX_AREA} m²`),
|
.max(CONSTRAINTS.MAX_AREA, `Maximum areal er ${CONSTRAINTS.MAX_AREA} m²`),
|
||||||
height: z.coerce
|
height: z
|
||||||
.number()
|
.number()
|
||||||
.min(CONSTRAINTS.MIN_HEIGHT, `Minimum højde er ${CONSTRAINTS.MIN_HEIGHT} cm`)
|
.min(CONSTRAINTS.MIN_HEIGHT, `Minimum højde er ${CONSTRAINTS.MIN_HEIGHT} cm`)
|
||||||
.max(CONSTRAINTS.MAX_HEIGHT, `Maximum højde er ${CONSTRAINTS.MAX_HEIGHT} cm`),
|
.max(CONSTRAINTS.MAX_HEIGHT, `Maximum højde er ${CONSTRAINTS.MAX_HEIGHT} cm`),
|
||||||
|
|
|
||||||
|
|
@ -37,11 +37,11 @@ const formSchema = z.object({
|
||||||
.length(4, "Postnummer skal være 4 cifre")
|
.length(4, "Postnummer skal være 4 cifre")
|
||||||
.refine(validateDanishPostalCode, "Vi dækker desværre ikke dette område"),
|
.refine(validateDanishPostalCode, "Vi dækker desværre ikke dette område"),
|
||||||
address: z.string().optional(),
|
address: z.string().optional(),
|
||||||
area: z.coerce
|
area: z
|
||||||
.number()
|
.number()
|
||||||
.min(CONSTRAINTS.MIN_AREA, `Minimum ${CONSTRAINTS.MIN_AREA} m²`)
|
.min(CONSTRAINTS.MIN_AREA, `Minimum ${CONSTRAINTS.MIN_AREA} m²`)
|
||||||
.max(CONSTRAINTS.MAX_AREA, `Maximum ${CONSTRAINTS.MAX_AREA} m²`),
|
.max(CONSTRAINTS.MAX_AREA, `Maximum ${CONSTRAINTS.MAX_AREA} m²`),
|
||||||
height: z.coerce
|
height: z
|
||||||
.number()
|
.number()
|
||||||
.min(CONSTRAINTS.MIN_HEIGHT, `Minimum ${CONSTRAINTS.MIN_HEIGHT} cm`)
|
.min(CONSTRAINTS.MIN_HEIGHT, `Minimum ${CONSTRAINTS.MIN_HEIGHT} cm`)
|
||||||
.max(CONSTRAINTS.MAX_HEIGHT, `Maximum ${CONSTRAINTS.MAX_HEIGHT} cm`),
|
.max(CONSTRAINTS.MAX_HEIGHT, `Maximum ${CONSTRAINTS.MAX_HEIGHT} cm`),
|
||||||
|
|
|
||||||
41
lib/rate-limit.ts
Normal file
41
lib/rate-limit.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
// Simple in-memory rate limiter. No external dependencies.
|
||||||
|
// Tracks requests per IP with a sliding window.
|
||||||
|
|
||||||
|
interface RateLimitEntry {
|
||||||
|
count: number
|
||||||
|
resetAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = new Map<string, RateLimitEntry>()
|
||||||
|
|
||||||
|
// Clean up expired entries periodically
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now()
|
||||||
|
for (const [key, entry] of store) {
|
||||||
|
if (entry.resetAt < now) store.delete(key)
|
||||||
|
}
|
||||||
|
}, 60_000)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a request should be rate-limited.
|
||||||
|
* @param key - Unique identifier (e.g. IP address)
|
||||||
|
* @param limit - Max requests per window
|
||||||
|
* @param windowMs - Time window in milliseconds
|
||||||
|
* @returns true if the request is allowed, false if rate-limited
|
||||||
|
*/
|
||||||
|
export function rateLimit(key: string, limit: number, windowMs: number): boolean {
|
||||||
|
const now = Date.now()
|
||||||
|
const entry = store.get(key)
|
||||||
|
|
||||||
|
if (!entry || entry.resetAt < now) {
|
||||||
|
store.set(key, { count: 1, resetAt: now + windowMs })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.count >= limit) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.count++
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
@ -1,34 +1,26 @@
|
||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import type { NextRequest } from "next/server"
|
import type { NextRequest } from "next/server"
|
||||||
|
|
||||||
// Routes that require authentication
|
|
||||||
const protectedPaths = ["/dashboard", "/historik", "/admin"]
|
|
||||||
|
|
||||||
export function middleware(request: NextRequest) {
|
export function middleware(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl
|
const { pathname } = request.nextUrl
|
||||||
|
|
||||||
// Check if path requires authentication
|
// Don't protect the login page
|
||||||
const isProtectedPath = protectedPaths.some((path) => pathname.startsWith(path))
|
if (pathname === "/intern/login") {
|
||||||
|
|
||||||
if (!isProtectedPath) {
|
|
||||||
return NextResponse.next()
|
return NextResponse.next()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for session cookie
|
// Check for session cookie on all /intern/* routes
|
||||||
const sessionCookie = request.cookies.get("session")
|
const sessionCookie = request.cookies.get("session")
|
||||||
|
|
||||||
if (!sessionCookie?.value) {
|
if (!sessionCookie?.value) {
|
||||||
// Redirect to login
|
const loginUrl = new URL("/intern/login", request.url)
|
||||||
const loginUrl = new URL("/login", request.url)
|
|
||||||
loginUrl.searchParams.set("redirect", pathname)
|
loginUrl.searchParams.set("redirect", pathname)
|
||||||
return NextResponse.redirect(loginUrl)
|
return NextResponse.redirect(loginUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cookie exists - let the page validate the session
|
|
||||||
// (Session validation happens server-side in the page)
|
|
||||||
return NextResponse.next()
|
return NextResponse.next()
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/dashboard/:path*", "/historik/:path*", "/admin/:path*"],
|
matcher: ["/intern/:path*"],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
46
package-lock.json
generated
46
package-lock.json
generated
|
|
@ -16,7 +16,6 @@
|
||||||
"@radix-ui/react-slider": "^1.3.6",
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@radix-ui/react-slot": "1.2.4",
|
"@radix-ui/react-slot": "1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"bcrypt": "^6.0.0",
|
|
||||||
"better-sqlite3": "^12.6.0",
|
"better-sqlite3": "^12.6.0",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
|
|
@ -31,7 +30,6 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "3.3.3",
|
"@eslint/eslintrc": "3.3.3",
|
||||||
"@types/bcrypt": "^6.0.0",
|
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "25.0.5",
|
"@types/node": "25.0.5",
|
||||||
"@types/nodemailer": "^7.0.5",
|
"@types/nodemailer": "^7.0.5",
|
||||||
|
|
@ -3905,16 +3903,6 @@
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/bcrypt": {
|
|
||||||
"version": "6.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
|
|
||||||
"integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/node": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/better-sqlite3": {
|
"node_modules/@types/better-sqlite3": {
|
||||||
"version": "7.6.13",
|
"version": "7.6.13",
|
||||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
||||||
|
|
@ -4924,20 +4912,6 @@
|
||||||
"baseline-browser-mapping": "dist/cli.js"
|
"baseline-browser-mapping": "dist/cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bcrypt": {
|
|
||||||
"version": "6.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
|
|
||||||
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"node-addon-api": "^8.3.0",
|
|
||||||
"node-gyp-build": "^4.8.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/better-sqlite3": {
|
"node_modules/better-sqlite3": {
|
||||||
"version": "12.6.0",
|
"version": "12.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.0.tgz",
|
||||||
|
|
@ -7705,26 +7679,6 @@
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/node-addon-api": {
|
|
||||||
"version": "8.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
|
|
||||||
"integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "^18 || ^20 || >= 21"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/node-gyp-build": {
|
|
||||||
"version": "4.8.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
|
||||||
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"node-gyp-build": "bin.js",
|
|
||||||
"node-gyp-build-optional": "optional.js",
|
|
||||||
"node-gyp-build-test": "build-test.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.27",
|
"version": "2.0.27",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check ."
|
"format:check": "prettier --check .",
|
||||||
|
"setup": "node scripts/setup.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "5.2.2",
|
"@hookform/resolvers": "5.2.2",
|
||||||
|
|
@ -20,7 +21,6 @@
|
||||||
"@radix-ui/react-slider": "^1.3.6",
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@radix-ui/react-slot": "1.2.4",
|
"@radix-ui/react-slot": "1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"bcrypt": "^6.0.0",
|
|
||||||
"better-sqlite3": "^12.6.0",
|
"better-sqlite3": "^12.6.0",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
|
|
@ -35,7 +35,6 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "3.3.3",
|
"@eslint/eslintrc": "3.3.3",
|
||||||
"@types/bcrypt": "^6.0.0",
|
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "25.0.5",
|
"@types/node": "25.0.5",
|
||||||
"@types/nodemailer": "^7.0.5",
|
"@types/nodemailer": "^7.0.5",
|
||||||
|
|
|
||||||
69
scripts/build-release.sh
Executable file
69
scripts/build-release.sh
Executable file
|
|
@ -0,0 +1,69 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
||||||
|
DATE=$(date +%Y-%m-%d)
|
||||||
|
RELEASE_DIR="/tmp/foamking-release-${TIMESTAMP}"
|
||||||
|
ZIP_NAME="foamking-beregner-${DATE}.zip"
|
||||||
|
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
|
||||||
|
echo "FoamKing Release Builder"
|
||||||
|
echo "========================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Create temp directory
|
||||||
|
mkdir -p "${RELEASE_DIR}"
|
||||||
|
echo "Build directory: ${RELEASE_DIR}"
|
||||||
|
|
||||||
|
# Copy shipping files
|
||||||
|
echo "Copying files..."
|
||||||
|
cp -r "${PROJECT_DIR}/app" "${RELEASE_DIR}/"
|
||||||
|
cp -r "${PROJECT_DIR}/components" "${RELEASE_DIR}/"
|
||||||
|
cp -r "${PROJECT_DIR}/lib" "${RELEASE_DIR}/"
|
||||||
|
cp -r "${PROJECT_DIR}/public" "${RELEASE_DIR}/"
|
||||||
|
mkdir -p "${RELEASE_DIR}/scripts"
|
||||||
|
cp "${PROJECT_DIR}/scripts/setup.js" "${RELEASE_DIR}/scripts/"
|
||||||
|
cp "${PROJECT_DIR}/middleware.ts" "${RELEASE_DIR}/"
|
||||||
|
cp "${PROJECT_DIR}/package.json" "${RELEASE_DIR}/"
|
||||||
|
cp "${PROJECT_DIR}/tsconfig.json" "${RELEASE_DIR}/"
|
||||||
|
cp "${PROJECT_DIR}/tailwind.config.ts" "${RELEASE_DIR}/"
|
||||||
|
cp "${PROJECT_DIR}/postcss.config.mjs" "${RELEASE_DIR}/"
|
||||||
|
cp "${PROJECT_DIR}/eslint.config.mjs" "${RELEASE_DIR}/"
|
||||||
|
cp "${PROJECT_DIR}/next.config.ts" "${RELEASE_DIR}/"
|
||||||
|
cp "${PROJECT_DIR}/.env.example" "${RELEASE_DIR}/"
|
||||||
|
cp "${PROJECT_DIR}/SETUP.md" "${RELEASE_DIR}/"
|
||||||
|
cp "${PROJECT_DIR}/OPSÆTNING.md" "${RELEASE_DIR}/"
|
||||||
|
|
||||||
|
# Copy .prettierrc if it exists
|
||||||
|
[ -f "${PROJECT_DIR}/.prettierrc.json" ] && cp "${PROJECT_DIR}/.prettierrc.json" "${RELEASE_DIR}/"
|
||||||
|
|
||||||
|
# Verify build
|
||||||
|
echo ""
|
||||||
|
echo "Installing dependencies..."
|
||||||
|
cd "${RELEASE_DIR}"
|
||||||
|
npm install 2>&1 | tail -1
|
||||||
|
|
||||||
|
echo "Verifying build..."
|
||||||
|
npm run build 2>&1 | tail -3
|
||||||
|
|
||||||
|
# Clean up build artifacts
|
||||||
|
echo ""
|
||||||
|
echo "Cleaning up..."
|
||||||
|
rm -rf "${RELEASE_DIR}/node_modules"
|
||||||
|
rm -rf "${RELEASE_DIR}/.next"
|
||||||
|
|
||||||
|
# Create archive
|
||||||
|
cd /tmp
|
||||||
|
if command -v zip &> /dev/null; then
|
||||||
|
zip -rq "${ZIP_NAME}" "foamking-release-${TIMESTAMP}"
|
||||||
|
ARCHIVE="/tmp/${ZIP_NAME}"
|
||||||
|
else
|
||||||
|
TAR_NAME="foamking-beregner-${DATE}.tar.gz"
|
||||||
|
tar -czf "${TAR_NAME}" "foamking-release-${TIMESTAMP}"
|
||||||
|
ARCHIVE="/tmp/${TAR_NAME}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Release built successfully!"
|
||||||
|
echo "Archive: ${ARCHIVE}"
|
||||||
|
echo "Size: $(du -h "${ARCHIVE}" | cut -f1)"
|
||||||
65
scripts/setup.js
Normal file
65
scripts/setup.js
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const Database = require("better-sqlite3")
|
||||||
|
const path = require("path")
|
||||||
|
const fs = require("fs")
|
||||||
|
|
||||||
|
const DB_PATH = path.join(process.cwd(), "data", "quotes.db")
|
||||||
|
const dataDir = path.dirname(DB_PATH)
|
||||||
|
|
||||||
|
console.log("FoamKing Prisberegner - Database Setup")
|
||||||
|
console.log("======================================\n")
|
||||||
|
|
||||||
|
// Create data directory
|
||||||
|
if (!fs.existsSync(dataDir)) {
|
||||||
|
fs.mkdirSync(dataDir, { recursive: true })
|
||||||
|
console.log("Created data/ directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if DB already exists
|
||||||
|
const dbExists = fs.existsSync(DB_PATH)
|
||||||
|
if (dbExists) {
|
||||||
|
console.log("Database already exists at", DB_PATH)
|
||||||
|
console.log("Delete data/quotes.db to start fresh.\n")
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = new Database(DB_PATH)
|
||||||
|
|
||||||
|
db.pragma("journal_mode = WAL")
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE quotes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
postal_code TEXT NOT NULL,
|
||||||
|
address TEXT,
|
||||||
|
area REAL NOT NULL,
|
||||||
|
height REAL NOT NULL,
|
||||||
|
include_floor_heating INTEGER DEFAULT 1,
|
||||||
|
flooring_type TEXT DEFAULT 'STANDARD',
|
||||||
|
customer_name TEXT NOT NULL,
|
||||||
|
customer_email TEXT NOT NULL,
|
||||||
|
customer_phone TEXT NOT NULL,
|
||||||
|
remarks TEXT,
|
||||||
|
total_excl_vat REAL NOT NULL,
|
||||||
|
total_incl_vat REAL NOT NULL,
|
||||||
|
status TEXT DEFAULT 'new',
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
email_opened_at TEXT
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Set auto-increment to start at 1000
|
||||||
|
db.exec(
|
||||||
|
"INSERT INTO quotes (id, postal_code, area, height, customer_name, customer_email, customer_phone, total_excl_vat, total_incl_vat) VALUES (999, '0000', 0, 0, 'init', 'init', 'init', 0, 0)"
|
||||||
|
)
|
||||||
|
db.exec("DELETE FROM quotes WHERE id = 999")
|
||||||
|
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
console.log("Database created at", DB_PATH)
|
||||||
|
console.log("Quote IDs will start at 1000.\n")
|
||||||
|
console.log("Next steps:")
|
||||||
|
console.log(" 1. Configure .env.local (see SETUP.md / OPSÆTNING.md)")
|
||||||
|
console.log(" 2. npm run build")
|
||||||
|
console.log(" 3. npm start")
|
||||||
Loading…
Add table
Reference in a new issue