diff --git a/docs/superpowers/plans/2026-07-01-insanitylab-website.md b/docs/superpowers/plans/2026-07-01-insanitylab-website.md new file mode 100644 index 0000000..808bfa1 --- /dev/null +++ b/docs/superpowers/plans/2026-07-01-insanitylab-website.md @@ -0,0 +1,3513 @@ +# Sito InsanityLab — Piano di implementazione + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Sito vetrina InsanityLab con blog gestibile dal cliente (login + editor TipTap) e form contatti via email, pronto per porting Docker su VPS in fase 2. + +**Architecture:** Astro 5 con output `server` (adapter Node standalone): pagine vetrina prerenderizzate, blog/admin server-rendered da SQLite. Un'unica app Node, JS client vanilla minimo. + +**Tech Stack:** Astro 5, @astrojs/node, better-sqlite3, bcryptjs, nodemailer, sanitize-html, TipTap, @fontsource (Montserrat, Open Sans), vitest. + +**Spec:** `docs/superpowers/specs/2026-07-01-insanitylab-website-design.md` + +## Global Constraints + +- Directory progetto: `/home/adriano/Documenti/Clienti/InsanityLab/insanitylab-website` (repo git già inizializzato, branch `main`). +- Route pubbliche in inglese: `/`, `/about`, `/training`, `/programs`, `/programs/[slug]`, `/blog`, `/blog/[slug]`, `/contact`. Admin: `/admin`, `/admin/login`, `/admin/new`, `/admin/edit/[id]`. API: `POST /api/contact`, `/api/admin/posts*`, `POST /api/admin/upload`. +- Slug programmi: `personal-training`, `performance-class`, `coaching`, `pilates-flow`, `rehab`, `nutrition`. +- Categorie blog fisse: `Articoli tecnici & scientifici`, `Contenuti educativi`, `LinkedIn / Insight`, `Eventi & Presidi`. +- Palette: accento tan `#b5a48b`, marrone scuro `#3a2d26`, fondo alternativo `#f5f3f0` (verificare campionando i PDF, Task 3). +- Testi UI in italiano. Niente Google Fonts CDN (font self-hosted). Niente framework CSS o JS client. +- DB: `data/insanitylab.db` (gitignored). Upload: `uploads/` a root progetto (gitignored), serviti da route dinamica `/uploads/[...path]`. +- Variabili d'ambiente lette con fallback `process.env.X ?? import.meta.env.X` (dev: Vite carica `.env`; prod: `node --env-file=.env`). +- Sito di riferimento design: PDF in `../FigmaDoc/`. Template analizzato: `../Dati/cassetta-me-analisi.md`. +- Dati di contatto reali: tel `351 7535977`, `info@insanitylab.it`, Via Leandro Alberti 76, 40139 Bologna, Lun–Ven 6.00–21.00, Sab 8.00–19.00, Dom chiuso. +- Commit frequenti, messaggi brevi in italiano, prefissi `feat:`/`fix:`/`chore:`/`test:`. + +--- + +### Task 1: Scaffold progetto Astro + +**Files:** +- Create: `package.json`, `astro.config.mjs`, `tsconfig.json`, `vitest.config.ts`, `.gitignore`, `.env.example`, `README.md`, `src/pages/index.astro` (placeholder), `src/env.d.ts`, `data/.gitkeep`, `uploads/.gitkeep` + +**Interfaces:** +- Produces: progetto buildabile; script npm `dev`, `build`, `preview`, `start`, `test`, `create-user`. + +- [ ] **Step 1: package.json e installazione dipendenze** + +Crea `package.json`: + +```json +{ + "name": "insanitylab-website", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview", + "start": "node --env-file=.env ./dist/server/entry.mjs", + "test": "vitest run", + "create-user": "node scripts/create-user.mjs" + } +} +``` + +Run: +```bash +cd /home/adriano/Documenti/Clienti/InsanityLab/insanitylab-website +npm install astro @astrojs/node @astrojs/sitemap better-sqlite3 bcryptjs nodemailer sanitize-html @fontsource/montserrat @fontsource/open-sans @tiptap/core @tiptap/starter-kit @tiptap/extension-image @tiptap/extension-link +npm install -D vitest typescript @types/better-sqlite3 @types/bcryptjs @types/nodemailer @types/sanitize-html +``` +Expected: install senza errori (better-sqlite3 compila nativo). + +- [ ] **Step 2: file di configurazione** + +`astro.config.mjs`: +```js +import { defineConfig } from 'astro/config'; +import node from '@astrojs/node'; +import sitemap from '@astrojs/sitemap'; + +export default defineConfig({ + site: 'https://insanitylab.tielogic.xyz', + output: 'server', + adapter: node({ mode: 'standalone' }), + integrations: [sitemap()], +}); +``` + +`tsconfig.json`: +```json +{ + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "src/**/*", "tests/**/*"], + "exclude": ["dist"] +} +``` + +`vitest.config.ts`: +```ts +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { include: ['tests/**/*.test.ts'] }, +}); +``` + +`.gitignore`: +``` +node_modules/ +dist/ +.astro/ +.env +data/*.db* +uploads/* +!uploads/.gitkeep +assets-src/ +``` + +`.env.example`: +``` +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=user +SMTP_PASS=pass +CONTACT_TO=info@insanitylab.it +CONTACT_FROM=sito@insanitylab.it +DB_PATH=data/insanitylab.db +UPLOADS_DIR=uploads +``` + +`src/env.d.ts`: +```ts +/// + +declare namespace App { + interface Locals { + user?: { id: number; username: string }; + } +} +``` + +`src/pages/index.astro` (placeholder, sostituito nel Task 9): +```astro +--- +export const prerender = true; +--- +InsanityLab +

InsanityLab — in costruzione

+``` + +Crea `data/.gitkeep` e `uploads/.gitkeep` vuoti. Copia `.env.example` in `.env`. + +- [ ] **Step 3: README.md** + +Prosa italiana completa: scopo (sito vetrina InsanityLab con blog e form contatti), setup (`npm install`, `cp .env.example .env`, `npm run create-user -- `), comandi (`dev`, `build`, `start`, `test`), stack, struttura cartelle, riferimento alla spec in `docs/superpowers/specs/`. + +- [ ] **Step 4: verifica build** + +Run: `npm run build` +Expected: build completata, cartella `dist/` creata. + +- [ ] **Step 5: commit** + +```bash +git add -A && git commit -m "chore: scaffold progetto Astro 5 con adapter node" +``` + +--- + +### Task 2: Estrazione asset da Figma + +**Files:** +- Create: `scripts/extract-fig-images.sh`, `scripts/asset-map.json`, `scripts/apply-asset-map.mjs`, `src/assets/img/*` (immagini rinominate), `public/img/logo-*.png` + +**Interfaces:** +- Produces: immagini in `src/assets/img/` con nomi semantici (`hero-1.png`, `info-performance.jpg`, `team-donata.jpg`, `program-one-to-one.jpg`, `partner-edenred.png`, ecc.); loghi in `public/img/`. + +- [ ] **Step 1: script estrazione** + +`scripts/extract-fig-images.sh`: +```bash +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +FIG="../FigmaDoc/Web Insanity.fig" +OUT="assets-src" +rm -rf "$OUT" && mkdir -p "$OUT" +unzip -o -j -q "$FIG" 'images/*' -d "$OUT" +cd "$OUT" +for f in *; do + case "$(file --brief --mime-type "$f")" in + image/png) mv "$f" "$f.png" ;; + image/jpeg) mv "$f" "$f.jpg" ;; + image/webp) mv "$f" "$f.webp" ;; + *) rm -f "$f" ;; + esac +done +echo "Estratte: $(ls | wc -l) immagini in $OUT/" +``` + +Run: `chmod +x scripts/extract-fig-images.sh && ./scripts/extract-fig-images.sh` +Expected: decine di immagini con estensione in `assets-src/`. + +- [ ] **Step 2: identificazione visiva e mappatura** + +Visualizza le immagini estratte (tool Read su ogni file, oppure contact sheet con `montage assets-src/*.{png,jpg} -tile 6x -geometry 200x200+4+4 -label '%f' assets-src/sheet_%d.png`). Compila `scripts/asset-map.json` associando ogni hash al nome di destinazione. Formato: + +```json +{ + "src/assets/img": { + ".jpg": "hero-1.jpg", + ".jpg": "info-performance.jpg", + ".jpg": "info-balance.jpg", + ".jpg": "info-longevity.jpg", + ".jpg": "feature-performance.jpg", + ".jpg": "feature-balance.jpg", + ".jpg": "feature-longevity.jpg", + ".jpg": "quote-bg.jpg", + ".jpg": "training-one-to-one.jpg", + ".jpg": "training-small-groups.jpg", + ".jpg": "training-performance-class.jpg", + ".jpg": "training-fit-remote.jpg", + ".jpg": "team-donata.jpg", + ".jpg": "team-nicola.jpg", + ".jpg": "team-eleonora.jpg", + ".jpg": "team-antonello.jpg", + ".jpg": "method-step-1.jpg", + ".jpg": "method-step-2.jpg", + ".jpg": "method-step-3.jpg", + ".jpg": "method-step-4.jpg", + ".png": "partner-edenred.png", + ".png": "partner-welbee.png", + ".png": "partner-aon.png", + ".png": "partner-howden.png" + }, + "public/img": { + ".png": "logo-dark.png", + ".png": "logo-light.png" + } +} +``` + +Nomi aggiuntivi liberi per immagini di about/training trovate nell'archivio (`about-*.jpg`, ecc.). Le immagini non usate restano in `assets-src/` (gitignored). Se un'immagine attesa manca (es. loghi), fallback: `../Dati/logo-dark.png`, `../Dati/logo-light.png` e altri file in `../Dati/`. + +- [ ] **Step 3: script applicazione mappa** + +`scripts/apply-asset-map.mjs`: +```js +import { copyFileSync, mkdirSync, existsSync } from 'node:fs'; +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const map = JSON.parse(readFileSync(join(root, 'scripts/asset-map.json'), 'utf8')); +let n = 0, missing = 0; +for (const [destDir, entries] of Object.entries(map)) { + mkdirSync(join(root, destDir), { recursive: true }); + for (const [src, dest] of Object.entries(entries)) { + const from = join(root, 'assets-src', src); + if (!existsSync(from)) { console.error(`MANCANTE: ${src}`); missing++; continue; } + copyFileSync(from, join(root, destDir, dest)); + n++; + } +} +console.log(`Copiate ${n} immagini${missing ? `, ${missing} mancanti` : ''}`); +process.exit(missing ? 1 : 0); +``` + +Run: `node scripts/apply-asset-map.mjs` +Expected: `Copiate N immagini`, exit 0. + +- [ ] **Step 4: ottimizza pesi** + +Le immagini Figma possono superare 5 MB. Ridimensiona quelle sopra 2000 px di larghezza: +```bash +cd src/assets/img +for f in *.jpg *.png; do + w=$(identify -format '%w' "$f") + [ "$w" -gt 2000 ] && mogrify -resize 2000x "$f" && echo "ridotta: $f" +done +``` +Expected: nessuna immagine > 2000 px; `du -sh src/assets/img` sotto ~15 MB. + +- [ ] **Step 5: commit** + +```bash +git add -A && git commit -m "feat: asset immagini estratti da Figma e mappati" +``` + +--- + +### Task 3: Design tokens e CSS globale + +**Files:** +- Create: `src/styles/global.css` + +**Interfaces:** +- Produces: variabili CSS `--c-accent`, `--c-accent-dark`, `--c-dark`, `--c-text`, `--c-heading`, `--c-bg-alt`; classi utility `.container`, `.btn`, `.btn--dark`, `.btn--light`, `.section`, `.section--dark`, `.section-heading`, `.eyebrow`; font family var `--font-heading`, `--font-body`. + +- [ ] **Step 1: campiona colori esatti dai PDF** + +```bash +S=/tmp/ilab-sample && mkdir -p $S +pdftoppm -png -r 30 "../FigmaDoc/Homepage.pdf" $S/h +# bottone SCOPRI hero (~x55,y200 alla risoluzione 30dpi), sezione agenda scura, sfondo chiaro +convert $S/h-1.png -format "%[pixel:p{60,205}] %[pixel:p{50,1500}] %[pixel:p{300,1360}]" info: +``` +Annota i valori RGB reali e usali negli step successivi al posto dei default indicati (attesi: tan ~`#b5a48b`, marrone ~`#3a2d26`, chiaro ~`#f5f3f0`). + +- [ ] **Step 2: global.css** + +```css +/* === Design tokens (valori campionati da Figma) === */ +:root { + --c-accent: #b5a48b; + --c-accent-dark: #9c8b70; + --c-dark: #3a2d26; + --c-dark-2: #4a3a30; + --c-heading: #2b2b2b; + --c-text: #4b4b4b; + --c-text-light: #8a8a8a; + --c-bg: #ffffff; + --c-bg-alt: #f5f3f0; + --font-heading: 'Montserrat', sans-serif; + --font-body: 'Open Sans', sans-serif; + --header-h: 70px; +} + +/* === Reset === */ +*, *::before, *::after { box-sizing: border-box; } +body { margin: 0; font-family: var(--font-body); color: var(--c-text); font-size: 16px; line-height: 1.7; background: var(--c-bg); } +img { max-width: 100%; height: auto; display: block; } +h1, h2, h3, h4 { font-family: var(--font-heading); color: var(--c-heading); font-weight: 500; letter-spacing: .04em; line-height: 1.2; margin: 0 0 .5em; } +h1 { font-size: clamp(2.2rem, 5vw, 3.8rem); } +h2 { font-size: clamp(1.8rem, 4vw, 2.6rem); } +h3 { font-size: 1.3rem; } +a { color: inherit; } +p { margin: 0 0 1em; } + +/* === Utility === */ +.container { max-width: 1200px; margin-inline: auto; padding-inline: 20px; } +.section { padding-block: 90px; } +.section--alt { background: var(--c-bg-alt); } +.section--dark { background: var(--c-dark); color: #cfc6bd; } +.section--dark h2, .section--dark h3 { color: #fff; } +.eyebrow { font-family: var(--font-heading); text-transform: uppercase; letter-spacing: .25em; font-size: .75rem; color: var(--c-accent-dark); } +.section-heading { text-align: center; max-width: 640px; margin: 0 auto 56px; } +.section-heading h2 { text-transform: uppercase; letter-spacing: .12em; } + +.btn { display: inline-flex; align-items: center; gap: 14px; font-family: var(--font-heading); font-size: .72rem; font-weight: 600; letter-spacing: .2em; text-transform: uppercase; text-decoration: none; border: 0; cursor: pointer; padding: 16px 26px; background: var(--c-accent); color: #fff; transition: background .2s; } +.btn:hover { background: var(--c-accent-dark); } +.btn::after { content: '→'; font-size: .9rem; } +.btn--dark { background: var(--c-dark); } +.btn--dark:hover { background: var(--c-dark-2); } +.btn--light { background: #fff; color: var(--c-heading); } + +/* === Form === */ +.field { width: 100%; border: 1px solid #ddd; background: #fff; padding: 14px 16px; font-family: var(--font-body); font-size: .95rem; color: var(--c-text); margin-bottom: 14px; } +.field:focus { outline: 2px solid var(--c-accent); outline-offset: -1px; } +textarea.field { min-height: 120px; resize: vertical; } +.form-msg { font-size: .9rem; margin-top: 8px; } +.form-msg--ok { color: #3c7a3c; } +.form-msg--err { color: #b03030; } + +/* honeypot */ +.hp { position: absolute; left: -9999px; opacity: 0; } +``` + +- [ ] **Step 2b: aggiorna i valori con quelli campionati allo Step 1.** + +- [ ] **Step 3: commit** + +```bash +git add src/styles/global.css && git commit -m "feat: design tokens e css globale" +``` + +--- + +### Task 4: Lib di base — slugify e rate limit (TDD) + +**Files:** +- Create: `src/lib/slug.ts`, `src/lib/rate-limit.ts`, `src/lib/env.ts` +- Test: `tests/slug.test.ts`, `tests/rate-limit.test.ts` + +**Interfaces:** +- Produces: `slugify(input: string): string`; `rateLimit(key: string, max: number, windowMs: number): boolean` (true = consentito); `getEnv(name: string, fallback?: string): string`. + +- [ ] **Step 1: test slugify (falliranno)** + +`tests/slug.test.ts`: +```ts +import { describe, it, expect } from 'vitest'; +import { slugify } from '../src/lib/slug'; + +describe('slugify', () => { + it('minuscole e trattini', () => { + expect(slugify('Il Metodo InsanityLab')).toBe('il-metodo-insanitylab'); + }); + it('rimuove accenti', () => { + expect(slugify('Attività fisica è già qui')).toBe('attivita-fisica-e-gia-qui'); + }); + it('comprime simboli e spazi multipli', () => { + expect(slugify('Blog & Edugo -- news!!')).toBe('blog-edugo-news'); + }); + it('niente trattini ai bordi', () => { + expect(slugify(' ciao ')).toBe('ciao'); + }); + it('stringa vuota resta vuota', () => { + expect(slugify('')).toBe(''); + }); +}); +``` + +- [ ] **Step 2: verifica che falliscano** + +Run: `npx vitest run tests/slug.test.ts` +Expected: FAIL (`slugify` non esiste). + +- [ ] **Step 3: implementa slugify** + +`src/lib/slug.ts`: +```ts +export function slugify(input: string): string { + return input + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} +``` + +- [ ] **Step 4: test rate limit (falliranno)** + +`tests/rate-limit.test.ts`: +```ts +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { rateLimit, _resetBuckets } from '../src/lib/rate-limit'; + +afterEach(() => { _resetBuckets(); vi.useRealTimers(); }); + +describe('rateLimit', () => { + it('consente fino a max richieste', () => { + expect(rateLimit('a', 2, 1000)).toBe(true); + expect(rateLimit('a', 2, 1000)).toBe(true); + expect(rateLimit('a', 2, 1000)).toBe(false); + }); + it('chiavi indipendenti', () => { + expect(rateLimit('a', 1, 1000)).toBe(true); + expect(rateLimit('b', 1, 1000)).toBe(true); + }); + it('la finestra scade', () => { + vi.useFakeTimers(); + expect(rateLimit('a', 1, 1000)).toBe(true); + expect(rateLimit('a', 1, 1000)).toBe(false); + vi.advanceTimersByTime(1100); + expect(rateLimit('a', 1, 1000)).toBe(true); + }); +}); +``` + +- [ ] **Step 5: implementa rate-limit ed env** + +`src/lib/rate-limit.ts`: +```ts +const buckets = new Map(); + +export function rateLimit(key: string, max: number, windowMs: number): boolean { + const now = Date.now(); + const recent = (buckets.get(key) ?? []).filter((t) => now - t < windowMs); + if (recent.length >= max) { + buckets.set(key, recent); + return false; + } + recent.push(now); + buckets.set(key, recent); + return true; +} + +export function _resetBuckets(): void { + buckets.clear(); +} +``` + +`src/lib/env.ts`: +```ts +export function getEnv(name: string, fallback?: string): string { + const v = process.env[name] ?? (import.meta.env ? import.meta.env[name] : undefined) ?? fallback; + if (v === undefined) throw new Error(`Variabile d'ambiente mancante: ${name}`); + return v; +} +``` + +- [ ] **Step 6: verifica test verdi** + +Run: `npm test` +Expected: tutti PASS. + +- [ ] **Step 7: commit** + +```bash +git add src/lib tests && git commit -m "feat: slugify, rate limit ed env helper con test" +``` + +--- + +### Task 5: Layer database — schema e repository posts (TDD) + +**Files:** +- Create: `src/lib/db.ts`, `src/lib/posts.ts` +- Test: `tests/posts.test.ts` + +**Interfaces:** +- Consumes: `slugify` da Task 4 (nei consumer, non qui). +- Produces: + - `createDb(path?: string): Database.Database` — apre/crea DB, applica schema idempotente. + - `getDb(): Database.Database` — singleton su `DB_PATH`. + - Tipo `Post { id: number; title: string; slug: string; category: string; excerpt: string; body_html: string; cover: string | null; draft: number; published_at: string | null; created_at: string; updated_at: string }`. + - `PostInput { title: string; slug: string; category: string; excerpt: string; body_html: string; cover?: string | null; draft: boolean }`. + - `createPost(db, input: PostInput): number` (ritorna id; se `draft=false` imposta `published_at`). + - `updatePost(db, id: number, input: PostInput): void` (imposta `published_at` alla prima pubblicazione, aggiorna `updated_at`). + - `deletePost(db, id: number): void` + - `getPostById(db, id: number): Post | undefined` + - `getPublishedBySlug(db, slug: string): Post | undefined` + - `listPublished(db, opts?: { category?: string; page?: number; perPage?: number }): { items: Post[]; total: number }` (ordinati per `published_at` desc) + - `listAllPosts(db): Post[]` (anche bozze, per admin) + - `listRecent(db, limit?: number): Post[]` + - `listArchiveMonths(db): { month: string; count: number }[]` (formato `YYYY-MM`) + +- [ ] **Step 1: implementa db.ts** + +`src/lib/db.ts`: +```ts +import Database from 'better-sqlite3'; +import { mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; + +const SCHEMA = ` +CREATE TABLE IF NOT EXISTS posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + category TEXT NOT NULL, + excerpt TEXT NOT NULL DEFAULT '', + body_html TEXT NOT NULL DEFAULT '', + cover TEXT, + draft INTEGER NOT NULL DEFAULT 1, + published_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_posts_pub ON posts (draft, published_at DESC); +`; + +export function createDb(path?: string): Database.Database { + const p = path ?? process.env.DB_PATH ?? 'data/insanitylab.db'; + if (p !== ':memory:') mkdirSync(dirname(p), { recursive: true }); + const db = new Database(p); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + db.exec(SCHEMA); + return db; +} + +let singleton: Database.Database | null = null; + +export function getDb(): Database.Database { + if (!singleton) singleton = createDb(); + return singleton; +} +``` + +- [ ] **Step 2: test posts (falliranno)** + +`tests/posts.test.ts`: +```ts +import { describe, it, expect, beforeEach } from 'vitest'; +import type Database from 'better-sqlite3'; +import { createDb } from '../src/lib/db'; +import { + createPost, updatePost, deletePost, getPostById, getPublishedBySlug, + listPublished, listAllPosts, listRecent, listArchiveMonths, type PostInput, +} from '../src/lib/posts'; + +let db: Database.Database; +beforeEach(() => { db = createDb(':memory:'); }); + +const base: PostInput = { + title: 'Titolo', slug: 'titolo', category: 'Contenuti educativi', + excerpt: 'estratto', body_html: '

corpo

', cover: null, draft: false, +}; + +describe('posts repository', () => { + it('crea e rilegge un post pubblicato', () => { + const id = createPost(db, base); + const post = getPostById(db, id)!; + expect(post.title).toBe('Titolo'); + expect(post.draft).toBe(0); + expect(post.published_at).not.toBeNull(); + }); + + it('bozza non ha published_at e non appare nelle liste pubbliche', () => { + const id = createPost(db, { ...base, slug: 'bozza', draft: true }); + expect(getPostById(db, id)!.published_at).toBeNull(); + expect(getPublishedBySlug(db, 'bozza')).toBeUndefined(); + expect(listPublished(db).total).toBe(0); + expect(listAllPosts(db)).toHaveLength(1); + }); + + it('slug duplicato lancia', () => { + createPost(db, base); + expect(() => createPost(db, base)).toThrow(); + }); + + it('update imposta published_at alla prima pubblicazione e lo conserva', () => { + const id = createPost(db, { ...base, draft: true }); + updatePost(db, id, { ...base, draft: false }); + const first = getPostById(db, id)!.published_at; + expect(first).not.toBeNull(); + updatePost(db, id, { ...base, title: 'Nuovo', draft: false }); + expect(getPostById(db, id)!.published_at).toBe(first); + expect(getPostById(db, id)!.title).toBe('Nuovo'); + }); + + it('listPublished filtra per categoria e pagina', () => { + for (let i = 0; i < 12; i++) { + createPost(db, { ...base, slug: `post-${i}`, category: i % 2 ? 'Contenuti educativi' : 'Eventi & Presidi' }); + } + const all = listPublished(db, { page: 1, perPage: 9 }); + expect(all.total).toBe(12); + expect(all.items).toHaveLength(9); + const edu = listPublished(db, { category: 'Contenuti educativi' }); + expect(edu.total).toBe(6); + }); + + it('delete rimuove', () => { + const id = createPost(db, base); + deletePost(db, id); + expect(getPostById(db, id)).toBeUndefined(); + }); + + it('listRecent e archivio mesi', () => { + createPost(db, base); + expect(listRecent(db, 4)).toHaveLength(1); + const months = listArchiveMonths(db); + expect(months).toHaveLength(1); + expect(months[0].count).toBe(1); + expect(months[0].month).toMatch(/^\d{4}-\d{2}$/); + }); +}); +``` + +- [ ] **Step 3: verifica che falliscano** + +Run: `npx vitest run tests/posts.test.ts` +Expected: FAIL (modulo `posts` mancante). + +- [ ] **Step 4: implementa posts.ts** + +`src/lib/posts.ts`: +```ts +import type Database from 'better-sqlite3'; + +export interface Post { + id: number; title: string; slug: string; category: string; excerpt: string; + body_html: string; cover: string | null; draft: number; + published_at: string | null; created_at: string; updated_at: string; +} + +export interface PostInput { + title: string; slug: string; category: string; excerpt: string; + body_html: string; cover?: string | null; draft: boolean; +} + +export function createPost(db: Database.Database, input: PostInput): number { + const res = db.prepare( + `INSERT INTO posts (title, slug, category, excerpt, body_html, cover, draft, published_at) + VALUES (@title, @slug, @category, @excerpt, @body_html, @cover, @draft, + CASE WHEN @draft = 0 THEN datetime('now') ELSE NULL END)` + ).run({ ...input, cover: input.cover ?? null, draft: input.draft ? 1 : 0 }); + return Number(res.lastInsertRowid); +} + +export function updatePost(db: Database.Database, id: number, input: PostInput): void { + db.prepare( + `UPDATE posts SET title = @title, slug = @slug, category = @category, + excerpt = @excerpt, body_html = @body_html, cover = @cover, draft = @draft, + published_at = CASE WHEN @draft = 0 AND published_at IS NULL THEN datetime('now') ELSE published_at END, + updated_at = datetime('now') + WHERE id = @id` + ).run({ ...input, id, cover: input.cover ?? null, draft: input.draft ? 1 : 0 }); +} + +export function deletePost(db: Database.Database, id: number): void { + db.prepare('DELETE FROM posts WHERE id = ?').run(id); +} + +export function getPostById(db: Database.Database, id: number): Post | undefined { + return db.prepare('SELECT * FROM posts WHERE id = ?').get(id) as Post | undefined; +} + +export function getPublishedBySlug(db: Database.Database, slug: string): Post | undefined { + return db.prepare('SELECT * FROM posts WHERE slug = ? AND draft = 0').get(slug) as Post | undefined; +} + +export function listPublished( + db: Database.Database, + opts: { category?: string; page?: number; perPage?: number } = {}, +): { items: Post[]; total: number } { + const { category, page = 1, perPage = 9 } = opts; + const where = category ? 'WHERE draft = 0 AND category = @category' : 'WHERE draft = 0'; + const total = (db.prepare(`SELECT COUNT(*) AS n FROM posts ${where}`) + .get({ category }) as { n: number }).n; + const items = db.prepare( + `SELECT * FROM posts ${where} ORDER BY published_at DESC LIMIT @limit OFFSET @offset` + ).all({ category, limit: perPage, offset: (page - 1) * perPage }) as Post[]; + return { items, total }; +} + +export function listAllPosts(db: Database.Database): Post[] { + return db.prepare('SELECT * FROM posts ORDER BY updated_at DESC').all() as Post[]; +} + +export function listRecent(db: Database.Database, limit = 4): Post[] { + return db.prepare( + 'SELECT * FROM posts WHERE draft = 0 ORDER BY published_at DESC LIMIT ?' + ).all(limit) as Post[]; +} + +export function listArchiveMonths(db: Database.Database): { month: string; count: number }[] { + return db.prepare( + `SELECT strftime('%Y-%m', published_at) AS month, COUNT(*) AS count + FROM posts WHERE draft = 0 GROUP BY month ORDER BY month DESC` + ).all() as { month: string; count: number }[]; +} +``` + +- [ ] **Step 5: verifica test verdi** + +Run: `npm test` +Expected: tutti PASS. + +- [ ] **Step 6: commit** + +```bash +git add src/lib/db.ts src/lib/posts.ts tests/posts.test.ts && git commit -m "feat: layer sqlite e repository posts con test" +``` + +--- + +### Task 6: Autenticazione, middleware e script create-user (TDD) + +**Files:** +- Create: `src/lib/auth.ts`, `src/middleware.ts`, `scripts/create-user.mjs` +- Test: `tests/auth.test.ts` + +**Interfaces:** +- Consumes: `createDb`/`getDb` (Task 5), `rateLimit` (Task 4). +- Produces: + - `hashPassword(plain: string): string`, `verifyPassword(plain: string, hash: string): boolean` + - `createUser(db, username: string, password: string): number` + - `login(db, username: string, password: string): string | null` — token sessione o null. + - `getSessionUser(db, token: string): { id: number; username: string } | null` — null se scaduta (e la elimina). + - `logout(db, token: string): void` + - Costante `SESSION_COOKIE = 'session'`, durata 7 giorni. + - Middleware: protegge `/admin*` (tranne `/admin/login`) e `/api/admin*`; popola `Astro.locals.user`. + +- [ ] **Step 1: test auth (falliranno)** + +`tests/auth.test.ts`: +```ts +import { describe, it, expect, beforeEach } from 'vitest'; +import type Database from 'better-sqlite3'; +import { createDb } from '../src/lib/db'; +import { hashPassword, verifyPassword, createUser, login, getSessionUser, logout } from '../src/lib/auth'; + +let db: Database.Database; +beforeEach(() => { db = createDb(':memory:'); }); + +describe('auth', () => { + it('hash e verifica password', () => { + const h = hashPassword('segreta123'); + expect(h).not.toBe('segreta123'); + expect(verifyPassword('segreta123', h)).toBe(true); + expect(verifyPassword('sbagliata', h)).toBe(false); + }); + + it('login corretto crea sessione recuperabile', () => { + createUser(db, 'adriano', 'segreta123'); + const token = login(db, 'adriano', 'segreta123'); + expect(token).toBeTruthy(); + const user = getSessionUser(db, token!); + expect(user).toMatchObject({ username: 'adriano' }); + }); + + it('login errato ritorna null', () => { + createUser(db, 'adriano', 'segreta123'); + expect(login(db, 'adriano', 'sbagliata')).toBeNull(); + expect(login(db, 'inesistente', 'x')).toBeNull(); + }); + + it('sessione scaduta viene rifiutata ed eliminata', () => { + createUser(db, 'adriano', 'segreta123'); + const token = login(db, 'adriano', 'segreta123')!; + db.prepare("UPDATE sessions SET expires_at = datetime('now', '-1 day')").run(); + expect(getSessionUser(db, token)).toBeNull(); + expect(db.prepare('SELECT COUNT(*) AS n FROM sessions').get()).toMatchObject({ n: 0 }); + }); + + it('logout elimina la sessione', () => { + createUser(db, 'adriano', 'segreta123'); + const token = login(db, 'adriano', 'segreta123')!; + logout(db, token); + expect(getSessionUser(db, token)).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: verifica che falliscano** + +Run: `npx vitest run tests/auth.test.ts` +Expected: FAIL (modulo `auth` mancante). + +- [ ] **Step 3: implementa auth.ts** + +`src/lib/auth.ts`: +```ts +import type Database from 'better-sqlite3'; +import bcrypt from 'bcryptjs'; +import { randomBytes } from 'node:crypto'; + +export const SESSION_COOKIE = 'session'; +const SESSION_DAYS = 7; + +export function hashPassword(plain: string): string { + return bcrypt.hashSync(plain, 12); +} + +export function verifyPassword(plain: string, hash: string): boolean { + return bcrypt.compareSync(plain, hash); +} + +export function createUser(db: Database.Database, username: string, password: string): number { + const res = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)') + .run(username, hashPassword(password)); + return Number(res.lastInsertRowid); +} + +export function login(db: Database.Database, username: string, password: string): string | null { + const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username) as + | { id: number; password_hash: string } | undefined; + if (!user || !verifyPassword(password, user.password_hash)) return null; + const token = randomBytes(32).toString('hex'); + db.prepare( + `INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, datetime('now', '+${SESSION_DAYS} days'))` + ).run(token, user.id); + return token; +} + +export function getSessionUser(db: Database.Database, token: string): { id: number; username: string } | null { + const row = db.prepare( + `SELECT s.token, s.expires_at, u.id, u.username + FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ?` + ).get(token) as { token: string; expires_at: string; id: number; username: string } | undefined; + if (!row) return null; + if (row.expires_at <= new Date().toISOString().slice(0, 19).replace('T', ' ')) { + db.prepare('DELETE FROM sessions WHERE token = ?').run(token); + return null; + } + return { id: row.id, username: row.username }; +} + +export function logout(db: Database.Database, token: string): void { + db.prepare('DELETE FROM sessions WHERE token = ?').run(token); +} +``` + +- [ ] **Step 4: verifica test verdi** + +Run: `npm test` +Expected: tutti PASS. + +- [ ] **Step 5: middleware** + +`src/middleware.ts`: +```ts +import { defineMiddleware } from 'astro:middleware'; +import { getDb } from './lib/db'; +import { getSessionUser, SESSION_COOKIE } from './lib/auth'; + +export const onRequest = defineMiddleware((context, next) => { + const { pathname } = context.url; + const isProtected = + (pathname.startsWith('/admin') && pathname !== '/admin/login') || + pathname.startsWith('/api/admin'); + if (!isProtected) return next(); + + const token = context.cookies.get(SESSION_COOKIE)?.value; + const user = token ? getSessionUser(getDb(), token) : null; + if (!user) { + if (pathname.startsWith('/api/')) { + return new Response(JSON.stringify({ error: 'Non autorizzato' }), { + status: 401, headers: { 'Content-Type': 'application/json' }, + }); + } + return context.redirect('/admin/login'); + } + context.locals.user = user; + return next(); +}); +``` + +- [ ] **Step 6: script create-user** + +`scripts/create-user.mjs`: +```js +import Database from 'better-sqlite3'; +import bcrypt from 'bcryptjs'; +import { mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; + +const [, , username, password] = process.argv; +if (!username || !password) { + console.error('Uso: npm run create-user -- '); + process.exit(1); +} +const path = process.env.DB_PATH ?? 'data/insanitylab.db'; +mkdirSync(dirname(path), { recursive: true }); +const db = new Database(path); +db.exec(`CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL +);`); +db.prepare( + `INSERT INTO users (username, password_hash) VALUES (?, ?) + ON CONFLICT(username) DO UPDATE SET password_hash = excluded.password_hash` +).run(username, bcrypt.hashSync(password, 12)); +console.log(`Utente "${username}" creato/aggiornato in ${path}`); +``` + +Run: `npm run create-user -- admin prova1234 && sqlite3 data/insanitylab.db 'SELECT username FROM users;'` +Expected: `admin`. + +- [ ] **Step 7: commit** + +```bash +git add src/lib/auth.ts src/middleware.ts scripts/create-user.mjs tests/auth.test.ts +git commit -m "feat: autenticazione a sessioni, middleware admin e script create-user" +``` + +--- + +### Task 7: Layout base, Header, Footer, 404 + +**Files:** +- Create: `src/layouts/Base.astro`, `src/components/Header.astro`, `src/components/Footer.astro`, `src/components/ContactForm.astro`, `src/pages/404.astro` +- Modify: `src/pages/index.astro` (usa Base) +- Depends: `src/data/site.ts` (Task 8 — eseguire Task 8 prima o creare qui il file e ampliarlo lì; ordine consigliato: Task 8 poi Task 7 se si preferisce, sono indipendenti dagli altri) + +**Interfaces:** +- Consumes: `site` da `src/data/site.ts` (Task 8): `{ name, phone, email, address, cityLine, hours: string[], socials: { label, url }[], navigation: { label, href }[] }`. +- Produces: `` layout con slot; `` che POSTa a `/api/contact` via fetch. + +- [ ] **Step 1: Base.astro** + +```astro +--- +import '@fontsource/montserrat/400.css'; +import '@fontsource/montserrat/500.css'; +import '@fontsource/montserrat/600.css'; +import '@fontsource/montserrat/700.css'; +import '@fontsource/open-sans/400.css'; +import '@fontsource/open-sans/600.css'; +import '../styles/global.css'; +import Header from '../components/Header.astro'; +import Footer from '../components/Footer.astro'; + +interface Props { title: string; description?: string } +const { title, description = 'InsanityLab — Performance. Balance. Longevity. Allenamento personalizzato, piccoli gruppi e benessere a Bologna.' } = Astro.props; +--- + + + + + + {title} — InsanityLab + + + + + + + + +
+
+