# 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