12-task plan covering route restructure, auth simplification, DB cleanup, security hardening, debug stripping, setup docs, and build script for zip delivery. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1031 lines
28 KiB
Markdown
1031 lines
28 KiB
Markdown
# FoamKing Delivery Cleanup Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Prepare the FoamKing calculator for zip delivery — restructure routes under `/intern/*`, replace DB-based auth with env-based single admin user, clean up the database layer, strip debug code, harden security, write setup docs, and create a build script.
|
|
|
|
**Architecture:** Next.js app with SQLite (better-sqlite3). Auth simplified from DB-backed bcrypt sessions to env-based credentials with in-memory session Map. All protected routes consolidated under `/intern/*`. Build script produces a clean zip with no git history, no docs, no secrets.
|
|
|
|
**Tech Stack:** Next.js 16, TypeScript, SQLite (better-sqlite3), Tailwind CSS, shadcn/ui, nodemailer
|
|
|
|
**Design doc:** `docs/plans/2026-02-22-delivery-cleanup-design.md`
|
|
|
|
---
|
|
|
|
### Task 1: Rewrite lib/auth.ts — env-based auth with swappable interface
|
|
|
|
**Files:**
|
|
- Rewrite: `lib/auth.ts`
|
|
|
|
**Step 1: Rewrite lib/auth.ts**
|
|
|
|
Replace the entire file with:
|
|
|
|
```typescript
|
|
import { cookies } from "next/headers"
|
|
|
|
const SESSION_COOKIE_NAME = "session"
|
|
const SESSION_DURATION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
|
|
|
|
// In-memory session store. Replace this with your own session/token system.
|
|
const sessions = new Map<string, { email: string; expiresAt: Date }>()
|
|
|
|
function generateSessionId(): string {
|
|
const bytes = new Uint8Array(32)
|
|
crypto.getRandomValues(bytes)
|
|
return Array.from(bytes)
|
|
.map((b) => b.toString(16).padStart(2, "0"))
|
|
.join("")
|
|
}
|
|
|
|
// ─── AUTH INTERFACE ────────────────────────────────────────────────
|
|
// To integrate your own auth (JWT, OAuth, etc.), replace these functions.
|
|
// The rest of the application only calls checkAuth(), login(), and logout().
|
|
// ───────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Check if the current request is authenticated.
|
|
* Replace this function to integrate JWT, OAuth, or any other auth system.
|
|
*/
|
|
export async function checkAuth(): Promise<{ authenticated: boolean; email?: string }> {
|
|
const cookieStore = await cookies()
|
|
const sessionId = cookieStore.get(SESSION_COOKIE_NAME)?.value
|
|
|
|
if (!sessionId) return { authenticated: false }
|
|
|
|
const session = sessions.get(sessionId)
|
|
if (!session) return { authenticated: false }
|
|
|
|
if (session.expiresAt < new Date()) {
|
|
sessions.delete(sessionId)
|
|
return { authenticated: false }
|
|
}
|
|
|
|
return { authenticated: true, email: session.email }
|
|
}
|
|
|
|
/**
|
|
* Authenticate with email and password.
|
|
* Default: compares against ADMIN_EMAIL/ADMIN_PASSWORD env vars.
|
|
* Replace this function to integrate your own auth system.
|
|
*/
|
|
export async function login(
|
|
email: string,
|
|
password: string
|
|
): Promise<{ success: true } | { success: false; error: string }> {
|
|
const adminEmail = process.env.ADMIN_EMAIL
|
|
const adminPassword = process.env.ADMIN_PASSWORD
|
|
|
|
if (!adminEmail || !adminPassword) {
|
|
return { success: false, error: "Admin-konfiguration mangler (ADMIN_EMAIL/ADMIN_PASSWORD)" }
|
|
}
|
|
|
|
if (email !== adminEmail || password !== adminPassword) {
|
|
return { success: false, error: "Forkert email eller adgangskode" }
|
|
}
|
|
|
|
const sessionId = generateSessionId()
|
|
const expiresAt = new Date(Date.now() + SESSION_DURATION_MS)
|
|
|
|
sessions.set(sessionId, { email, expiresAt })
|
|
|
|
const cookieStore = await cookies()
|
|
cookieStore.set(SESSION_COOKIE_NAME, sessionId, {
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === "production",
|
|
sameSite: "strict",
|
|
expires: expiresAt,
|
|
path: "/",
|
|
})
|
|
|
|
return { success: true }
|
|
}
|
|
|
|
/**
|
|
* Log out the current session.
|
|
*/
|
|
export async function logout(): Promise<void> {
|
|
const cookieStore = await cookies()
|
|
const sessionId = cookieStore.get(SESSION_COOKIE_NAME)?.value
|
|
|
|
if (sessionId) {
|
|
sessions.delete(sessionId)
|
|
cookieStore.delete(SESSION_COOKIE_NAME)
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Verify the app still compiles**
|
|
|
|
Run: `npm run build`
|
|
Expected: Build errors related to removed DB imports (this is expected — we fix them in later tasks)
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add lib/auth.ts
|
|
git commit -m "refactor: replace DB-based auth with env-based single admin user"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Rewrite lib/db.ts — clean schema, remove users/sessions
|
|
|
|
**Files:**
|
|
- Rewrite: `lib/db.ts`
|
|
|
|
**Step 1: Rewrite lib/db.ts**
|
|
|
|
Replace the entire file. Remove users table, sessions table, all user/session functions, and migration hacks. Keep only quotes functionality:
|
|
|
|
```typescript
|
|
import Database from "better-sqlite3"
|
|
import path from "path"
|
|
import fs from "fs"
|
|
|
|
const DB_PATH = path.join(process.cwd(), "data", "quotes.db")
|
|
|
|
// Ensure data directory exists
|
|
const dataDir = path.dirname(DB_PATH)
|
|
if (!fs.existsSync(dataDir)) {
|
|
fs.mkdirSync(dataDir, { recursive: true })
|
|
}
|
|
|
|
const db = new Database(DB_PATH)
|
|
|
|
// Enable WAL mode for better concurrent read performance
|
|
db.pragma("journal_mode = WAL")
|
|
|
|
// Create schema if not exists
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS 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
|
|
)
|
|
`)
|
|
|
|
// Ensure quote IDs start at 1000
|
|
const countResult = db.prepare("SELECT COUNT(*) as count FROM quotes").get() as { count: number }
|
|
if (countResult.count === 0) {
|
|
try {
|
|
const seqExists = db.prepare("SELECT seq FROM sqlite_sequence WHERE name = 'quotes'").get()
|
|
if (!seqExists) {
|
|
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")
|
|
}
|
|
} catch {
|
|
// sqlite_sequence may not exist yet on fresh DB — that's fine
|
|
}
|
|
}
|
|
|
|
// ─── Types ─────────────────────────────────────────────────────────
|
|
|
|
export interface QuoteInput {
|
|
postalCode: string
|
|
address?: string
|
|
area: number
|
|
height: number
|
|
includeFloorHeating: boolean
|
|
flooringType: string
|
|
customerName: string
|
|
customerEmail: string
|
|
customerPhone: string
|
|
remarks?: string
|
|
totalExclVat: number
|
|
totalInclVat: number
|
|
}
|
|
|
|
export type QuoteStatus = "new" | "contacted" | "accepted" | "rejected"
|
|
|
|
export interface StoredQuote extends QuoteInput {
|
|
id: number
|
|
status: QuoteStatus
|
|
createdAt: string
|
|
emailOpenedAt: string | null
|
|
}
|
|
|
|
// ─── Queries ───────────────────────────────────────────────────────
|
|
|
|
export function saveQuote(quote: QuoteInput): { id: number; slug: string } {
|
|
const stmt = db.prepare(`
|
|
INSERT INTO quotes (
|
|
postal_code, address, area, height, include_floor_heating, flooring_type,
|
|
customer_name, customer_email, customer_phone, remarks, total_excl_vat, total_incl_vat
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`)
|
|
|
|
const result = stmt.run(
|
|
quote.postalCode,
|
|
quote.address || null,
|
|
quote.area,
|
|
quote.height,
|
|
quote.includeFloorHeating ? 1 : 0,
|
|
quote.flooringType,
|
|
quote.customerName,
|
|
quote.customerEmail,
|
|
quote.customerPhone,
|
|
quote.remarks || null,
|
|
quote.totalExclVat,
|
|
quote.totalInclVat
|
|
)
|
|
|
|
const id = result.lastInsertRowid as number
|
|
const slug = `${quote.postalCode}-${id}`
|
|
|
|
return { id, slug }
|
|
}
|
|
|
|
export function getQuoteBySlug(slug: string): StoredQuote | null {
|
|
const match = slug.match(/^(\d{4})-(\d+)$/)
|
|
if (!match) return null
|
|
|
|
const [, postalCode, idStr] = match
|
|
const id = parseInt(idStr, 10)
|
|
|
|
const row = db.prepare("SELECT * FROM quotes WHERE id = ? AND postal_code = ?").get(id, postalCode) as any
|
|
if (!row) return null
|
|
|
|
return rowToQuote(row)
|
|
}
|
|
|
|
export function getQuoteById(id: number): StoredQuote | null {
|
|
const row = db.prepare("SELECT * FROM quotes WHERE id = ?").get(id) as any
|
|
if (!row) return null
|
|
return rowToQuote(row)
|
|
}
|
|
|
|
export function getAllQuotes(): StoredQuote[] {
|
|
const rows = db.prepare("SELECT * FROM quotes ORDER BY id DESC").all() as any[]
|
|
return rows.map(rowToQuote)
|
|
}
|
|
|
|
export function updateQuoteStatus(id: number, status: QuoteStatus): boolean {
|
|
const result = db.prepare("UPDATE quotes SET status = ? WHERE id = ?").run(status, id)
|
|
return result.changes > 0
|
|
}
|
|
|
|
export function markEmailOpened(id: number): boolean {
|
|
const result = db.prepare(
|
|
"UPDATE quotes SET email_opened_at = ? WHERE id = ? AND email_opened_at IS NULL"
|
|
).run(new Date().toISOString(), id)
|
|
return result.changes > 0
|
|
}
|
|
|
|
function rowToQuote(row: any): StoredQuote {
|
|
return {
|
|
id: row.id,
|
|
postalCode: row.postal_code,
|
|
address: row.address,
|
|
area: row.area,
|
|
height: row.height,
|
|
includeFloorHeating: row.include_floor_heating === 1,
|
|
flooringType: row.flooring_type,
|
|
customerName: row.customer_name,
|
|
customerEmail: row.customer_email,
|
|
customerPhone: row.customer_phone,
|
|
remarks: row.remarks,
|
|
totalExclVat: row.total_excl_vat,
|
|
totalInclVat: row.total_incl_vat,
|
|
status: row.status || "new",
|
|
createdAt: row.created_at,
|
|
emailOpenedAt: row.email_opened_at || null,
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Commit**
|
|
|
|
```bash
|
|
git add lib/db.ts
|
|
git commit -m "refactor: clean DB schema, remove users/sessions tables"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Delete /api/auth/setup, update login and logout routes
|
|
|
|
**Files:**
|
|
- Delete: `app/api/auth/setup/route.ts`
|
|
- Modify: `app/api/auth/login/route.ts`
|
|
- Modify: `app/api/auth/logout/route.ts`
|
|
|
|
**Step 1: Delete the setup endpoint**
|
|
|
|
```bash
|
|
rm app/api/auth/setup/route.ts
|
|
```
|
|
|
|
**Step 2: Rewrite login route**
|
|
|
|
Replace `app/api/auth/login/route.ts` with:
|
|
|
|
```typescript
|
|
import { NextRequest, NextResponse } from "next/server"
|
|
import { login } from "@/lib/auth"
|
|
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
const { email, password } = await request.json()
|
|
|
|
if (!email || !password) {
|
|
return NextResponse.json({ error: "Email og adgangskode er påkrævet" }, { status: 400 })
|
|
}
|
|
|
|
const result = await login(email, password)
|
|
|
|
if (!result.success) {
|
|
return NextResponse.json({ error: result.error }, { status: 401 })
|
|
}
|
|
|
|
return NextResponse.json({ success: true })
|
|
} catch {
|
|
return NextResponse.json({ error: "Der opstod en fejl" }, { status: 500 })
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 3: Rewrite logout route**
|
|
|
|
Replace `app/api/auth/logout/route.ts` with:
|
|
|
|
```typescript
|
|
import { NextResponse } from "next/server"
|
|
import { logout } from "@/lib/auth"
|
|
|
|
export async function POST() {
|
|
try {
|
|
await logout()
|
|
return NextResponse.json({ success: true })
|
|
} catch {
|
|
return NextResponse.json({ error: "Der opstod en fejl" }, { status: 500 })
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add -A app/api/auth/
|
|
git commit -m "refactor: simplify auth routes, remove setup endpoint"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Move routes — /admin → /intern/beregner, /dashboard → /intern, /historik → /intern/historik, /login → /intern/login
|
|
|
|
**Files:**
|
|
- Move: `app/admin/page.tsx` → `app/intern/beregner/page.tsx`
|
|
- Move: `app/dashboard/page.tsx` → `app/intern/page.tsx`
|
|
- Move: `app/historik/page.tsx` → `app/intern/historik/page.tsx`
|
|
- Move: `app/login/page.tsx` → `app/intern/login/page.tsx`
|
|
|
|
**Step 1: Create new directories and move files**
|
|
|
|
```bash
|
|
mkdir -p app/intern/beregner app/intern/historik app/intern/login
|
|
mv app/admin/page.tsx app/intern/beregner/page.tsx
|
|
mv app/dashboard/page.tsx app/intern/page.tsx
|
|
mv app/historik/page.tsx app/intern/historik/page.tsx
|
|
mv app/login/page.tsx app/intern/login/page.tsx
|
|
rmdir app/admin app/dashboard app/historik app/login
|
|
```
|
|
|
|
**Step 2: Update all internal links in moved files**
|
|
|
|
In `app/intern/page.tsx` (was dashboard):
|
|
- Change `router.push("/login")` → `router.push("/intern/login")`
|
|
- Change `<Link href="/historik">` → `<Link href="/intern/historik">`
|
|
|
|
In `app/intern/historik/page.tsx` (was historik):
|
|
- Change `router.push("/login")` → `router.push("/intern/login")`
|
|
- Change `<Link href="/dashboard">` → `<Link href="/intern">`
|
|
|
|
In `app/intern/login/page.tsx` (was login):
|
|
- Change `fetch("/api/auth/login"` — keep as-is (API routes don't move)
|
|
- Change `router.push("/dashboard")` → `router.push("/intern")`
|
|
|
|
In `app/intern/beregner/page.tsx` (was admin):
|
|
- No route references to update
|
|
|
|
**Step 3: Update middleware.ts**
|
|
|
|
Replace entire file:
|
|
|
|
```typescript
|
|
import { NextResponse } from "next/server"
|
|
import type { NextRequest } from "next/server"
|
|
|
|
export function middleware(request: NextRequest) {
|
|
const { pathname } = request.nextUrl
|
|
|
|
// Don't protect the login page
|
|
if (pathname === "/intern/login") {
|
|
return NextResponse.next()
|
|
}
|
|
|
|
// Check for session cookie on all /intern/* routes
|
|
const sessionCookie = request.cookies.get("session")
|
|
|
|
if (!sessionCookie?.value) {
|
|
const loginUrl = new URL("/intern/login", request.url)
|
|
loginUrl.searchParams.set("redirect", pathname)
|
|
return NextResponse.redirect(loginUrl)
|
|
}
|
|
|
|
return NextResponse.next()
|
|
}
|
|
|
|
export const config = {
|
|
matcher: ["/intern/:path*"],
|
|
}
|
|
```
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "refactor: consolidate protected routes under /intern/*"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Strip debug code — console.logs, dev toggles, TODO markers
|
|
|
|
**Files:**
|
|
- Modify: `app/intern/beregner/page.tsx` — remove console.log and "Admin Mode" toggle reference if not needed publicly
|
|
- Modify: `app/intern/page.tsx` — remove console.error
|
|
- Modify: `app/intern/historik/page.tsx` — remove console.error
|
|
- Modify: `app/api/quotes/route.ts` — remove console.error
|
|
- Modify: `app/api/quote-request/route.ts` — remove console.error
|
|
|
|
**Step 1: Remove all console.log/console.error statements**
|
|
|
|
In each file listed above, find and remove all `console.log(...)` and `console.error(...)` lines. In catch blocks, keep the error handling logic but remove the console output.
|
|
|
|
Known locations:
|
|
- `app/intern/beregner/page.tsx:39` — `console.log("Quote request (test mode):", {...})`
|
|
- `app/intern/page.tsx:49` — `console.error("Failed to fetch quotes:", error)`
|
|
- `app/intern/page.tsx:90` — `console.error("Logout failed:", error)`
|
|
- `app/intern/historik/page.tsx:51` — `console.error("Failed to fetch quotes:", error)`
|
|
- `app/intern/historik/page.tsx:63` — `console.error("Logout failed:", error)`
|
|
- `app/api/quote-request/route.ts:300` — `console.error("Quote request error:", error)`
|
|
- `app/api/quotes/route.ts:15` — `console.error("Get quotes error:", error)`
|
|
- `app/api/quotes/route.ts:45` — `console.error("Update quote error:", error)`
|
|
|
|
**Step 2: Commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "chore: strip debug console.log/error statements"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Add rate limiting to API routes
|
|
|
|
**Files:**
|
|
- Create: `lib/rate-limit.ts`
|
|
- Modify: `app/api/auth/login/route.ts`
|
|
- Modify: `app/api/quote-request/route.ts`
|
|
|
|
**Step 1: Create lib/rate-limit.ts**
|
|
|
|
```typescript
|
|
// 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
|
|
}
|
|
```
|
|
|
|
**Step 2: Add rate limiting to login route**
|
|
|
|
In `app/api/auth/login/route.ts`, add at the top of the POST handler:
|
|
|
|
```typescript
|
|
import { rateLimit } from "@/lib/rate-limit"
|
|
|
|
// Inside POST handler, before processing:
|
|
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 })
|
|
}
|
|
```
|
|
|
|
**Step 3: Add rate limiting to quote-request route**
|
|
|
|
In `app/api/quote-request/route.ts`, add at the top of the POST handler:
|
|
|
|
```typescript
|
|
import { rateLimit } from "@/lib/rate-limit"
|
|
|
|
// Inside POST handler, before processing:
|
|
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 })
|
|
}
|
|
```
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add lib/rate-limit.ts app/api/auth/login/route.ts app/api/quote-request/route.ts
|
|
git commit -m "feat: add rate limiting to login and quote-request endpoints"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Create npm run setup seed script
|
|
|
|
**Files:**
|
|
- Create: `scripts/setup.js`
|
|
- Modify: `package.json` — add `"setup"` script
|
|
|
|
**Step 1: Create scripts/setup.js**
|
|
|
|
```javascript
|
|
#!/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")
|
|
```
|
|
|
|
**Step 2: Add setup script to package.json**
|
|
|
|
Add to `"scripts"`:
|
|
```json
|
|
"setup": "node scripts/setup.js"
|
|
```
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add scripts/setup.js package.json
|
|
git commit -m "feat: add npm run setup seed script for database initialization"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Update .env.example
|
|
|
|
**Files:**
|
|
- Rewrite: `.env.example`
|
|
|
|
**Step 1: Replace .env.example**
|
|
|
|
```bash
|
|
# ─── Admin Login ────────────────────────────────────────────────────
|
|
ADMIN_EMAIL=admin@example.com
|
|
ADMIN_PASSWORD=changeme
|
|
|
|
# ─── Email (SMTP) ──────────────────────────────────────────────────
|
|
# Office 365: smtp.office365.com, port 587
|
|
# Requires SMTP AUTH enabled for the sending account in Exchange Admin Center.
|
|
# See SETUP.md / OPSÆTNING.md for instructions.
|
|
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
|
|
|
|
# ─── 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
|
|
```
|
|
|
|
**Step 2: Commit**
|
|
|
|
```bash
|
|
git add .env.example
|
|
git commit -m "docs: update .env.example with Office 365 SMTP and admin config"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Remove bcrypt dependency from package.json
|
|
|
|
**Files:**
|
|
- Modify: `package.json`
|
|
|
|
**Step 1: Remove bcrypt**
|
|
|
|
```bash
|
|
npm uninstall bcrypt @types/bcrypt
|
|
```
|
|
|
|
**Step 2: Verify build**
|
|
|
|
```bash
|
|
npm run build
|
|
```
|
|
|
|
Expected: Should compile successfully if all auth references are updated.
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add package.json package-lock.json
|
|
git commit -m "chore: remove bcrypt dependency (auth is now env-based)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: Write SETUP.md and OPSÆTNING.md
|
|
|
|
**Files:**
|
|
- Create: `SETUP.md`
|
|
- Create: `OPSÆTNING.md`
|
|
|
|
**Step 1: Create SETUP.md**
|
|
|
|
```markdown
|
|
# FoamKing Prisberegner — Setup Guide
|
|
|
|
## Prerequisites
|
|
|
|
- Node.js 18+ (recommended: 20 LTS)
|
|
- npm
|
|
|
|
## Installation
|
|
|
|
1. **Install dependencies:**
|
|
|
|
```bash
|
|
npm install
|
|
```
|
|
|
|
2. **Configure environment:**
|
|
|
|
Copy the example config and edit it:
|
|
|
|
```bash
|
|
cp .env.example .env.local
|
|
```
|
|
|
|
Edit `.env.local` with your values. See the configuration section below.
|
|
|
|
3. **Initialize database:**
|
|
|
|
```bash
|
|
npm run setup
|
|
```
|
|
|
|
4. **Build and start:**
|
|
|
|
```bash
|
|
npm run build
|
|
npm start
|
|
```
|
|
|
|
The application runs on port 3001 by default.
|
|
|
|
## Configuration (.env.local)
|
|
|
|
### Admin Login (Required)
|
|
|
|
```
|
|
ADMIN_EMAIL=admin@example.com
|
|
ADMIN_PASSWORD=your-secure-password
|
|
```
|
|
|
|
These credentials are used to log in at `/intern/login`.
|
|
|
|
### Email — Office 365 SMTP (Required)
|
|
|
|
```
|
|
SMTP_HOST=smtp.office365.com
|
|
SMTP_PORT=587
|
|
SMTP_USER=tilbud@foamking.dk
|
|
SMTP_PASS=your-password
|
|
EMAIL_FROM_NAME=Foam King Prisberegner
|
|
EMAIL_TO=info@foamking.dk
|
|
```
|
|
|
|
**Important:** SMTP AUTH must be enabled for the sending account (`tilbud@foamking.dk`) in your Office 365 / Exchange Admin Center:
|
|
|
|
1. Go to [Exchange Admin Center](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
|
|
|
|
If your organization uses Security Defaults or Conditional Access policies that block legacy auth, you may need to create an exclusion for this account, or use an App Password if MFA is enabled.
|
|
|
|
### Base URL (Required)
|
|
|
|
```
|
|
NEXT_PUBLIC_BASE_URL=https://beregner.foamking.dk
|
|
```
|
|
|
|
Used for generating links in emails (e.g., quote tracking links).
|
|
|
|
### Distance Calculation (Optional)
|
|
|
|
```
|
|
OPENROUTE_API_KEY=your_api_key_here
|
|
```
|
|
|
|
Without this key, transport distances are calculated from a built-in postal code table (sufficient for price estimates). With the key, actual driving distances from OpenRouteService are used.
|
|
|
|
Get a free key at [openrouteservice.org](https://openrouteservice.org/dev/#/signup) (2,000 requests/day).
|
|
|
|
## Routes
|
|
|
|
| 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 |
|
|
|
|
## Custom Authentication
|
|
|
|
The default auth uses `ADMIN_EMAIL`/`ADMIN_PASSWORD` environment variables with in-memory sessions.
|
|
|
|
To integrate your own auth system (JWT, OAuth, SSO, etc.), edit `lib/auth.ts`. The application only uses three functions:
|
|
|
|
- `checkAuth()` — returns `{ authenticated: boolean; email?: string }`
|
|
- `login(email, password)` — authenticates and sets session
|
|
- `logout()` — clears session
|
|
|
|
Replace these functions with your own implementation. The middleware in `middleware.ts` checks for a `session` cookie — update this if your auth uses a different mechanism (e.g., Bearer tokens).
|
|
|
|
## Development
|
|
|
|
```bash
|
|
npm run dev # Start dev server (port 3001)
|
|
npm run build # Production build
|
|
npm start # Start production server
|
|
npm run lint # Run ESLint
|
|
npm run setup # Initialize database
|
|
```
|
|
```
|
|
|
|
**Step 2: Create OPSÆTNING.md**
|
|
|
|
Write the Danish version with the same structure and content, translated to Danish.
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add SETUP.md OPSÆTNING.md
|
|
git commit -m "docs: add setup guides in English and Danish"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 11: Create scripts/build-release.sh
|
|
|
|
**Files:**
|
|
- Create: `scripts/build-release.sh`
|
|
|
|
**Step 1: Create the build script**
|
|
|
|
```bash
|
|
#!/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 --ignore-scripts 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 zip
|
|
cd /tmp
|
|
zip -rq "${ZIP_NAME}" "foamking-release-${TIMESTAMP}"
|
|
|
|
echo ""
|
|
echo "Release built successfully!"
|
|
echo "Zip: /tmp/${ZIP_NAME}"
|
|
echo "Size: $(du -h "/tmp/${ZIP_NAME}" | cut -f1)"
|
|
```
|
|
|
|
**Step 2: Make executable**
|
|
|
|
```bash
|
|
chmod +x scripts/build-release.sh
|
|
```
|
|
|
|
**Step 3: Test the build script**
|
|
|
|
```bash
|
|
./scripts/build-release.sh
|
|
```
|
|
|
|
Expected: Creates a zip at `/tmp/foamking-beregner-<date>.zip`
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add scripts/build-release.sh
|
|
git commit -m "feat: add release build script for zip delivery"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 12: Final verification — build, verify no secrets, test zip
|
|
|
|
**Step 1: Delete the existing data/quotes.db to test fresh setup**
|
|
|
|
```bash
|
|
rm -rf data/
|
|
```
|
|
|
|
**Step 2: Run the full flow**
|
|
|
|
```bash
|
|
npm run setup
|
|
npm run build
|
|
```
|
|
|
|
Expected: Both succeed.
|
|
|
|
**Step 3: Verify no secrets in tracked files**
|
|
|
|
Search for known credential patterns:
|
|
```bash
|
|
grep -r "Foamking1066\|sk-proj-\|eyJvcm" --include="*.ts" --include="*.tsx" --include="*.js" --include="*.md" .
|
|
```
|
|
|
|
Expected: No matches (only in .env.local which is gitignored).
|
|
|
|
**Step 4: Run the build-release script**
|
|
|
|
```bash
|
|
./scripts/build-release.sh
|
|
```
|
|
|
|
Expected: Clean zip produced.
|
|
|
|
**Step 5: Final commit if any fixes needed, then tag**
|
|
|
|
```bash
|
|
git tag -a v1.0.0 -m "Release v1.0.0 - delivery-ready"
|
|
```
|