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>
This commit is contained in:
+48
-54
@@ -1,72 +1,75 @@
|
||||
import type { RowDataPacket, ResultSetHeader } from 'mysql2'
|
||||
import { db, DB } from './db'
|
||||
|
||||
// Gestión del foro para el admin, sobre el esquema FusionCMS (ver sql/forum_fusion.sql):
|
||||
// forum_categories(id, order, name)
|
||||
// forum_forums(id, category, order, name, description, icon, colortitle, type)
|
||||
// No hay columnas _en / visibility / deleted: las categorías y foros son datos del
|
||||
// seed, editables aquí. Borrar un foro es un DELETE real (se rechaza si tiene temas).
|
||||
|
||||
export interface AdminCategory {
|
||||
id: number
|
||||
name: string
|
||||
name_en: string
|
||||
order: number
|
||||
}
|
||||
|
||||
export interface AdminForum {
|
||||
id: number
|
||||
category_id: number
|
||||
category: number
|
||||
name: string
|
||||
name_en: string
|
||||
description: string
|
||||
description_en: string
|
||||
icon: string | null
|
||||
colortitle: string | null
|
||||
type: number
|
||||
order: number
|
||||
visibility: number
|
||||
}
|
||||
|
||||
/** Categorías con sus foros (incluye ocultos y sin borrar), para el admin. */
|
||||
export async function listCategoriesWithForums(): Promise<
|
||||
{ category: AdminCategory; forums: AdminForum[] }[]
|
||||
> {
|
||||
const [cats] = await db(DB.web).query<RowDataPacket[]>(
|
||||
'SELECT id, name, name_en, `order` FROM forum_categories ORDER BY `order`, id',
|
||||
'SELECT id, name, `order` FROM forum_categories ORDER BY `order`, id',
|
||||
)
|
||||
const [forums] = await db(DB.web).query<RowDataPacket[]>(
|
||||
'SELECT id, category_id, name, name_en, description, description_en, `order`, visibility FROM forums WHERE deleted = 0 ORDER BY `order`, id',
|
||||
'SELECT id, category, name, description, icon, colortitle, type, `order` FROM forum_forums ORDER BY `order`, id',
|
||||
)
|
||||
return cats.map((c) => ({
|
||||
category: { id: c.id, name: c.name, name_en: c.name_en || '', order: c.order },
|
||||
category: { id: c.id, name: c.name, order: c.order },
|
||||
forums: forums
|
||||
.filter((f) => f.category_id === c.id)
|
||||
.filter((f) => f.category === c.id)
|
||||
.map((f) => ({
|
||||
id: f.id,
|
||||
category_id: f.category_id,
|
||||
category: f.category,
|
||||
name: f.name,
|
||||
name_en: f.name_en || '',
|
||||
description: f.description,
|
||||
description_en: f.description_en || '',
|
||||
icon: f.icon,
|
||||
colortitle: f.colortitle,
|
||||
type: f.type,
|
||||
order: f.order,
|
||||
visibility: f.visibility,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function createCategory(name: string, nameEn: string | null, order: number): Promise<number> {
|
||||
export async function createCategory(name: string, order: number): Promise<number> {
|
||||
const [res] = await db(DB.web).query<ResultSetHeader>(
|
||||
'INSERT INTO forum_categories (name, name_en, `order`, created_at) VALUES (?, ?, ?, NOW())',
|
||||
[name, nameEn || null, order],
|
||||
'INSERT INTO forum_categories (name, `order`) VALUES (?, ?)',
|
||||
[name.slice(0, 45), order],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
export async function updateCategory(id: number, name: string, nameEn: string | null, order: number): Promise<void> {
|
||||
await db(DB.web).query('UPDATE forum_categories SET name = ?, name_en = ?, `order` = ? WHERE id = ?', [
|
||||
name,
|
||||
nameEn || null,
|
||||
export async function updateCategory(id: number, name: string, order: number): Promise<void> {
|
||||
await db(DB.web).query('UPDATE forum_categories SET name = ?, `order` = ? WHERE id = ?', [
|
||||
name.slice(0, 45),
|
||||
order,
|
||||
id,
|
||||
])
|
||||
}
|
||||
|
||||
/** Borra una categoría solo si no tiene foros (no borrados). */
|
||||
/** Borra una categoría solo si no tiene foros. */
|
||||
export async function deleteCategory(id: number): Promise<boolean> {
|
||||
const [f] = await db(DB.web).query<RowDataPacket[]>(
|
||||
'SELECT COUNT(*) AS n FROM forums WHERE category_id = ? AND deleted = 0',
|
||||
'SELECT COUNT(*) AS n FROM forum_forums WHERE category = ?',
|
||||
[id],
|
||||
)
|
||||
if (Number(f[0]?.n ?? 0) > 0) return false
|
||||
@@ -74,44 +77,35 @@ export async function deleteCategory(id: number): Promise<boolean> {
|
||||
return true
|
||||
}
|
||||
|
||||
export async function createForum(
|
||||
categoryId: number,
|
||||
name: string,
|
||||
nameEn: string | null,
|
||||
description: string,
|
||||
descriptionEn: string | null,
|
||||
order: number,
|
||||
visibility: number,
|
||||
): Promise<number> {
|
||||
export interface ForumInput {
|
||||
category: number
|
||||
name: string
|
||||
description: string
|
||||
icon: string | null
|
||||
colortitle: string | null
|
||||
type: number
|
||||
order: number
|
||||
}
|
||||
|
||||
export async function createForum(f: ForumInput): Promise<number> {
|
||||
const [res] = await db(DB.web).query<ResultSetHeader>(
|
||||
'INSERT INTO forums (category_id, name, name_en, description, description_en, `order`, type, visibility, deleted, created_at, updated_at) ' +
|
||||
'VALUES (?, ?, ?, ?, ?, ?, 0, ?, 0, NOW(), NOW())',
|
||||
[categoryId, name, nameEn || null, description, descriptionEn || null, order, visibility ? 1 : 0],
|
||||
'INSERT INTO forum_forums (category, name, description, icon, colortitle, type, `order`) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[f.category, f.name.slice(0, 45), f.description, f.icon || null, f.colortitle || null, f.type, f.order],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
export async function updateForum(
|
||||
id: number,
|
||||
name: string,
|
||||
nameEn: string | null,
|
||||
description: string,
|
||||
descriptionEn: string | null,
|
||||
order: number,
|
||||
visibility: number,
|
||||
): Promise<void> {
|
||||
export async function updateForum(id: number, f: ForumInput): Promise<void> {
|
||||
await db(DB.web).query(
|
||||
'UPDATE forums SET name = ?, name_en = ?, description = ?, description_en = ?, `order` = ?, visibility = ?, updated_at = NOW() WHERE id = ?',
|
||||
[name, nameEn || null, description, descriptionEn || null, order, visibility ? 1 : 0, id],
|
||||
'UPDATE forum_forums SET category = ?, name = ?, description = ?, icon = ?, colortitle = ?, type = ?, `order` = ? WHERE id = ?',
|
||||
[f.category, f.name.slice(0, 45), f.description, f.icon || null, f.colortitle || null, f.type, f.order, id],
|
||||
)
|
||||
}
|
||||
|
||||
/** Borrado lógico de un foro (deleted = 1). */
|
||||
export async function deleteForum(id: number): Promise<void> {
|
||||
await db(DB.web).query('UPDATE forums SET deleted = 1 WHERE id = ?', [id])
|
||||
}
|
||||
|
||||
/** Cambia la visibilidad de un foro. */
|
||||
export async function setForumVisibility(id: number, visible: boolean): Promise<void> {
|
||||
await db(DB.web).query('UPDATE forums SET visibility = ? WHERE id = ?', [visible ? 1 : 0, id])
|
||||
/** Borra un foro solo si no tiene temas. */
|
||||
export async function deleteForum(id: number): Promise<boolean> {
|
||||
const [t] = await db(DB.web).query<RowDataPacket[]>('SELECT COUNT(*) AS n FROM forum_topics WHERE forum = ?', [id])
|
||||
if (Number(t[0]?.n ?? 0) > 0) return false
|
||||
await db(DB.web).query('DELETE FROM forum_forums WHERE id = ?', [id])
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// Formato de fechas del foro, sensible al idioma (es/en). Reemplaza el date()
|
||||
// de PHP del original por Intl, coherente con el locale de la página.
|
||||
export function formatForumDate(iso: string | null, locale: string): string {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
return new Intl.DateTimeFormat(locale === 'en' ? 'en-US' : 'es-ES', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(d)
|
||||
}
|
||||
+21
-36
@@ -2,61 +2,60 @@ import type { ResultSetHeader, RowDataPacket } from 'mysql2'
|
||||
import { db, DB } from './db'
|
||||
import { cleanPostHtml } from './forum-sanitize'
|
||||
|
||||
// Escritura del foro (esquema FusionCMS, ver lib/forum.ts). `poster` es el AccountID.
|
||||
// El texto se guarda como HTML ya saneado con nh3 (cleanPostHtml), no como BBCode:
|
||||
// es la adaptación a nuestro editor TinyMCE. El esquema no cambia (columna `text`).
|
||||
|
||||
// El título vive en forum_topics.name, un varchar(45) del esquema original. Se recorta
|
||||
// a 45 para no chocar con el modo estricto de MySQL; la validación de longitud real la
|
||||
// hace la API antes de llegar aquí.
|
||||
const TITLE_MAX = 45
|
||||
|
||||
export async function createTopic(
|
||||
forumId: number,
|
||||
name: string,
|
||||
poster: string,
|
||||
posterId: number,
|
||||
text: string,
|
||||
): Promise<number> {
|
||||
const clean = cleanPostHtml(text)
|
||||
const [res] = await db(DB.web).query<ResultSetHeader>(
|
||||
'INSERT INTO forum_topics (forum_id, name, poster, poster_id, created, created_at, updated_at, locked, sticky, deleted) ' +
|
||||
'VALUES (?, ?, ?, ?, NOW(), NOW(), NOW(), 0, 0, 0)',
|
||||
[forumId, name, poster, posterId],
|
||||
'INSERT INTO forum_topics (forum, name, sticky, locked, deleted, created) VALUES (?, ?, 0, 0, 0, NOW())',
|
||||
[forumId, name.slice(0, TITLE_MAX)],
|
||||
)
|
||||
const topicId = res.insertId
|
||||
await db(DB.web).query(
|
||||
'INSERT INTO forum_posts (topic_id, poster, poster_id, text, time, created_at, deleted) VALUES (?, ?, ?, ?, NOW(), NOW(), 0)',
|
||||
[topicId, poster, posterId, clean],
|
||||
'INSERT INTO forum_posts (topic, poster, text, time, deleted) VALUES (?, ?, ?, NOW(), 0)',
|
||||
[topicId, posterId, clean],
|
||||
)
|
||||
return topicId
|
||||
}
|
||||
|
||||
export async function createPost(
|
||||
topicId: number,
|
||||
poster: string,
|
||||
posterId: number,
|
||||
text: string,
|
||||
): Promise<number> {
|
||||
export async function createPost(topicId: number, posterId: number, text: string): Promise<number> {
|
||||
const clean = cleanPostHtml(text)
|
||||
const [res] = await db(DB.web).query<ResultSetHeader>(
|
||||
'INSERT INTO forum_posts (topic_id, poster, poster_id, text, time, created_at, deleted) VALUES (?, ?, ?, ?, NOW(), NOW(), 0)',
|
||||
[topicId, poster, posterId, clean],
|
||||
'INSERT INTO forum_posts (topic, poster, text, time, deleted) VALUES (?, ?, ?, NOW(), 0)',
|
||||
[topicId, posterId, clean],
|
||||
)
|
||||
await db(DB.web).query('UPDATE forum_topics SET updated_at = NOW() WHERE id = ?', [topicId])
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
/** Datos mínimos de un post para comprobar permisos (autor + tema). */
|
||||
export async function getPost(
|
||||
postId: number,
|
||||
): Promise<{ id: number; topic_id: number; poster_id: number | null; text: string } | null> {
|
||||
): Promise<{ id: number; topic: number; poster: number; text: string; deleted: boolean } | null> {
|
||||
const [rows] = await db(DB.web).query<RowDataPacket[]>(
|
||||
'SELECT id, topic_id, poster_id, text FROM forum_posts WHERE id = ? AND deleted = 0',
|
||||
'SELECT id, topic, poster, text, deleted FROM forum_posts WHERE id = ?',
|
||||
[postId],
|
||||
)
|
||||
const r = rows[0]
|
||||
return r ? { id: r.id, topic_id: r.topic_id, poster_id: r.poster_id ?? null, text: r.text } : null
|
||||
return r ? { id: r.id, topic: r.topic, poster: r.poster, text: r.text, deleted: r.deleted === 1 } : null
|
||||
}
|
||||
|
||||
/** Edita el texto de un post (ya saneado por el llamador). */
|
||||
export async function updatePost(postId: number, text: string): Promise<void> {
|
||||
const clean = cleanPostHtml(text)
|
||||
await db(DB.web).query('UPDATE forum_posts SET text = ?, updated_at = NOW() WHERE id = ?', [clean, postId])
|
||||
await db(DB.web).query('UPDATE forum_posts SET text = ? WHERE id = ?', [clean, postId])
|
||||
}
|
||||
|
||||
/** Marca/desmarca un post como borrado (borrado lógico). */
|
||||
export async function setPostDeleted(postId: number, deleted: boolean): Promise<void> {
|
||||
await db(DB.web).query('UPDATE forum_posts SET deleted = ? WHERE id = ?', [deleted ? 1 : 0, postId])
|
||||
}
|
||||
@@ -71,21 +70,7 @@ export async function setTopicFlag(
|
||||
await db(DB.web).query(`UPDATE forum_topics SET ${field} = ? WHERE id = ?`, [value ? 1 : 0, topicId])
|
||||
}
|
||||
|
||||
/** Mueve un tema a otro foro (marca moved=1). Solo moderación. */
|
||||
export async function moveTopic(topicId: number, newForumId: number): Promise<void> {
|
||||
await db(DB.web).query('UPDATE forum_topics SET forum_id = ?, moved = 1 WHERE id = ?', [newForumId, topicId])
|
||||
}
|
||||
|
||||
/** Comprueba que un foro existe y es visible (para crear tema). */
|
||||
export async function forumIsPostable(forumId: number): Promise<boolean> {
|
||||
const [rows] = await db(DB.web).query<RowDataPacket[]>(
|
||||
'SELECT id FROM forums WHERE id = ? AND visibility = 1 AND deleted = 0',
|
||||
[forumId],
|
||||
)
|
||||
return Boolean(rows[0])
|
||||
}
|
||||
|
||||
/** Comprueba que un tema existe y no está cerrado (para responder). */
|
||||
/** ¿El tema existe, no está borrado y admite respuestas (no cerrado)? */
|
||||
export async function topicIsReplyable(topicId: number): Promise<boolean> {
|
||||
const [rows] = await db(DB.web).query<RowDataPacket[]>(
|
||||
'SELECT locked FROM forum_topics WHERE id = ? AND deleted = 0',
|
||||
|
||||
+242
-170
@@ -1,126 +1,221 @@
|
||||
import type { RowDataPacket } from 'mysql2'
|
||||
import { db, DB } from './db'
|
||||
|
||||
export interface ForumInfo {
|
||||
// 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_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_topic_poster: string | null
|
||||
last_poster: number | null
|
||||
last_time: string | null
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: number
|
||||
name: string
|
||||
forums: ForumInfo[]
|
||||
forums: ForumRow[]
|
||||
}
|
||||
|
||||
export interface Topic {
|
||||
export interface TopicRow {
|
||||
id: number
|
||||
name: string
|
||||
poster: string
|
||||
created: string | null
|
||||
sticky: boolean
|
||||
locked: boolean
|
||||
views: number
|
||||
deleted: boolean
|
||||
created: string | null
|
||||
poster: number | null
|
||||
last_poster: number | null
|
||||
time_updated: string | null
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
export interface PostRow {
|
||||
id: number
|
||||
poster: string
|
||||
poster_id: number | null
|
||||
poster: number
|
||||
text: string
|
||||
time: string | null
|
||||
edited: boolean
|
||||
deleted: boolean
|
||||
}
|
||||
|
||||
export interface TopicFull {
|
||||
export interface ForumMeta {
|
||||
id: number
|
||||
name: string
|
||||
forum_id: number
|
||||
poster_id: number | null
|
||||
locked: boolean
|
||||
sticky: boolean
|
||||
deleted: boolean
|
||||
description: string
|
||||
icon: string | null
|
||||
colortitle: string | null
|
||||
type: number
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
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
|
||||
poster: string
|
||||
forum_id: number
|
||||
forum_name: string
|
||||
updated_at: string | null
|
||||
gmlevel: number
|
||||
isStaff: boolean
|
||||
joindate: string | null
|
||||
postCount: number
|
||||
}
|
||||
|
||||
/** Índice del foro: categorías con sus foros visibles (+ contadores y último tema). */
|
||||
export async function getForumIndex(locale = 'es'): Promise<Category[]> {
|
||||
const en = locale === 'en'
|
||||
/** 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, name_en FROM forum_categories ORDER BY `order`, id',
|
||||
'SELECT id, name FROM forum_categories ORDER BY `order`, name',
|
||||
)
|
||||
const [forums] = await db(DB.web).query<RowDataPacket[]>(
|
||||
`SELECT f.id, f.category_id, f.name, f.name_en, f.description, f.description_en, f.icon,
|
||||
(SELECT COUNT(*) FROM forum_topics t WHERE t.forum_id = f.id AND t.deleted = 0) AS topic_count,
|
||||
(SELECT COUNT(*) FROM forum_posts p JOIN forum_topics t ON p.topic_id = t.id
|
||||
WHERE t.forum_id = f.id AND p.deleted = 0 AND t.deleted = 0) AS post_count,
|
||||
(SELECT t2.id FROM forum_topics t2 WHERE t2.forum_id = f.id AND t2.deleted = 0
|
||||
ORDER BY t2.updated_at DESC, t2.id DESC LIMIT 1) AS last_topic_id,
|
||||
(SELECT t3.name FROM forum_topics t3 WHERE t3.forum_id = f.id AND t3.deleted = 0
|
||||
ORDER BY t3.updated_at DESC, t3.id DESC LIMIT 1) AS last_topic_name,
|
||||
(SELECT t4.poster FROM forum_topics t4 WHERE t4.forum_id = f.id AND t4.deleted = 0
|
||||
ORDER BY t4.updated_at DESC, t4.id DESC LIMIT 1) AS last_topic_poster
|
||||
FROM forums f
|
||||
WHERE f.visibility = 1 AND f.deleted = 0
|
||||
`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, ForumInfo[]>()
|
||||
const byCat = new Map<number, ForumRow[]>()
|
||||
for (const f of forums) {
|
||||
const info = f as unknown as ForumInfo & { name_en?: string | null; description_en?: string | null }
|
||||
info.name = en && info.name_en ? info.name_en : info.name
|
||||
info.description = en && info.description_en ? info.description_en : info.description
|
||||
if (!byCat.has(info.category_id)) byCat.set(info.category_id, [])
|
||||
byCat.get(info.category_id)!.push(info)
|
||||
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: en && c.name_en ? c.name_en : c.name, forums: byCat.get(c.id) ?? [] }))
|
||||
.map((c) => ({ id: c.id, name: c.name, forums: byCat.get(c.id) ?? [] }))
|
||||
.filter((c) => c.forums.length > 0)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** Lista de foros visibles para el desplegable de «mover tema». */
|
||||
export async function listForumsForMove(): Promise<{ id: number; name: string }[]> {
|
||||
export async function getForum(id: number): Promise<ForumMeta | null> {
|
||||
try {
|
||||
const [rows] = await db(DB.web).query<RowDataPacket[]>(
|
||||
'SELECT id, name FROM forums WHERE visibility = 1 AND deleted = 0 ORDER BY `order`, id',
|
||||
'SELECT id, name, description, icon, colortitle, type FROM forum_forums WHERE id = ?',
|
||||
[id],
|
||||
)
|
||||
return rows.map((r) => ({ id: r.id, name: r.name }))
|
||||
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 []
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function getForum(id: number, locale = 'es'): Promise<{ name: string; description: string } | null> {
|
||||
const en = locale === 'en'
|
||||
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 name, name_en, description, description_en FROM forums WHERE id = ? AND visibility = 1 AND deleted = 0',
|
||||
[id],
|
||||
`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],
|
||||
)
|
||||
if (!rows[0]) return null
|
||||
return {
|
||||
name: en && rows[0].name_en ? rows[0].name_en : rows[0].name,
|
||||
description: en && rows[0].description_en ? rows[0].description_en : rows[0].description,
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -129,7 +224,7 @@ export async function getForum(id: number, locale = 'es'): Promise<{ name: strin
|
||||
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_id = ? AND deleted = 0',
|
||||
'SELECT COUNT(*) AS n FROM forum_topics WHERE forum = ? AND deleted = 0',
|
||||
[forumId],
|
||||
)
|
||||
return Number(rows[0]?.n ?? 0)
|
||||
@@ -138,43 +233,67 @@ export async function countTopics(forumId: number): Promise<number> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTopics(forumId: number, offset = 0, limit = 20): Promise<Topic[]> {
|
||||
/** 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 id, name, poster, created, sticky, locked, views FROM forum_topics WHERE forum_id = ? AND deleted = 0 ORDER BY sticky DESC, updated_at DESC, id DESC LIMIT ? OFFSET ?',
|
||||
`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,
|
||||
poster: r.poster,
|
||||
created: r.created ? new Date(r.created).toISOString() : null,
|
||||
sticky: r.sticky === 1,
|
||||
locked: r.locked === 1,
|
||||
views: r.views ?? 0,
|
||||
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<TopicFull | null> {
|
||||
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, name, forum_id, poster_id, locked, sticky, deleted FROM forum_topics WHERE ${where}`,
|
||||
`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,
|
||||
forum_id: r.forum_id,
|
||||
poster_id: r.poster_id ?? null,
|
||||
locked: r.locked === 1,
|
||||
sticky: r.sticky === 1,
|
||||
locked: r.locked === 1,
|
||||
deleted: r.deleted === 1,
|
||||
created: r.created ? new Date(r.created).toISOString() : null,
|
||||
}
|
||||
: null
|
||||
} catch {
|
||||
@@ -186,7 +305,7 @@ export async function countPosts(topicId: number, includeDeleted = false): Promi
|
||||
try {
|
||||
const del = includeDeleted ? '' : 'AND deleted = 0'
|
||||
const [rows] = await db(DB.web).query<RowDataPacket[]>(
|
||||
`SELECT COUNT(*) AS n FROM forum_posts WHERE topic_id = ? ${del}`,
|
||||
`SELECT COUNT(*) AS n FROM forum_posts WHERE topic = ? ${del}`,
|
||||
[topicId],
|
||||
)
|
||||
return Number(rows[0]?.n ?? 0)
|
||||
@@ -195,23 +314,23 @@ export async function countPosts(topicId: number, includeDeleted = false): Promi
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPosts(topicId: number, offset = 0, limit = 15, includeDeleted = false): Promise<Post[]> {
|
||||
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, poster_id, text, time, created_at, updated_at, deleted FROM forum_posts WHERE topic_id = ? ${del} ORDER BY time ASC, id ASC LIMIT ? OFFSET ?`,
|
||||
`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,
|
||||
poster_id: r.poster_id ?? null,
|
||||
text: r.text,
|
||||
time: r.time ? new Date(r.time).toISOString() : null,
|
||||
// editado si updated_at es posterior (más de 2s) al alta del post
|
||||
edited: Boolean(
|
||||
r.updated_at && r.created_at && new Date(r.updated_at).getTime() - new Date(r.created_at).getTime() > 2000,
|
||||
),
|
||||
deleted: r.deleted === 1,
|
||||
}))
|
||||
} catch {
|
||||
@@ -219,78 +338,12 @@ export async function getPosts(topicId: number, offset = 0, limit = 15, includeD
|
||||
}
|
||||
}
|
||||
|
||||
export interface ForumProfile {
|
||||
username: string
|
||||
postCount: number
|
||||
topicCount: number
|
||||
joindate: string | null
|
||||
gmlevel: number
|
||||
status: 'admin' | 'gm' | null
|
||||
recentTopics: { id: number; name: string; forum_id: number }[]
|
||||
}
|
||||
|
||||
/** Perfil público de un usuario del foro (contadores + antigüedad + rango). */
|
||||
export async function getForumProfile(username: string): Promise<ForumProfile> {
|
||||
const profile: ForumProfile = {
|
||||
username,
|
||||
postCount: 0,
|
||||
topicCount: 0,
|
||||
joindate: null,
|
||||
gmlevel: 0,
|
||||
status: null,
|
||||
recentTopics: [],
|
||||
}
|
||||
try {
|
||||
const [pc] = await db(DB.web).query<RowDataPacket[]>(
|
||||
'SELECT COUNT(*) AS n FROM forum_posts WHERE poster = ? AND deleted = 0',
|
||||
[username],
|
||||
)
|
||||
profile.postCount = Number(pc[0]?.n ?? 0)
|
||||
const [tc] = await db(DB.web).query<RowDataPacket[]>(
|
||||
'SELECT COUNT(*) AS n FROM forum_topics WHERE poster = ? AND deleted = 0',
|
||||
[username],
|
||||
)
|
||||
profile.topicCount = Number(tc[0]?.n ?? 0)
|
||||
const [rt] = await db(DB.web).query<RowDataPacket[]>(
|
||||
'SELECT id, name, forum_id FROM forum_topics WHERE poster = ? AND deleted = 0 ORDER BY updated_at DESC, id DESC LIMIT 5',
|
||||
[username],
|
||||
)
|
||||
profile.recentTopics = rt.map((r) => ({ id: r.id, name: r.name, forum_id: r.forum_id }))
|
||||
} catch {
|
||||
/* tablas de foro no pobladas */
|
||||
}
|
||||
try {
|
||||
const [acc] = await db(DB.auth).query<RowDataPacket[]>(
|
||||
'SELECT id, joindate FROM account WHERE username = ?',
|
||||
[username],
|
||||
)
|
||||
if (acc[0]) {
|
||||
profile.joindate = acc[0].joindate ? new Date(acc[0].joindate).toISOString() : null
|
||||
const [ga] = await db(DB.auth).query<RowDataPacket[]>(
|
||||
'SELECT MAX(SecurityLevel) AS lvl FROM account_access WHERE AccountID = ?',
|
||||
[acc[0].id],
|
||||
)
|
||||
profile.gmlevel = ga[0]?.lvl != null ? Number(ga[0].lvl) : 0
|
||||
}
|
||||
} catch {
|
||||
/* account_access puede no existir */
|
||||
}
|
||||
if (profile.gmlevel >= 16) profile.status = 'admin'
|
||||
else if (profile.gmlevel >= 1) profile.status = 'gm'
|
||||
return profile
|
||||
}
|
||||
|
||||
/** Nº de temas que coinciden con la búsqueda (foros visibles). */
|
||||
export async function countSearchTopics(query: string): Promise<number> {
|
||||
const like = `%${query}%`
|
||||
/** 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(DISTINCT t.id) AS n
|
||||
FROM forum_topics t
|
||||
JOIN forums f ON f.id = t.forum_id AND f.visibility = 1 AND f.deleted = 0
|
||||
LEFT JOIN forum_posts p ON p.topic_id = t.id AND p.deleted = 0
|
||||
WHERE t.deleted = 0 AND (t.name LIKE ? OR p.text LIKE ?)`,
|
||||
[like, like],
|
||||
'SELECT COUNT(*) AS n FROM forum_posts WHERE poster = ? AND deleted = 0',
|
||||
[posterId],
|
||||
)
|
||||
return Number(rows[0]?.n ?? 0)
|
||||
} catch {
|
||||
@@ -298,29 +351,48 @@ export async function countSearchTopics(query: string): Promise<number> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Busca temas por título o contenido de sus posts (solo foros visibles). */
|
||||
export async function searchTopics(query: string, offset = 0, limit = 20): Promise<SearchResult[]> {
|
||||
const like = `%${query}%`
|
||||
/**
|
||||
* 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 [rows] = await db(DB.web).query<RowDataPacket[]>(
|
||||
`SELECT DISTINCT t.id, t.name, t.poster, t.forum_id, t.updated_at, f.name AS forum_name
|
||||
FROM forum_topics t
|
||||
JOIN forums f ON f.id = t.forum_id AND f.visibility = 1 AND f.deleted = 0
|
||||
LEFT JOIN forum_posts p ON p.topic_id = t.id AND p.deleted = 0
|
||||
WHERE t.deleted = 0 AND (t.name LIKE ? OR p.text LIKE ?)
|
||||
ORDER BY t.updated_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[like, like, limit, offset],
|
||||
const [accs] = await db(DB.auth).query<RowDataPacket[]>(
|
||||
`SELECT id, username, joindate FROM account WHERE id IN (${placeholders})`,
|
||||
unique,
|
||||
)
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
poster: r.poster,
|
||||
forum_id: r.forum_id,
|
||||
forum_name: r.forum_name,
|
||||
updated_at: r.updated_at ? new Date(r.updated_at).toISOString() : null,
|
||||
}))
|
||||
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 {
|
||||
return []
|
||||
/* 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user