import { randomUUID } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; import { supabase } from '../../supabase.js'; import { listEntries } from '../../files.js'; // Daten-Schicht für den Dialog (Foren + Threads + Wortmeldungen). // Alle DB-Zugriffe laufen über den Service-Client (umgeht RLS). const SITE_DIR = process.env.SITE_DIR || '/site'; export const LIBRARY_SLUG = 'beitraege'; export async function profileFor(email) { try { const all = JSON.parse(await readFile(path.join(SITE_DIR, 'data', 'authors.json'), 'utf8')); return all[email] || null; } catch { return null; } } // Library-Beiträge als Threads in der Kategorie „Beiträge" spiegeln, damit man // auf jeden Beitrag einen Dialog starten kann. Idempotent (upsert über key). // // Gedrosselt: Reads rufen das bei jedem Forum-Aufruf, aber der eigentliche // Sync (DB + Filesystem-Walk + Upsert) läuft höchstens alle SYNC_TTL ms. // `force: true` (z.B. nach Publish) überspringt die Drosselung. const SYNC_TTL = 60_000; let lastSync = 0; export async function syncLibrary({ force = false } = {}) { if (!force && Date.now() - lastSync < SYNC_TTL) return; lastSync = Date.now(); const { data: forum } = await supabase .from('forums').select('id').eq('slug', LIBRARY_SLUG).single(); if (!forum) return; let entries = []; try { entries = (await listEntries()).filter((e) => e.kind === 'beitrag'); } catch { return; } if (!entries.length) return; const rows = entries.map((e) => ({ forum_id: forum.id, key: e.url, title: e.title, url: e.url, kind: 'library', })); // Nicht title überschreiben? Doch — Titel kann sich ändern. user_id/locked bleiben. await supabase.from('threads').upsert(rows, { onConflict: 'key', ignoreDuplicates: false }); } // Wortmeldungen pro Thread-Key aggregieren: { [key]: {count, last} }. // Aggregiert in Postgres (View comment_stats) statt alle Zeilen zu laden. async function commentStats() { const { data } = await supabase.from('comment_stats').select('thread,count,last'); const map = {}; for (const r of data || []) map[r.thread] = { count: r.count, last: r.last }; return map; } // Alle Foren mit Thread-/Wortmeldungs-Zahl und letzter Aktivität. export async function forumsWithCounts() { await syncLibrary(); const [{ data: forums }, { data: threads }, stats] = await Promise.all([ supabase.from('forums').select('*').order('sort'), supabase.from('threads').select('id,forum_id,key,deleted'), commentStats(), ]); const byForum = {}; for (const t of threads || []) { if (t.deleted) continue; const f = byForum[t.forum_id] || (byForum[t.forum_id] = { threads: 0, posts: 0, last: '' }); f.threads += 1; const s = stats[t.key]; if (s) { f.posts += s.count; if (s.last > f.last) f.last = s.last; } } return (forums || []).map((f) => ({ ...f, thread_count: byForum[f.id]?.threads || 0, post_count: byForum[f.id]?.posts || 0, last_at: byForum[f.id]?.last || null, })); } // Ein Forum samt seiner Threads (nach letzter Aktivität sortiert). export async function forumWithThreads(slug) { const { data: forum } = await supabase.from('forums').select('*').eq('slug', slug).single(); if (!forum) return null; if (forum.kind === 'library') await syncLibrary(); const [{ data: threads }, stats] = await Promise.all([ supabase.from('threads').select('*').eq('forum_id', forum.id).eq('deleted', false), commentStats(), ]); const list = (threads || []).map((t) => ({ key: t.key, title: t.title, url: t.url, kind: t.kind, locked: t.locked, author_name: t.author_name, created_at: t.created_at, count: stats[t.key]?.count || 0, last: stats[t.key]?.last || t.created_at, })).sort((a, b) => (b.last || '').localeCompare(a.last || '')); return { forum, threads: list }; } // Letzte Wortmeldungen über alles — für die linke Spalte der Übersicht. export async function recentComments(limit = 20) { await syncLibrary(); const [{ data: comments }, { data: threads }, { data: forums }] = await Promise.all([ supabase.from('comments').select('id,thread,author_name,body,created_at') .eq('deleted', false).order('created_at', { ascending: false }).limit(limit), supabase.from('threads').select('key,title,url,forum_id,kind'), supabase.from('forums').select('id,slug,name'), ]); const tByKey = {}; for (const t of threads || []) tByKey[t.key] = t; const fById = {}; for (const f of forums || []) fById[f.id] = f; return (comments || []).map((c) => { const t = tByKey[c.thread]; const f = t ? fById[t.forum_id] : null; return { id: c.id, body: c.body, author_name: c.author_name, created_at: c.created_at, thread_title: t?.title || c.thread, thread_url: t ? (t.kind === 'library' ? t.url : '/dialog/?thread=' + encodeURIComponent(c.thread)) : c.thread, forum_name: f?.name || null, forum_slug: f?.slug || null, }; }); } // Moderations-Überblick: letzte Wortmeldungen + alle Threads (zum Sperren/Löschen). export async function recentForModeration() { const [comments, { data: threads }, { data: forums }] = await Promise.all([ recentComments(50), supabase.from('threads').select('key,title,url,kind,forum_id,locked,deleted,author_name,created_at') .order('created_at', { ascending: false }), supabase.from('forums').select('id,name,slug'), ]); const fById = {}; for (const f of forums || []) fById[f.id] = f; const stats = await commentStats(); const t = (threads || []).map((x) => ({ key: x.key, title: x.title, url: x.url, kind: x.kind, locked: x.locked, deleted: x.deleted, author_name: x.author_name, created_at: x.created_at, forum_name: fById[x.forum_id]?.name || null, count: stats[x.key]?.count || 0, })); return { comments, threads: t }; } // Neuen Thread in einem Forum anlegen (+ erste Wortmeldung). Gibt den Thread zurück. export async function createThread({ forumId, forumSlug, title, body, user, email }) { let fid = forumId; if (!fid && forumSlug) { const { data: f } = await supabase.from('forums').select('id,kind').eq('slug', forumSlug).single(); if (!f) return { error: 'Forum unbekannt' }; if (f.kind === 'library') return { error: 'In Beiträge entstehen Threads automatisch' }; fid = f.id; } if (!fid) return { error: 'Forum nötig' }; if (!title || !title.trim()) return { error: 'Titel nötig' }; if (!body || !body.trim()) return { error: 'Erster Beitrag nötig' }; const prof = await profileFor(email); const name = prof?.name || email.split('@')[0]; const key = 't/' + randomUUID(); const { data: thread, error: e1 } = await supabase.from('threads').insert({ forum_id: fid, key, title: title.trim(), url: '/dialog/?thread=' + encodeURIComponent(key), kind: 'forum', author_name: name, user_id: user.id, }).select('*').single(); if (e1) return { error: e1.message }; const { error: e2 } = await supabase.from('comments').insert({ thread: key, user_id: user.id, author_name: name, author_avatar: prof?.avatar || null, body: body.trim(), }); if (e2) return { error: e2.message }; return { thread }; } // Ist ein Thread gesperrt? (verhindert neue Wortmeldungen) export async function threadLocked(key) { const { data } = await supabase.from('threads').select('locked').eq('key', key).single(); return !!data?.locked; } // Thread-Metadaten für die Thread-Ansicht (Titel, Forum-Rücklink, Lock-Status). export async function threadMeta(key) { const { data: t } = await supabase .from('threads').select('title,url,kind,locked,forum_id').eq('key', key).single(); if (!t) return null; let forum = null; if (t.forum_id) { const { data: f } = await supabase.from('forums').select('slug,name').eq('id', t.forum_id).single(); forum = f || null; } return { title: t.title, url: t.url, kind: t.kind, locked: !!t.locked, forum }; }