diff --git a/src/lib/db.ts b/src/lib/db.ts new file mode 100644 index 0000000..537d0aa --- /dev/null +++ b/src/lib/db.ts @@ -0,0 +1,47 @@ +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; +} diff --git a/src/lib/posts.ts b/src/lib/posts.ts new file mode 100644 index 0000000..dad9159 --- /dev/null +++ b/src/lib/posts.ts @@ -0,0 +1,74 @@ +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 }[]; +} diff --git a/tests/posts.test.ts b/tests/posts.test.ts new file mode 100644 index 0000000..ad373fa --- /dev/null +++ b/tests/posts.test.ts @@ -0,0 +1,74 @@ +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}$/); + }); +});