foamking/docs/plans/2026-02-22-delivery-cleanup.md
mikl0s 7eae4fde33 Add delivery cleanup implementation plan
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>
2026-02-22 21:35:06 +00:00

28 KiB

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:

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

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:

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

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

rm app/api/auth/setup/route.ts

Step 2: Rewrite login route

Replace app/api/auth/login/route.ts with:

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:

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

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.tsxapp/intern/beregner/page.tsx
  • Move: app/dashboard/page.tsxapp/intern/page.tsx
  • Move: app/historik/page.tsxapp/intern/historik/page.tsx
  • Move: app/login/page.tsxapp/intern/login/page.tsx

Step 1: Create new directories and move files

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:

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

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:39console.log("Quote request (test mode):", {...})
  • app/intern/page.tsx:49console.error("Failed to fetch quotes:", error)
  • app/intern/page.tsx:90console.error("Logout failed:", error)
  • app/intern/historik/page.tsx:51console.error("Failed to fetch quotes:", error)
  • app/intern/historik/page.tsx:63console.error("Logout failed:", error)
  • app/api/quote-request/route.ts:300console.error("Quote request error:", error)
  • app/api/quotes/route.ts:15console.error("Get quotes error:", error)
  • app/api/quotes/route.ts:45console.error("Update quote error:", error)

Step 2: Commit

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

// 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:

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:

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

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

#!/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":

"setup": "node scripts/setup.js"

Step 3: Commit

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

# ─── 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

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

npm uninstall bcrypt @types/bcrypt

Step 2: Verify build

npm run build

Expected: Should compile successfully if all auth references are updated.

Step 3: Commit

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

# FoamKing Prisberegner — Setup Guide

## Prerequisites

- Node.js 18+ (recommended: 20 LTS)
- npm

## Installation

1. **Install dependencies:**

   ```bash
   npm install
  1. Configure environment:

    Copy the example config and edit it:

    cp .env.example .env.local
    

    Edit .env.local with your values. See the configuration section below.

  2. Initialize database:

    npm run setup
    
  3. Build and start:

    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
  2. Navigate to RecipientsMailboxes
  3. Select the tilbud@foamking.dk mailbox
  4. Click Manage email apps settings (or Mail flowEmail 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 (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

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

#!/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

chmod +x scripts/build-release.sh

Step 3: Test the build script

./scripts/build-release.sh

Expected: Creates a zip at /tmp/foamking-beregner-<date>.zip

Step 4: Commit

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

rm -rf data/

Step 2: Run the full flow

npm run setup
npm run build

Expected: Both succeed.

Step 3: Verify no secrets in tracked files

Search for known credential patterns:

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

./scripts/build-release.sh

Expected: Clean zip produced.

Step 5: Final commit if any fixes needed, then tag

git tag -a v1.0.0 -m "Release v1.0.0 - delivery-ready"