Files
kgva/api/src/plugins/dialog/dialog-store.js
T
karim bba1df32cb Squashed 'cms/core/' content from commit 59b9e20
git-subtree-dir: cms/core
git-subtree-split: 59b9e2075ac48cf017db38009d54dbe56a98fffc
2026-06-30 01:46:20 +02:00

183 lines
7.8 KiB
JavaScript

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 };
}