| 1 | import Database from 'better-sqlite3';
|
|---|
| 2 | import path from 'path';
|
|---|
| 3 | import { fileURLToPath } from 'url';
|
|---|
| 4 | import fs from 'fs';
|
|---|
| 5 |
|
|---|
| 6 | const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|---|
| 7 | const dbPath = process.env.DATABASE_PATH || path.join(__dirname, '../../storage/database.sqlite');
|
|---|
| 8 |
|
|---|
| 9 | // Ensure storage directory exists
|
|---|
| 10 | const storageDir = path.dirname(dbPath);
|
|---|
| 11 | if (!fs.existsSync(storageDir)) {
|
|---|
| 12 | fs.mkdirSync(storageDir, { recursive: true });
|
|---|
| 13 | }
|
|---|
| 14 |
|
|---|
| 15 | // Initialize database
|
|---|
| 16 | const db = new Database(dbPath);
|
|---|
| 17 | db.pragma('journal_mode = WAL');
|
|---|
| 18 | db.pragma('foreign_keys = ON');
|
|---|
| 19 |
|
|---|
| 20 | export function initializeDatabase() {
|
|---|
| 21 | const tableExists = db.prepare(`
|
|---|
| 22 | SELECT name FROM sqlite_master WHERE type='table' AND name='users'
|
|---|
| 23 | `).get();
|
|---|
| 24 |
|
|---|
| 25 | if (!tableExists) {
|
|---|
| 26 | console.log('🔧 Initializing database schema...');
|
|---|
| 27 | const schemaPath = path.join(__dirname, '..', 'db', 'migrations', '001-init.sql');
|
|---|
| 28 | const schema = fs.readFileSync(schemaPath, 'utf-8');
|
|---|
| 29 | db.exec(schema);
|
|---|
| 30 | console.log('✅ Database initialized with v9-soul schema');
|
|---|
| 31 | }
|
|---|
| 32 |
|
|---|
| 33 | // Additive column migrations — safe to run every boot.
|
|---|
| 34 | // SQLite throws if the column already exists; we swallow that.
|
|---|
| 35 | ensureColumn('sites', 'enable_audio_player', 'INTEGER DEFAULT 1');
|
|---|
| 36 | ensureColumn('sites', 'profile_photo', 'TEXT');
|
|---|
| 37 | ensureColumn('audio_tracks', 'cover_url', 'TEXT');
|
|---|
| 38 | ensureColumn('audio_tracks', 'album', 'TEXT');
|
|---|
| 39 | ensureColumn('users', 'reset_token', 'TEXT');
|
|---|
| 40 | ensureColumn('users', 'reset_token_expires', 'DATETIME');
|
|---|
| 41 | // Google OAuth: koppel een Google-account aan een user (login via Google).
|
|---|
| 42 | ensureColumn('users', 'google_sub', 'TEXT');
|
|---|
| 43 | // Read-only/kijk-account: kan alles bekijken maar geen wijzigingen doen.
|
|---|
| 44 | ensureColumn('users', 'readonly', 'INTEGER DEFAULT 0');
|
|---|
| 45 | // Site-level moderation toggle. 'trust' = auto-approve, 'moderate' = pending until reviewed.
|
|---|
| 46 | ensureColumn('sites', 'comments_moderation_mode', "TEXT DEFAULT 'moderate'");
|
|---|
| 47 | // Per-site Prutter toggle: when off, DM endpoints/UI are hidden for that site.
|
|---|
| 48 | ensureColumn('sites', 'enable_prutter', 'INTEGER DEFAULT 1');
|
|---|
| 49 | // Cirkels: mag deze site in cirkels van anderen verschijnen (surfacing opt-out).
|
|---|
| 50 | ensureColumn('sites', 'allow_circle', 'INTEGER DEFAULT 1');
|
|---|
| 51 |
|
|---|
| 52 | // Eén EXPLICIETE primaire/hoofd-site (= de bedrijfs-/labelsite in hub-modus,
|
|---|
| 53 | // de enige site in solo) i.p.v. de fragiele "oudste = hoofd"-conventie die op
|
|---|
| 54 | // 4 plekken gedupliceerd stond. Backfill: markeer de oudste als er nog geen
|
|---|
| 55 | // primaire site is, zodat bestaand gedrag exact behouden blijft.
|
|---|
| 56 | ensureColumn('sites', 'is_primary', 'INTEGER DEFAULT 0');
|
|---|
| 57 | try {
|
|---|
| 58 | const hasPrimary = db.prepare('SELECT 1 FROM sites WHERE is_primary = 1 LIMIT 1').get();
|
|---|
| 59 | if (!hasPrimary) {
|
|---|
| 60 | const oldest = db.prepare('SELECT id FROM sites ORDER BY created_at ASC LIMIT 1').get();
|
|---|
| 61 | if (oldest) db.prepare('UPDATE sites SET is_primary = 1 WHERE id = ?').run(oldest.id);
|
|---|
| 62 | }
|
|---|
| 63 | } catch (e) { /* sites-tabel nog leeg/afwezig bij verse init — ensurePrimarySite regelt 't */ }
|
|---|
| 64 |
|
|---|
| 65 | // v9 audit additions —————————————————————————————————————————
|
|---|
| 66 | // SEO/social columns the v9 template uses (most live in 001-init.sql already
|
|---|
| 67 | // for fresh DBs but ensureColumn is idempotent for existing DBs).
|
|---|
| 68 | ensureColumn('sites', 'twitter', 'TEXT'); // @handle (with @)
|
|---|
| 69 | ensureColumn('sites', 'schema_type', "TEXT DEFAULT 'Person'"); // Person|Organization
|
|---|
| 70 | ensureColumn('sites', 'publisher_name', 'TEXT');
|
|---|
| 71 | ensureColumn('sites', 'publisher_url', 'TEXT');
|
|---|
| 72 | ensureColumn('sites', 'publisher_logo', 'TEXT');
|
|---|
| 73 | ensureColumn('sites', 'profile_enabled', 'INTEGER DEFAULT 1');
|
|---|
| 74 | ensureColumn('sites', 'profile_name', 'TEXT'); // display name (falls back to title)
|
|---|
| 75 | ensureColumn('sites', 'profile_bio', 'TEXT'); // short bio for header
|
|---|
| 76 | ensureColumn('sites', 'profile_links', 'TEXT'); // JSON array [{platform, url}]
|
|---|
| 77 | ensureColumn('sites', 'feed_view_default', "TEXT DEFAULT 'grid'"); // timeline | grid
|
|---|
| 78 | ensureColumn('sites', 'feed_view_switch', 'INTEGER DEFAULT 1'); // show switcher
|
|---|
| 79 | ensureColumn('sites', 'show_search', 'INTEGER DEFAULT 1');
|
|---|
| 80 | ensureColumn('sites', 'show_archive_link', 'INTEGER DEFAULT 1');
|
|---|
| 81 |
|
|---|
| 82 | // Per-post noindex + type
|
|---|
| 83 | ensureColumn('posts', 'noindex', 'INTEGER DEFAULT 0');
|
|---|
| 84 | ensureColumn('posts', 'publish_at', 'DATETIME'); // release-planning (premium #3): geplande go-live
|
|---|
| 85 | ensureColumn('posts', 'fan_only', 'INTEGER DEFAULT 0'); // fan-only preview (premium #3)
|
|---|
| 86 | ensureColumn('posts', 'type', "TEXT DEFAULT 'post'"); // post | foto | video | audio
|
|---|
| 87 |
|
|---|
| 88 | // Statistieken (premium-module) — kale tellers, cookievrij.
|
|---|
| 89 | ensureColumn('posts', 'view_count', 'INTEGER DEFAULT 0'); // weergaven per post
|
|---|
| 90 | ensureColumn('audio_tracks', 'play_count', 'INTEGER DEFAULT 0'); // plays per track
|
|---|
| 91 | ensureColumn('audio_tracks', 'downloadable', 'INTEGER DEFAULT 0'); // download-voor-email (premium #2)
|
|---|
| 92 | ensureColumn('audio_tracks', 'credit', 'TEXT'); // eigenaar/credit (copyright-houder)
|
|---|
| 93 | ensureColumn('audio_tracks', 'license', 'TEXT'); // licentie (bv. "CC BY 4.0", "Alle rechten voorbehouden")
|
|---|
| 94 |
|
|---|
| 95 | // Playlists (v9 feature) — first-class entity. CREATE IF NOT EXISTS is
|
|---|
| 96 | // idempotent so it's safe to run on every boot regardless of DB age.
|
|---|
| 97 | db.exec(`
|
|---|
| 98 | CREATE TABLE IF NOT EXISTS playlists (
|
|---|
| 99 | id TEXT PRIMARY KEY,
|
|---|
| 100 | site_id TEXT NOT NULL,
|
|---|
| 101 | title TEXT NOT NULL,
|
|---|
| 102 | artist TEXT,
|
|---|
| 103 | year INTEGER,
|
|---|
| 104 | cover_url TEXT,
|
|---|
| 105 | kind TEXT DEFAULT 'album',
|
|---|
| 106 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|---|
| 107 | updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|---|
| 108 | FOREIGN KEY (site_id) REFERENCES sites(id)
|
|---|
| 109 | );
|
|---|
| 110 | CREATE TABLE IF NOT EXISTS playlist_tracks (
|
|---|
| 111 | playlist_id TEXT NOT NULL,
|
|---|
| 112 | track_id TEXT NOT NULL,
|
|---|
| 113 | position INTEGER NOT NULL DEFAULT 0,
|
|---|
| 114 | PRIMARY KEY (playlist_id, track_id),
|
|---|
| 115 | FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
|
|---|
| 116 | FOREIGN KEY (track_id) REFERENCES audio_tracks(id) ON DELETE CASCADE
|
|---|
| 117 | );
|
|---|
| 118 | CREATE INDEX IF NOT EXISTS idx_playlist_tracks_pos
|
|---|
| 119 | ON playlist_tracks(playlist_id, position);
|
|---|
| 120 | `);
|
|---|
| 121 |
|
|---|
| 122 | // Globale app-instellingen (key/value singleton). O.a. de tenancy-modus
|
|---|
| 123 | // (solo = één site, hub = bedrijfssite + /user/). Default = solo.
|
|---|
| 124 | db.exec(`
|
|---|
| 125 | CREATE TABLE IF NOT EXISTS app_settings (
|
|---|
| 126 | key TEXT PRIMARY KEY,
|
|---|
| 127 | value TEXT,
|
|---|
| 128 | updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|---|
| 129 | );
|
|---|
| 130 | `);
|
|---|
| 131 | db.prepare("INSERT OR IGNORE INTO app_settings (key, value) VALUES ('tenancy', 'solo')").run();
|
|---|
| 132 |
|
|---|
| 133 | // ── Statistieken (premium) — cookievrij ─────────────────────
|
|---|
| 134 | // stat_daily: per dag per site het aantal pageviews (kale teller).
|
|---|
| 135 | // stat_visitor_day: per dag per site een rij per UNIEKE bezoeker-hash
|
|---|
| 136 | // (sha256 van IP+UA+dag-salt; de salt roteert dagelijks en wordt nooit
|
|---|
| 137 | // bewaard → geen persistente identifier, geen cookie, geen toestemming nodig).
|
|---|
| 138 | db.exec(`
|
|---|
| 139 | CREATE TABLE IF NOT EXISTS stat_daily (
|
|---|
| 140 | site_id TEXT NOT NULL,
|
|---|
| 141 | day TEXT NOT NULL,
|
|---|
| 142 | pageviews INTEGER NOT NULL DEFAULT 0,
|
|---|
| 143 | PRIMARY KEY (site_id, day)
|
|---|
| 144 | );
|
|---|
| 145 | CREATE TABLE IF NOT EXISTS stat_visitor_day (
|
|---|
| 146 | site_id TEXT NOT NULL,
|
|---|
| 147 | day TEXT NOT NULL,
|
|---|
| 148 | visitor_hash TEXT NOT NULL,
|
|---|
| 149 | PRIMARY KEY (site_id, day, visitor_hash)
|
|---|
| 150 | );
|
|---|
| 151 | CREATE INDEX IF NOT EXISTS idx_stat_visitor_day ON stat_visitor_day(site_id, day);
|
|---|
| 152 | CREATE TABLE IF NOT EXISTS stat_referrer (
|
|---|
| 153 | site_id TEXT NOT NULL,
|
|---|
| 154 | host TEXT NOT NULL,
|
|---|
| 155 | count INTEGER NOT NULL DEFAULT 0,
|
|---|
| 156 | PRIMARY KEY (site_id, host)
|
|---|
| 157 | );
|
|---|
| 158 | `);
|
|---|
| 159 |
|
|---|
| 160 | // ── Cirkels (federatie) ─────────────────────────────────────
|
|---|
| 161 | // Decentrale, asymmetrische verbindingen tussen solo-instances.
|
|---|
| 162 | db.exec(`
|
|---|
| 163 | CREATE TABLE IF NOT EXISTS circle_links (
|
|---|
| 164 | id TEXT PRIMARY KEY,
|
|---|
| 165 | local_site_id TEXT NOT NULL,
|
|---|
| 166 | remote_url TEXT NOT NULL,
|
|---|
| 167 | remote_actor_id TEXT,
|
|---|
| 168 | label TEXT,
|
|---|
| 169 | status TEXT DEFAULT 'active',
|
|---|
| 170 | added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|---|
| 171 | last_synced DATETIME,
|
|---|
| 172 | last_error TEXT,
|
|---|
| 173 | UNIQUE(local_site_id, remote_url),
|
|---|
| 174 | FOREIGN KEY (local_site_id) REFERENCES sites(id)
|
|---|
| 175 | );
|
|---|
| 176 | CREATE TABLE IF NOT EXISTS remote_actors (
|
|---|
| 177 | id TEXT PRIMARY KEY,
|
|---|
| 178 | url TEXT UNIQUE NOT NULL,
|
|---|
| 179 | name TEXT,
|
|---|
| 180 | summary TEXT,
|
|---|
| 181 | avatar TEXT,
|
|---|
| 182 | public_key TEXT NOT NULL,
|
|---|
| 183 | fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|---|
| 184 | );
|
|---|
| 185 | CREATE TABLE IF NOT EXISTS remote_posts (
|
|---|
| 186 | id TEXT PRIMARY KEY,
|
|---|
| 187 | actor_id TEXT NOT NULL,
|
|---|
| 188 | published DATETIME,
|
|---|
| 189 | title TEXT,
|
|---|
| 190 | summary TEXT,
|
|---|
| 191 | url TEXT,
|
|---|
| 192 | media_json TEXT,
|
|---|
| 193 | raw_json TEXT,
|
|---|
| 194 | fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|---|
| 195 | FOREIGN KEY (actor_id) REFERENCES remote_actors(id)
|
|---|
| 196 | );
|
|---|
| 197 | `);
|
|---|
| 198 |
|
|---|
| 199 | // Tags van de originele post — getoond in de cirkel (comma-separated string).
|
|---|
| 200 | ensureColumn('remote_posts', 'tags', 'TEXT');
|
|---|
| 201 |
|
|---|
| 202 | // Nieuwsbrief / mailinglijst (premium). Abonnees per site; double opt-in als SMTP
|
|---|
| 203 | // er is (status 'pending' tot bevestigd), anders single opt-in ('confirmed').
|
|---|
| 204 | // 'unsub' = uitgeschreven. token = confirm/unsubscribe-sleutel (in de e-maillinks).
|
|---|
| 205 | db.exec(`
|
|---|
| 206 | CREATE TABLE IF NOT EXISTS subscribers (
|
|---|
| 207 | id TEXT PRIMARY KEY,
|
|---|
| 208 | site_id TEXT NOT NULL,
|
|---|
| 209 | email TEXT NOT NULL,
|
|---|
| 210 | status TEXT NOT NULL DEFAULT 'pending',
|
|---|
| 211 | source TEXT DEFAULT 'widget',
|
|---|
| 212 | token TEXT NOT NULL,
|
|---|
| 213 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|---|
| 214 | confirmed_at DATETIME,
|
|---|
| 215 | UNIQUE(site_id, email)
|
|---|
| 216 | );
|
|---|
| 217 | CREATE INDEX IF NOT EXISTS idx_subscribers_site_status ON subscribers(site_id, status);
|
|---|
| 218 | `);
|
|---|
| 219 |
|
|---|
| 220 | // Verstuurde nieuwsbrieven (historie + aantallen).
|
|---|
| 221 | db.exec(`
|
|---|
| 222 | CREATE TABLE IF NOT EXISTS newsletters (
|
|---|
| 223 | id TEXT PRIMARY KEY,
|
|---|
| 224 | site_id TEXT NOT NULL,
|
|---|
| 225 | subject TEXT NOT NULL,
|
|---|
| 226 | body TEXT NOT NULL,
|
|---|
| 227 | sent_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|---|
| 228 | recipient_count INTEGER DEFAULT 0
|
|---|
| 229 | );
|
|---|
| 230 | `);
|
|---|
| 231 |
|
|---|
| 232 | // Show-agenda (premium #8): tourdata/optredens per site.
|
|---|
| 233 | db.exec(`
|
|---|
| 234 | CREATE TABLE IF NOT EXISTS shows (
|
|---|
| 235 | id TEXT PRIMARY KEY,
|
|---|
| 236 | site_id TEXT NOT NULL,
|
|---|
| 237 | date TEXT NOT NULL,
|
|---|
| 238 | time TEXT,
|
|---|
| 239 | city TEXT NOT NULL,
|
|---|
| 240 | venue TEXT,
|
|---|
| 241 | country TEXT,
|
|---|
| 242 | ticket_url TEXT,
|
|---|
| 243 | notes TEXT,
|
|---|
| 244 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|---|
| 245 | );
|
|---|
| 246 | CREATE INDEX IF NOT EXISTS idx_shows_site_date ON shows(site_id, date);
|
|---|
| 247 | `);
|
|---|
| 248 |
|
|---|
| 249 | // Link-in-bio klikstatistiek (premium #6). Per (site, url) een teller; de
|
|---|
| 250 | // link-in-bio-pagina linkt via /links/go/:i dat de klik telt en doorstuurt.
|
|---|
| 251 | db.exec(`
|
|---|
| 252 | CREATE TABLE IF NOT EXISTS link_clicks (
|
|---|
| 253 | site_id TEXT NOT NULL,
|
|---|
| 254 | url TEXT NOT NULL,
|
|---|
| 255 | clicks INTEGER DEFAULT 0,
|
|---|
| 256 | updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|---|
| 257 | PRIMARY KEY (site_id, url)
|
|---|
| 258 | );
|
|---|
| 259 | `);
|
|---|
| 260 |
|
|---|
| 261 | // Likes / favorieten: een ingelogde gebruiker kan een post liken. De set van
|
|---|
| 262 | // posts die een gebruiker likte = z'n favorieten (/favorieten-pagina). Eén rij
|
|---|
| 263 | // per (post, user); uniek zodat liken idempotent is.
|
|---|
| 264 | db.exec(`
|
|---|
| 265 | CREATE TABLE IF NOT EXISTS post_likes (
|
|---|
| 266 | post_id TEXT NOT NULL,
|
|---|
| 267 | user_id TEXT NOT NULL,
|
|---|
| 268 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|---|
| 269 | PRIMARY KEY (post_id, user_id)
|
|---|
| 270 | );
|
|---|
| 271 | CREATE INDEX IF NOT EXISTS idx_post_likes_user ON post_likes(user_id, created_at);
|
|---|
| 272 | CREATE INDEX IF NOT EXISTS idx_post_likes_post ON post_likes(post_id);
|
|---|
| 273 | `);
|
|---|
| 274 | }
|
|---|
| 275 |
|
|---|
| 276 | function ensureColumn(table, column, definition) {
|
|---|
| 277 | try {
|
|---|
| 278 | db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
|---|
| 279 | console.log(`🔧 Added column ${table}.${column}`);
|
|---|
| 280 | } catch (e) {
|
|---|
| 281 | // "duplicate column name" → already there. Anything else, surface it.
|
|---|
| 282 | if (!/duplicate column/i.test(e.message)) {
|
|---|
| 283 | console.error(`❌ ensureColumn(${table}.${column}):`, e.message);
|
|---|
| 284 | }
|
|---|
| 285 | }
|
|---|
| 286 | }
|
|---|
| 287 |
|
|---|
| 288 | export default db;
|
|---|