bba1df32cb
git-subtree-dir: cms/core git-subtree-split: 59b9e2075ac48cf017db38009d54dbe56a98fffc
183 lines
7.8 KiB
JavaScript
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 };
|
|
}
|