Files
NightSpire/web-next/lib/forum.ts
T
Inna af707ad98c Foro: reemplazar el foro anterior por el port del foro estilo FusionCMS
Se sustituye por completo el foro anterior de Next (que estaba vacío: 0 temas, 0
posts) por una adaptación del foro PHP de FusionCMS que tenía el usuario, con su
estructura, sus imágenes y su estética, llevadas a Next.

BASE DE DATOS (acore_web), esquema idéntico al original (sql/forum_fusion.sql):
  forum_categories(id, order, name)
  forum_forums(id, category, order, name, description, icon, colortitle, type)
  forum_topics(id, forum, name, sticky, locked, deleted, created)
  forum_posts(id, topic, poster, text, time, deleted)
Se hizo DROP de las tablas antiguas (forums, forum_categories, forum_topics,
forum_posts) y CREATE de estas, con el seed original (News/Reports/General,
foros con icono, clases con color, subforos por idioma con bandera). Única
diferencia con el dump: forum_topics.created, que faltaba y usan las vistas, y
el recorte del salto de línea final en los nombres de clase.

Los temas no guardan autor ni última actividad: se derivan del primer/último post,
como en el original. `poster` es el AccountID de acore_auth; el nombre se resuelve
al render (username sin el sufijo #N de Battle.net). El texto se guarda como HTML
saneado con nh3 (no BBCode) para encajar con el editor.

EDITOR: TinyMCE community self-hosted (licenseKey gpl), servido desde /tinymce
(scripts/copy-tinymce.mjs en postinstall; public/tinymce en .gitignore por ser
artefacto). El HTML se sanea SIEMPRE en el servidor.

PÁGINAS (app/[locale]/forum): índice con los 3 tipos de foro (fila normal, tarjeta
de clase tipo 1, tarjeta de idioma tipo 2), subforo, tema, crear y editar. La
lógica de forum.js (crear/responder/editar/moderar) se portó a fetch + los avisos
del sitio, sin jQuery/SweetAlert, y la moderación pasa por POST (no GET).

CSS: forum.css adaptado al tema (app/forum.css), rutas de imagen a /forum, dorado
alineado al acento del sitio (#d79602), y se aportan .nice_button/.main-wide/
.pagination que no estaban en theme.css.

ADMIN: lib/admin-forum.ts, sus rutas y AdminForumManager reescritos al esquema
nuevo (categoría, icono, color, tipo, orden; sin _en/visibility).

Se eliminan /forum/search y /forum/user (no existen en el foro nuevo) y los
componentes que quedaban huérfanos (ForumSearchBox, PostActions, TopicModBar,
NewTopicForm). i18n ES/EN completado (next-intl).

Verificado en producción: el índice renderiza en es/en con categorías, clases,
banderas e iconos; los assets y TinyMCE sirven 200; crear tema y responder guardan
las filas con el esquema correcto y las páginas las muestran (autor resuelto, HTML
saneado). Datos de prueba borrados: el foro arranca vacío.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 12:22:08 +00:00

399 lines
13 KiB
TypeScript

import type { RowDataPacket } from 'mysql2'
import { db, DB } from './db'
// Foro estilo FusionCMS (ver sql/forum_fusion.sql). Esquema:
// forum_categories(id, order, name)
// forum_forums(id, category, order, name, description, icon, colortitle, type)
// forum_topics(id, forum, name, sticky, locked, deleted, created)
// forum_posts(id, topic, poster, text, time, deleted)
// `poster` es el AccountID de acore_auth (entero); el nombre se resuelve aparte
// con resolvePosters(). Los temas no guardan autor: se deriva del primer/último post.
// Tipos de foro (columna `type`):
// 0 = fila normal (icono de forum_icons)
// 1 = disciplina de clase (icon = clase CSS, colortitle = color)
// 2 = subforo por idioma (icon = ruta a una bandera)
export interface ForumRow {
id: number
category: number
order: number
name: string
description: string
icon: string | null
colortitle: string | null
type: number
topic_count: number
post_count: number
last_topic_id: number | null
last_topic_name: string | null
last_poster: number | null
last_time: string | null
}
export interface Category {
id: number
name: string
forums: ForumRow[]
}
export interface TopicRow {
id: number
name: string
sticky: boolean
locked: boolean
deleted: boolean
created: string | null
poster: number | null
last_poster: number | null
time_updated: string | null
}
export interface PostRow {
id: number
poster: number
text: string
time: string | null
deleted: boolean
}
export interface ForumMeta {
id: number
name: string
description: string
icon: string | null
colortitle: string | null
type: number
}
export interface ForumPath {
category_id: number
category: string
forum_id: number
forum: string
forum_description: string
}
export interface TopicPath extends ForumPath {
topic_id: number
topic: string
}
export interface PosterInfo {
id: number
name: string
gmlevel: number
isStaff: boolean
joindate: string | null
postCount: number
}
/** Nivel GM mínimo para considerarse «staff» en el foro (badge y color). */
const STAFF_GMLEVEL = Number(process.env.FORUM_MOD_GMLEVEL || 2)
/** Índice del foro: categorías ordenadas, cada una con sus foros y agregados. */
export async function getForumIndex(): Promise<Category[]> {
try {
const [cats] = await db(DB.web).query<RowDataPacket[]>(
'SELECT id, name FROM forum_categories ORDER BY `order`, name',
)
const [forums] = await db(DB.web).query<RowDataPacket[]>(
`SELECT f.id, f.category, f.\`order\`, f.name, f.description, f.icon, f.colortitle, f.type,
(SELECT COUNT(*) FROM forum_topics t WHERE t.forum = f.id AND t.deleted = 0) AS topic_count,
(SELECT COUNT(*) FROM forum_posts p JOIN forum_topics t ON p.topic = t.id
WHERE t.forum = f.id AND p.deleted = 0 AND t.deleted = 0) AS post_count,
(SELECT t.id FROM forum_posts p JOIN forum_topics t ON p.topic = t.id
WHERE t.forum = f.id AND p.deleted = 0 AND t.deleted = 0
ORDER BY p.time DESC, p.id DESC LIMIT 1) AS last_topic_id,
(SELECT t.name FROM forum_posts p JOIN forum_topics t ON p.topic = t.id
WHERE t.forum = f.id AND p.deleted = 0 AND t.deleted = 0
ORDER BY p.time DESC, p.id DESC LIMIT 1) AS last_topic_name,
(SELECT p.poster FROM forum_posts p JOIN forum_topics t ON p.topic = t.id
WHERE t.forum = f.id AND p.deleted = 0 AND t.deleted = 0
ORDER BY p.time DESC, p.id DESC LIMIT 1) AS last_poster,
(SELECT p.time FROM forum_posts p JOIN forum_topics t ON p.topic = t.id
WHERE t.forum = f.id AND p.deleted = 0 AND t.deleted = 0
ORDER BY p.time DESC, p.id DESC LIMIT 1) AS last_time
FROM forum_forums f
ORDER BY f.\`order\`, f.id`,
)
const byCat = new Map<number, ForumRow[]>()
for (const f of forums) {
const row: ForumRow = {
id: f.id,
category: f.category,
order: f.order,
name: f.name,
description: f.description,
icon: f.icon,
colortitle: f.colortitle,
type: f.type,
topic_count: Number(f.topic_count ?? 0),
post_count: Number(f.post_count ?? 0),
last_topic_id: f.last_topic_id ?? null,
last_topic_name: f.last_topic_name ?? null,
last_poster: f.last_poster ?? null,
last_time: f.last_time ? new Date(f.last_time).toISOString() : null,
}
if (!byCat.has(row.category)) byCat.set(row.category, [])
byCat.get(row.category)!.push(row)
}
return cats
.map((c) => ({ id: c.id, name: c.name, forums: byCat.get(c.id) ?? [] }))
.filter((c) => c.forums.length > 0)
} catch {
return []
}
}
export async function getForum(id: number): Promise<ForumMeta | null> {
try {
const [rows] = await db(DB.web).query<RowDataPacket[]>(
'SELECT id, name, description, icon, colortitle, type FROM forum_forums WHERE id = ?',
[id],
)
const r = rows[0]
return r
? { id: r.id, name: r.name, description: r.description, icon: r.icon, colortitle: r.colortitle, type: r.type }
: null
} catch {
return null
}
}
export async function forumExists(id: number): Promise<boolean> {
if (!id) return false
try {
const [rows] = await db(DB.web).query<RowDataPacket[]>('SELECT id FROM forum_forums WHERE id = ?', [id])
return Boolean(rows[0])
} catch {
return false
}
}
export async function getForumPath(forumId: number): Promise<ForumPath | null> {
try {
const [rows] = await db(DB.web).query<RowDataPacket[]>(
`SELECT c.id AS category_id, c.name AS category, f.id AS forum_id, f.name AS forum, f.description AS forum_description
FROM forum_categories c JOIN forum_forums f ON f.category = c.id
WHERE f.id = ? LIMIT 1`,
[forumId],
)
const r = rows[0]
return r
? {
category_id: r.category_id,
category: r.category,
forum_id: r.forum_id,
forum: r.forum,
forum_description: r.forum_description,
}
: null
} catch {
return null
}
}
export async function getTopicPath(topicId: number): Promise<TopicPath | null> {
try {
const [rows] = await db(DB.web).query<RowDataPacket[]>(
`SELECT c.id AS category_id, c.name AS category, f.id AS forum_id, f.name AS forum,
f.description AS forum_description, t.id AS topic_id, t.name AS topic
FROM forum_categories c
JOIN forum_forums f ON f.category = c.id
JOIN forum_topics t ON t.forum = f.id
WHERE t.id = ? LIMIT 1`,
[topicId],
)
const r = rows[0]
return r
? {
category_id: r.category_id,
category: r.category,
forum_id: r.forum_id,
forum: r.forum,
forum_description: r.forum_description,
topic_id: r.topic_id,
topic: r.topic,
}
: null
} catch {
return null
}
}
export async function countTopics(forumId: number): Promise<number> {
try {
const [rows] = await db(DB.web).query<RowDataPacket[]>(
'SELECT COUNT(*) AS n FROM forum_topics WHERE forum = ? AND deleted = 0',
[forumId],
)
return Number(rows[0]?.n ?? 0)
} catch {
return 0
}
}
/** Temas de un foro. Autor = primer post; última actividad = último post no borrado. */
export async function getTopics(
forumId: number,
offset = 0,
limit = 10,
includeDeleted = false,
): Promise<TopicRow[]> {
try {
const del = includeDeleted ? '' : 'AND t.deleted = 0'
const [rows] = await db(DB.web).query<RowDataPacket[]>(
`SELECT t.id, t.name, t.sticky, t.locked, t.deleted, t.created,
(SELECT p.poster FROM forum_posts p WHERE p.topic = t.id ORDER BY p.time ASC, p.id ASC LIMIT 1) AS poster,
(SELECT p.poster FROM forum_posts p WHERE p.topic = t.id AND p.deleted = 0 ORDER BY p.time DESC, p.id DESC LIMIT 1) AS last_poster,
(SELECT p.time FROM forum_posts p WHERE p.topic = t.id AND p.deleted = 0 ORDER BY p.time DESC, p.id DESC LIMIT 1) AS time_updated
FROM forum_topics t
WHERE t.forum = ? ${del}
ORDER BY t.sticky DESC, COALESCE((SELECT MAX(p.time) FROM forum_posts p WHERE p.topic = t.id AND p.deleted = 0), t.created) DESC, t.id DESC
LIMIT ? OFFSET ?`,
[forumId, limit, offset],
)
return rows.map((r) => ({
id: r.id,
name: r.name,
sticky: r.sticky === 1,
locked: r.locked === 1,
deleted: r.deleted === 1,
created: r.created ? new Date(r.created).toISOString() : null,
poster: r.poster ?? null,
last_poster: r.last_poster ?? null,
time_updated: r.time_updated ? new Date(r.time_updated).toISOString() : null,
}))
} catch {
return []
}
}
export async function getTopic(id: number, includeDeleted = false): Promise<{
id: number
forum: number
name: string
sticky: boolean
locked: boolean
deleted: boolean
created: string | null
} | null> {
try {
const where = includeDeleted ? 'id = ?' : 'id = ? AND deleted = 0'
const [rows] = await db(DB.web).query<RowDataPacket[]>(
`SELECT id, forum, name, sticky, locked, deleted, created FROM forum_topics WHERE ${where}`,
[id],
)
const r = rows[0]
return r
? {
id: r.id,
forum: r.forum,
name: r.name,
sticky: r.sticky === 1,
locked: r.locked === 1,
deleted: r.deleted === 1,
created: r.created ? new Date(r.created).toISOString() : null,
}
: null
} catch {
return null
}
}
export async function countPosts(topicId: number, includeDeleted = false): Promise<number> {
try {
const del = includeDeleted ? '' : 'AND deleted = 0'
const [rows] = await db(DB.web).query<RowDataPacket[]>(
`SELECT COUNT(*) AS n FROM forum_posts WHERE topic = ? ${del}`,
[topicId],
)
return Number(rows[0]?.n ?? 0)
} catch {
return 0
}
}
export async function getPosts(
topicId: number,
offset = 0,
limit = 5,
includeDeleted = false,
): Promise<PostRow[]> {
try {
const del = includeDeleted ? '' : 'AND deleted = 0'
const [rows] = await db(DB.web).query<RowDataPacket[]>(
`SELECT id, poster, text, time, deleted FROM forum_posts WHERE topic = ? ${del} ORDER BY time ASC, id ASC LIMIT ? OFFSET ?`,
[topicId, limit, offset],
)
return rows.map((r) => ({
id: r.id,
poster: r.poster,
text: r.text,
time: r.time ? new Date(r.time).toISOString() : null,
deleted: r.deleted === 1,
}))
} catch {
return []
}
}
/** Nº de posts (no borrados) de un usuario, para el «Posts: N» del perfil lateral. */
export async function getUserPostCount(posterId: number): Promise<number> {
try {
const [rows] = await db(DB.web).query<RowDataPacket[]>(
'SELECT COUNT(*) AS n FROM forum_posts WHERE poster = ? AND deleted = 0',
[posterId],
)
return Number(rows[0]?.n ?? 0)
} catch {
return 0
}
}
/**
* Resuelve un conjunto de AccountID a nombre + rango + antigüedad en una sola pasada.
* El nombre es el username de la cuenta de juego (acore_auth.account), sin el sufijo
* «#N» de Battle.net para que se lea mejor. Devuelve un Map por id.
*/
export async function resolvePosters(ids: number[]): Promise<Map<number, PosterInfo>> {
const map = new Map<number, PosterInfo>()
const unique = [...new Set(ids.filter((n) => Number.isFinite(n) && n > 0))]
if (unique.length === 0) return map
const placeholders = unique.map(() => '?').join(',')
try {
const [accs] = await db(DB.auth).query<RowDataPacket[]>(
`SELECT id, username, joindate FROM account WHERE id IN (${placeholders})`,
unique,
)
const [access] = await db(DB.auth).query<RowDataPacket[]>(
`SELECT AccountID, MAX(SecurityLevel) AS lvl FROM account_access WHERE AccountID IN (${placeholders}) GROUP BY AccountID`,
unique,
)
const gm = new Map<number, number>()
for (const a of access) gm.set(Number(a.AccountID), Number(a.lvl ?? 0))
for (const a of accs) {
const gmlevel = gm.get(Number(a.id)) ?? 0
map.set(Number(a.id), {
id: Number(a.id),
name: String(a.username || '').replace(/#\d+$/, '') || `#${a.id}`,
gmlevel,
isStaff: gmlevel >= STAFF_GMLEVEL,
joindate: a.joindate ? new Date(a.joindate).toISOString() : null,
postCount: 0,
})
}
} catch {
/* acore_auth no disponible: se devuelven placeholders abajo */
}
// Cualquier id no resuelto (cuenta borrada) recibe un placeholder.
for (const id of unique) {
if (!map.has(id)) {
map.set(id, { id, name: `#${id}`, gmlevel: 0, isStaff: false, joindate: null, postCount: 0 })
}
}
return map
}