3a0b9dd5ee
Los nombres de categorías, foros, descripciones, clases e idiomas venían del seed solo en inglés. Ahora se muestran en el idioma de la página. Como es un conjunto fijo y conocido, se resuelve con un mapa de traducción en código (lib/forum-i18n.ts, inglés→español) que se aplica al vuelo, sin tocar el esquema (el inglés sigue siendo el valor guardado). Un texto no mapeado (p. ej. un foro nuevo creado desde el admin) se muestra tal cual. getForumIndex/getForum/getForumPath/getTopicPath aceptan el locale y localizan name/description/category (nunca el título de un tema, que es contenido de usuario). Las páginas pasan su locale. Verificado en producción: /es/forum en español (Noticias, Caballero de la Muerte, Foros por idioma) y /en/forum en inglés. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
443 lines
14 KiB
TypeScript
443 lines
14 KiB
TypeScript
import type { RowDataPacket } from 'mysql2'
|
|
import { db, DB } from './db'
|
|
import { localizeForum } from './forum-i18n'
|
|
|
|
// 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
|
|
avatar: string
|
|
}
|
|
|
|
// Avatar por clase (FusionCMS) del personaje de más nivel de la cuenta. Solo clases
|
|
// jugables en WotLK 3.4.3 (1..9 y 11). Sin personaje → guerrero por defecto.
|
|
const AVATAR_DIR = '/forum/avatars'
|
|
const AVATAR_CLASSES = new Set([1, 2, 3, 4, 5, 6, 7, 8, 9, 11])
|
|
function avatarForClass(classId: number | undefined): string {
|
|
const c = classId && AVATAR_CLASSES.has(classId) ? classId : 1
|
|
return `${AVATAR_DIR}/class-${c}.gif`
|
|
}
|
|
|
|
/** 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(locale = 'es'): 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: localizeForum(f.name, locale),
|
|
description: localizeForum(f.description, locale),
|
|
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: localizeForum(c.name, locale), forums: byCat.get(c.id) ?? [] }))
|
|
.filter((c) => c.forums.length > 0)
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
export async function getForum(id: number, locale = 'es'): 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: localizeForum(r.name, locale),
|
|
description: localizeForum(r.description, locale),
|
|
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, locale = 'es'): 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: localizeForum(r.category, locale),
|
|
forum_id: r.forum_id,
|
|
forum: localizeForum(r.forum, locale),
|
|
forum_description: localizeForum(r.forum_description, locale),
|
|
}
|
|
: null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export async function getTopicPath(topicId: number, locale = 'es'): 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: localizeForum(r.category, locale),
|
|
forum_id: r.forum_id,
|
|
forum: localizeForum(r.forum, locale),
|
|
forum_description: localizeForum(r.forum_description, locale),
|
|
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(',')
|
|
|
|
// Clase del personaje de más nivel de cada cuenta (para el avatar). Se tolera que
|
|
// acore_characters no exista o esté vacía: el avatar cae a guerrero por defecto.
|
|
const classByAccount = new Map<number, number>()
|
|
try {
|
|
const [chars] = await db(DB.characters).query<RowDataPacket[]>(
|
|
`SELECT account, class FROM characters WHERE account IN (${placeholders}) ORDER BY level DESC, guid ASC`,
|
|
unique,
|
|
)
|
|
for (const c of chars) {
|
|
const acc = Number(c.account)
|
|
if (!classByAccount.has(acc)) classByAccount.set(acc, Number(c.class))
|
|
}
|
|
} catch {
|
|
/* sin personajes: avatar por defecto */
|
|
}
|
|
|
|
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,
|
|
avatar: avatarForClass(classByAccount.get(Number(a.id))),
|
|
})
|
|
}
|
|
} 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,
|
|
avatar: avatarForClass(classByAccount.get(id)),
|
|
})
|
|
}
|
|
}
|
|
return map
|
|
}
|