cc6c76859c
forum_categories.name_en y forums.name_en/description_en (sql/add_forum_en.sql, aplicado en prod y traducidos los existentes: Comunidad/Anuncios/Discusión general). getForumIndex(locale) y getForum(id, locale) usan EN si existe, si no caen al español. Las páginas del foro pasan el locale. Labels (Temas/Mensajes) ya estaban traducidos. Verificado: /en/forum en inglés, /es/forum en español. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
327 lines
10 KiB
TypeScript
327 lines
10 KiB
TypeScript
import type { RowDataPacket } from 'mysql2'
|
|
import { db, DB } from './db'
|
|
|
|
export interface ForumInfo {
|
|
id: number
|
|
category_id: number
|
|
name: string
|
|
description: string
|
|
icon: string | null
|
|
topic_count: number
|
|
post_count: number
|
|
last_topic_id: number | null
|
|
last_topic_name: string | null
|
|
last_topic_poster: string | null
|
|
}
|
|
|
|
export interface Category {
|
|
id: number
|
|
name: string
|
|
forums: ForumInfo[]
|
|
}
|
|
|
|
export interface Topic {
|
|
id: number
|
|
name: string
|
|
poster: string
|
|
created: string | null
|
|
sticky: boolean
|
|
locked: boolean
|
|
views: number
|
|
}
|
|
|
|
export interface Post {
|
|
id: number
|
|
poster: string
|
|
poster_id: number | null
|
|
text: string
|
|
time: string | null
|
|
edited: boolean
|
|
deleted: boolean
|
|
}
|
|
|
|
export interface TopicFull {
|
|
id: number
|
|
name: string
|
|
forum_id: number
|
|
poster_id: number | null
|
|
locked: boolean
|
|
sticky: boolean
|
|
deleted: boolean
|
|
}
|
|
|
|
export interface SearchResult {
|
|
id: number
|
|
name: string
|
|
poster: string
|
|
forum_id: number
|
|
forum_name: string
|
|
updated_at: string | null
|
|
}
|
|
|
|
/** Í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'
|
|
try {
|
|
const [cats] = await db(DB.web).query<RowDataPacket[]>(
|
|
'SELECT id, name, name_en FROM forum_categories ORDER BY `order`, id',
|
|
)
|
|
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
|
|
ORDER BY f.\`order\`, f.id`,
|
|
)
|
|
const byCat = new Map<number, ForumInfo[]>()
|
|
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)
|
|
}
|
|
return cats
|
|
.map((c) => ({ id: c.id, name: en && c.name_en ? c.name_en : 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 }[]> {
|
|
try {
|
|
const [rows] = await db(DB.web).query<RowDataPacket[]>(
|
|
'SELECT id, name FROM forums WHERE visibility = 1 AND deleted = 0 ORDER BY `order`, id',
|
|
)
|
|
return rows.map((r) => ({ id: r.id, name: r.name }))
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
export async function getForum(id: number, locale = 'es'): Promise<{ name: string; description: string } | null> {
|
|
const en = locale === 'en'
|
|
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],
|
|
)
|
|
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,
|
|
}
|
|
} 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_id = ? AND deleted = 0',
|
|
[forumId],
|
|
)
|
|
return Number(rows[0]?.n ?? 0)
|
|
} catch {
|
|
return 0
|
|
}
|
|
}
|
|
|
|
export async function getTopics(forumId: number, offset = 0, limit = 20): Promise<Topic[]> {
|
|
try {
|
|
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 ?',
|
|
[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,
|
|
}))
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
export async function getTopic(id: number, includeDeleted = false): Promise<TopicFull | 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}`,
|
|
[id],
|
|
)
|
|
const r = rows[0]
|
|
return r
|
|
? {
|
|
id: r.id,
|
|
name: r.name,
|
|
forum_id: r.forum_id,
|
|
poster_id: r.poster_id ?? null,
|
|
locked: r.locked === 1,
|
|
sticky: r.sticky === 1,
|
|
deleted: r.deleted === 1,
|
|
}
|
|
: 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_id = ? ${del}`,
|
|
[topicId],
|
|
)
|
|
return Number(rows[0]?.n ?? 0)
|
|
} catch {
|
|
return 0
|
|
}
|
|
}
|
|
|
|
export async function getPosts(topicId: number, offset = 0, limit = 15, includeDeleted = false): Promise<Post[]> {
|
|
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 ?`,
|
|
[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 {
|
|
return []
|
|
}
|
|
}
|
|
|
|
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}%`
|
|
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],
|
|
)
|
|
return Number(rows[0]?.n ?? 0)
|
|
} catch {
|
|
return 0
|
|
}
|
|
}
|
|
|
|
/** 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}%`
|
|
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],
|
|
)
|
|
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,
|
|
}))
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|