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 { try { const [cats] = await db(DB.web).query( 'SELECT id, name FROM forum_categories ORDER BY `order`, name', ) const [forums] = await db(DB.web).query( `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() 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 { try { const [rows] = await db(DB.web).query( '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 { if (!id) return false try { const [rows] = await db(DB.web).query('SELECT id FROM forum_forums WHERE id = ?', [id]) return Boolean(rows[0]) } catch { return false } } export async function getForumPath(forumId: number, locale = 'es'): Promise { try { const [rows] = await db(DB.web).query( `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 { try { const [rows] = await db(DB.web).query( `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 { try { const [rows] = await db(DB.web).query( '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 { try { const del = includeDeleted ? '' : 'AND t.deleted = 0' const [rows] = await db(DB.web).query( `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( `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 { try { const del = includeDeleted ? '' : 'AND deleted = 0' const [rows] = await db(DB.web).query( `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 { try { const del = includeDeleted ? '' : 'AND deleted = 0' const [rows] = await db(DB.web).query( `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 { try { const [rows] = await db(DB.web).query( '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> { const map = new Map() 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() try { const [chars] = await db(DB.characters).query( `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( `SELECT id, username, joindate FROM account WHERE id IN (${placeholders})`, unique, ) const [access] = await db(DB.auth).query( `SELECT AccountID, MAX(SecurityLevel) AS lvl FROM account_access WHERE AccountID IN (${placeholders}) GROUP BY AccountID`, unique, ) const gm = new Map() 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 }