Foro: edición/borrado de posts, moderación de temas y búsqueda
Portado desde forum/db.py + forum/permissions.py de Django: - lib/forum-perm.ts: forumIsModerator (gmlevel >= FORUM_MOD_GMLEVEL) y canEditPost (autor o moderador). - Edición y borrado lógico de posts (autor o mod), respetando tema bloqueado, con validación de longitud mínima (plainLength). - Moderación de temas (solo mod): bloquear/desbloquear, fijar/no fijar, borrar. - Búsqueda de temas por título/contenido (searchTopics/countSearchTopics) con página /forum/search y buscador en el índice. - API: PATCH/DELETE /api/forum/post, POST /api/forum/moderate (401/403 correctos). - UI: PostActions, TopicModBar, ForumSearchBox; marca "editado" y "[Fijado]". - i18n: 22 claves nuevas en es/en. Verificado: build OK, /forum y /forum/search 200, APIs 401 sin sesión. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import type { RowDataPacket } from 'mysql2'
|
||||
import { db, DB } from './db'
|
||||
import type { SessionData } from './session'
|
||||
|
||||
/** Nivel GM mínimo para moderar el foro (equivale a FORUM_MOD_GMLEVEL en Django). */
|
||||
const MOD_GMLEVEL = Number(process.env.FORUM_MOD_GMLEVEL || 2)
|
||||
|
||||
/** Nivel GM máximo de una cuenta (acore_auth.account_access). 0 si no tiene. */
|
||||
export async function getGmlevel(accountId: number | undefined): Promise<number> {
|
||||
if (!accountId) return 0
|
||||
try {
|
||||
const [rows] = await db(DB.auth).query<RowDataPacket[]>(
|
||||
'SELECT MAX(gmlevel) AS lvl FROM account_access WHERE id = ?',
|
||||
[accountId],
|
||||
)
|
||||
return rows[0] && rows[0].lvl != null ? Number(rows[0].lvl) : 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** ¿El usuario puede moderar el foro? */
|
||||
export async function forumIsModerator(session: SessionData): Promise<boolean> {
|
||||
if (!session.accountId) return false
|
||||
return (await getGmlevel(session.accountId)) >= MOD_GMLEVEL
|
||||
}
|
||||
|
||||
/** Puede editar/borrar un post: su autor o un moderador. */
|
||||
export function canEditPost(
|
||||
session: SessionData,
|
||||
isMod: boolean,
|
||||
posterId: number | null | undefined,
|
||||
): boolean {
|
||||
if (!session.accountId) return false
|
||||
if (posterId != null && Number(posterId) === Number(session.accountId)) return true
|
||||
return isMod
|
||||
}
|
||||
@@ -26,3 +26,11 @@ export function cleanPostHtml(html: string): string {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Longitud del texto plano (sin etiquetas) — para validar longitud mínima. */
|
||||
export function plainLength(html: string): number {
|
||||
return sanitizeHtml(html || '', { allowedTags: [], allowedAttributes: {} })
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim().length
|
||||
}
|
||||
|
||||
@@ -38,6 +38,39 @@ export async function createPost(
|
||||
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> {
|
||||
const [rows] = await db(DB.web).query<RowDataPacket[]>(
|
||||
'SELECT id, topic_id, poster_id, text FROM forum_posts WHERE id = ? AND deleted = 0',
|
||||
[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
|
||||
}
|
||||
|
||||
/** 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])
|
||||
}
|
||||
|
||||
/** 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])
|
||||
}
|
||||
|
||||
/** Cambia una bandera del tema (sticky/locked/deleted). Solo moderación. */
|
||||
export async function setTopicFlag(
|
||||
topicId: number,
|
||||
field: 'sticky' | 'locked' | 'deleted',
|
||||
value: boolean,
|
||||
): Promise<void> {
|
||||
if (!['sticky', 'locked', 'deleted'].includes(field)) throw new Error('campo no permitido')
|
||||
await db(DB.web).query(`UPDATE forum_topics SET ${field} = ? WHERE id = ?`, [value ? 1 : 0, 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[]>(
|
||||
|
||||
+84
-4
@@ -33,8 +33,28 @@ export interface Topic {
|
||||
export interface Post {
|
||||
id: number
|
||||
poster: string
|
||||
poster_id: number | null
|
||||
text: string
|
||||
time: string | null
|
||||
edited: boolean
|
||||
}
|
||||
|
||||
export interface TopicFull {
|
||||
id: number
|
||||
name: string
|
||||
forum_id: number
|
||||
poster_id: number | null
|
||||
locked: boolean
|
||||
sticky: 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). */
|
||||
@@ -104,13 +124,23 @@ export async function getTopics(forumId: number): Promise<Topic[]> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTopic(id: number): Promise<{ name: string; locked: boolean } | null> {
|
||||
export async function getTopic(id: number): Promise<TopicFull | null> {
|
||||
try {
|
||||
const [rows] = await db(DB.web).query<RowDataPacket[]>(
|
||||
'SELECT name, locked FROM forum_topics WHERE id = ? AND deleted = 0',
|
||||
'SELECT id, name, forum_id, poster_id, locked, sticky FROM forum_topics WHERE id = ? AND deleted = 0',
|
||||
[id],
|
||||
)
|
||||
return rows[0] ? { name: rows[0].name, locked: rows[0].locked === 1 } : null
|
||||
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,
|
||||
}
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -119,14 +149,64 @@ export async function getTopic(id: number): Promise<{ name: string; locked: bool
|
||||
export async function getPosts(topicId: number): Promise<Post[]> {
|
||||
try {
|
||||
const [rows] = await db(DB.web).query<RowDataPacket[]>(
|
||||
'SELECT id, poster, text, time FROM forum_posts WHERE topic_id = ? AND deleted = 0 ORDER BY time ASC, id ASC',
|
||||
'SELECT id, poster, poster_id, text, time, created_at, updated_at FROM forum_posts WHERE topic_id = ? AND deleted = 0 ORDER BY time ASC, id ASC',
|
||||
[topicId],
|
||||
)
|
||||
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,
|
||||
),
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 []
|
||||
|
||||
Reference in New Issue
Block a user