diff --git a/api/schema.sql b/api/schema.sql index d0008678..45a30fa6 100644 --- a/api/schema.sql +++ b/api/schema.sql @@ -652,3 +652,80 @@ CREATE INDEX IF NOT EXISTS idx_game_map_tile_entities_map ON game_map_tile_entities(map_num, status); CREATE INDEX IF NOT EXISTS idx_game_uploaded_graphics_created_at ON game_uploaded_graphics(created_at DESC); + +-- ═══════════════════════════════════════════════════════════════════════════ +-- Etapa 5: propuestas y moderacion de mapas de usuario +-- ═══════════════════════════════════════════════════════════════════════════ + +-- Un mapa de usuario y su lugar en el flujo de moderacion. +-- +-- El contenido del mapa sigue viviendo en game_map_tile_overrides y +-- game_map_tile_entities: aca solo se guarda quien es el autor, el nombre y en +-- que punto del flujo esta. Mientras el estado no sea 'published' los +-- borradores no los ve ningun jugador, porque el endpoint publico de overrides +-- solo devuelve lo publicado. +-- +-- Un mapa tiene una sola propuesta vigente: reenviar despues de un rechazo +-- actualiza esta misma fila en vez de acumular propuestas muertas. +CREATE TABLE IF NOT EXISTS game_map_proposals ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + map_num INTEGER NOT NULL UNIQUE CHECK (map_num > 0), + owner_account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + entry_x INTEGER NOT NULL DEFAULT 1 CHECK (entry_x > 0), + entry_y INTEGER NOT NULL DEFAULT 1 CHECK (entry_y > 0), + status TEXT NOT NULL DEFAULT 'draft' + CHECK (status IN ('draft', 'proposed', 'in_review', 'published', 'rejected')), + rejection_reason TEXT, + auto_check_report JSONB NOT NULL DEFAULT '{}'::jsonb, + reviewed_by_account_id UUID REFERENCES accounts(id) ON DELETE SET NULL, + submitted_at TIMESTAMPTZ, + reviewed_at TIMESTAMPTZ, + -- Se setea al publicar y se limpia al rechazar o despublicar. Permite + -- despublicar un mapa reportado (que vuelve a la cola como 'in_review') + -- sin confundirlo con uno que nunca llego al mundo. + published_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Instalaciones que hayan aplicado una version previa del schema. +ALTER TABLE game_map_proposals + ADD COLUMN IF NOT EXISTS published_at TIMESTAMPTZ; + +CREATE INDEX IF NOT EXISTS idx_game_map_proposals_status + ON game_map_proposals(status, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_game_map_proposals_owner + ON game_map_proposals(owner_account_id, updated_at DESC); + +-- Reportes de jugadores sobre un mapa ya publicado. +-- +-- Cualquier jugador puede reportar. El reporte devuelve el mapa a la cola +-- (status 'in_review') pero no lo despublica solo: bajar el mapa del mundo es +-- una decision de un moderador. El autor original no pierde los reportes +-- cuando la propuesta se resuelve, porque quedan como historial. +CREATE TABLE IF NOT EXISTS game_map_reports ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + proposal_id UUID NOT NULL REFERENCES game_map_proposals(id) ON DELETE CASCADE, + reporter_account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + reason TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open' + CHECK (status IN ('open', 'resolved', 'dismissed')), + resolved_by_account_id UUID REFERENCES accounts(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + resolved_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS idx_game_map_reports_proposal + ON game_map_reports(proposal_id, created_at DESC); + +-- Quienes pueden moderar mapas de usuario. +-- +-- La cola no depende de una sola persona: un admin de game-data suma o quita +-- moderadores y el flujo escala sin tocar codigo. +CREATE TABLE IF NOT EXISTS game_map_moderators ( + account_id UUID PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE, + added_by_account_id UUID REFERENCES accounts(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/api/src/repositories/mapModeration.ts b/api/src/repositories/mapModeration.ts new file mode 100644 index 00000000..d8546302 --- /dev/null +++ b/api/src/repositories/mapModeration.ts @@ -0,0 +1,974 @@ +import { z } from "zod"; +import pool from "../db"; +import { sanitizeName } from "../lib/text"; +import { + MAP_SIZE, + listMapOverrides, + listMapTileEntities, + publishMap, + unpublishMap, + UPLOADED_GRAPHIC_INDEX_START, + type MapTileEntity, + type MapTileOverride, +} from "./worldBuilder"; + +/** + * Etapa 5: propuesta y moderacion de mapas de usuario. + * + * El contenido de un mapa de usuario vive en game_map_tile_overrides / + * game_map_tile_entities. Esta capa agrega el flujo de estados que decide + * cuando ese contenido llega al mundo: + * + * draft -> proposed -> in_review -> published + * \-> rejected (con motivo) + * + * Publicar es lo unico que hace visible el mapa para el resto de los jugadores, + * asi que mientras una propuesta no este aprobada sus borradores no salen del + * editor. Los chequeos automaticos corren al enviar, antes de que la propuesta + * llegue a la cola de un humano. + */ + +export const MAP_PROPOSAL_STATUSES = [ + "draft", + "proposed", + "in_review", + "published", + "rejected", +] as const; + +export type MapProposalStatus = (typeof MAP_PROPOSAL_STATUSES)[number]; + +export type MapAutoCheckIssue = { + code: string; + message: string; +}; + +export type MapAutoCheckReport = { + ok: boolean; + checkedAt: string; + issues: MapAutoCheckIssue[]; +}; + +export type MapProposal = { + id: string; + mapNum: number; + ownerAccountId: string; + name: string; + description: string; + entry: { x: number; y: number }; + status: MapProposalStatus; + rejectionReason: string | null; + autoCheckReport: MapAutoCheckReport | null; + reviewedByAccountId: string | null; + submittedAt: string | null; + reviewedAt: string | null; + publishedAt: string | null; + createdAt: string; + updatedAt: string; +}; + +export type MapAssetGraphic = { + grhIndex: number; + width: number; + height: number; + byteSize: number; + createdAt: string; + url: string; +}; + +export type MapModerationDetail = { + proposal: MapProposal; + overrides: MapTileOverride[]; + entities: MapTileEntity[]; + graphics: MapAssetGraphic[]; +}; + +type MapProposalRow = { + id: string; + map_num: number; + owner_account_id: string; + name: string; + description: string; + entry_x: number; + entry_y: number; + status: string; + rejection_reason: string | null; + auto_check_report: unknown; + reviewed_by_account_id: string | null; + submitted_at: Date | null; + reviewed_at: Date | null; + published_at: Date | null; + created_at: Date; + updated_at: Date; +}; + +/** + * Cuota maxima de tiles editados por mapa. + * + * Un mapa tiene MAP_SIZE x MAP_SIZE posiciones y el editor admite hasta 4 capas + * por posicion (ver tilePaintSchema), asi que este es el techo fisico. Es una + * red de seguridad contra datos corruptos, no un limite de diseno. + */ +export const MAX_MAP_TILE_OVERRIDES = MAP_SIZE * MAP_SIZE * 4; + +/** Cuota maxima de objetos/NPCs colocados por mapa. */ +export const MAX_MAP_ENTITIES = 500; + +/** Cuota maxima de graficos subidos distintos que puede referenciar un mapa. */ +export const MAX_MAP_UPLOADED_GRAPHICS = 500; + +/** + * Primera pasada de palabras prohibidas. Es una lista corta y explicita: la + * idea es que la moderacion automatica ataje lo evidente y que el revisor vea + * el resto. Ampliarla no requiere tocar la logica. + */ +export const DEFAULT_FORBIDDEN_WORDS = [ + "puta", + "puto", + "mierda", + "porno", + "nazi", + "hitler", + "violacion", + "prostituta", +]; + +function normalizeForbiddenText(value: string): string { + return ` ${sanitizeName(value).replace(/[^a-z0-9]+/g, " ").trim()} `; +} + +/** Devuelve una alerta por cada palabra prohibida encontrada en los textos. */ +export function findForbiddenWords( + texts: string[], + forbidden: string[] = DEFAULT_FORBIDDEN_WORDS, +): MapAutoCheckIssue[] { + const issues: MapAutoCheckIssue[] = []; + const seen = new Set(); + + for (const text of texts) { + const normalized = normalizeForbiddenText(text); + + for (const word of forbidden) { + const needle = normalizeForbiddenText(word); + + if (needle.length <= 2 || !normalized.includes(needle)) { + continue; + } + + const key = `forbidden_word:${word}`; + + if (seen.has(key)) { + continue; + } + + seen.add(key); + issues.push({ + code: "forbidden_word", + message: `El texto contiene una palabra prohibida: "${word}".`, + }); + } + } + + return issues; +} + +export type ReachabilityInput = { + width: number; + height: number; + entry: { x: number; y: number }; + isBlocked: (x: number, y: number) => boolean; +}; + +/** + * Detecta regiones aisladas y trampas sin salida. + * + * Recorre en anchura los tiles transitables alcanzables desde la entrada. Si + * queda algun tile transitable afuera del recorrido, esta encerrado por tiles + * bloqueados: nadie puede llegar ahi y, si hay personajes adentro, tampoco + * pueden salir. + */ +export function checkReachability( + input: ReachabilityInput, +): MapAutoCheckIssue[] { + const { width, height, entry } = input; + const inBounds = (x: number, y: number): boolean => + x >= 1 && x <= width && y >= 1 && y <= height; + + if (!inBounds(entry.x, entry.y)) { + return [ + { + code: "entry_out_of_bounds", + message: "El punto de entrada esta fuera de los limites del mapa.", + }, + ]; + } + + if (input.isBlocked(entry.x, entry.y)) { + return [ + { + code: "entry_blocked", + message: "El punto de entrada esta sobre un tile bloqueado.", + }, + ]; + } + + const key = (x: number, y: number): number => y * (width + 1) + x; + const visited = new Set([key(entry.x, entry.y)]); + const queue: Array<{ x: number; y: number }> = [{ x: entry.x, y: entry.y }]; + const neighbors = [ + [1, 0], + [-1, 0], + [0, 1], + [0, -1], + ] as const; + + let walkable = 0; + + for (let y = 1; y <= height; y += 1) { + for (let x = 1; x <= width; x += 1) { + if (!input.isBlocked(x, y)) { + walkable += 1; + } + } + } + + while (queue.length > 0) { + const current = queue.pop() as { x: number; y: number }; + + for (const [dx, dy] of neighbors) { + const nx = current.x + dx; + const ny = current.y + dy; + + if (!inBounds(nx, ny) || input.isBlocked(nx, ny)) { + continue; + } + + const cellKey = key(nx, ny); + + if (visited.has(cellKey)) { + continue; + } + + visited.add(cellKey); + queue.push({ x: nx, y: ny }); + } + } + + const issues: MapAutoCheckIssue[] = []; + + if (walkable <= 1) { + issues.push({ + code: "no_path_from_entry", + message: + "No hay ningun camino transitable desde el punto de entrada.", + }); + } + + if (visited.size < walkable) { + issues.push({ + code: "isolated_region", + message: `Hay ${walkable - visited.size} tile(s) a los que no se puede llegar desde la entrada (zonas inalcanzables o trampas sin salida).`, + }); + } + + return issues; +} + +export type MapQuotaInput = { + overrideCount: number; + entityCount: number; + uploadedGraphicCount: number; +}; + +/** Compara el contenido del mapa contra las cuotas permitidas. */ +export function checkQuotas(input: MapQuotaInput): MapAutoCheckIssue[] { + const issues: MapAutoCheckIssue[] = []; + + if (input.overrideCount > MAX_MAP_TILE_OVERRIDES) { + issues.push({ + code: "too_many_tiles", + message: `El mapa pinta ${input.overrideCount} tiles y el maximo es ${MAX_MAP_TILE_OVERRIDES}.`, + }); + } + + if (input.entityCount > MAX_MAP_ENTITIES) { + issues.push({ + code: "too_many_entities", + message: `El mapa coloca ${input.entityCount} objetos/NPCs y el maximo es ${MAX_MAP_ENTITIES}.`, + }); + } + + if (input.uploadedGraphicCount > MAX_MAP_UPLOADED_GRAPHICS) { + issues.push({ + code: "too_many_uploaded_graphics", + message: `El mapa usa ${input.uploadedGraphicCount} graficos subidos distintos y el maximo es ${MAX_MAP_UPLOADED_GRAPHICS}.`, + }); + } + + return issues; +} + +export type MapAutoCheckInput = { + name: string; + description: string; + texts?: string[]; + entry: { x: number; y: number }; + blockedTiles: Array<{ x: number; y: number }>; + overrideCount: number; + entityCount: number; + uploadedGraphicCount: number; + width?: number; + height?: number; + now?: Date; +}; + +/** + * Corre todos los chequeos automatizables de un mapa. + * + * Es una funcion pura (salvo el timestamp) para poder cubrirla con tests sin + * base de datos y para reutilizarla desde cualquier punto del flujo. + */ +export function runAutomaticChecks( + input: MapAutoCheckInput, +): MapAutoCheckReport { + const width = input.width ?? MAP_SIZE; + const height = input.height ?? MAP_SIZE; + const blocked = new Set( + input.blockedTiles.map((tile) => `${tile.x},${tile.y}`), + ); + + const issues: MapAutoCheckIssue[] = [ + ...findForbiddenWords([ + input.name, + input.description, + ...(input.texts ?? []), + ]), + ...checkReachability({ + width, + height, + entry: input.entry, + isBlocked: (x, y) => blocked.has(`${x},${y}`), + }), + ...checkQuotas({ + overrideCount: input.overrideCount, + entityCount: input.entityCount, + uploadedGraphicCount: input.uploadedGraphicCount, + }), + ]; + + return { + ok: issues.length === 0, + checkedAt: (input.now ?? new Date()).toISOString(), + issues, + }; +} + +export const proposalDraftSchema = z.object({ + mapNum: z.coerce.number().int().positive(), + name: z.string().trim().min(3).max(60), + description: z.string().trim().max(500).optional().default(""), + entryX: z.coerce.number().int().min(1).max(MAP_SIZE).optional(), + entryY: z.coerce.number().int().min(1).max(MAP_SIZE).optional(), +}); + +export const proposalReasonSchema = z.object({ + reason: z.string().trim().min(3).max(500), +}); + +function normalizeAutoCheckReport(value: unknown): MapAutoCheckReport | null { + if (!value || typeof value !== "object") { + return null; + } + + const report = value as Partial; + + if (!Array.isArray(report.issues)) { + return null; + } + + return { + ok: Boolean(report.ok), + checkedAt: + typeof report.checkedAt === "string" ? report.checkedAt : "", + issues: report.issues.filter( + (issue): issue is MapAutoCheckIssue => + Boolean(issue) && + typeof (issue as MapAutoCheckIssue).code === "string" && + typeof (issue as MapAutoCheckIssue).message === "string", + ), + }; +} + +function toMapProposal(row: MapProposalRow): MapProposal { + return { + id: row.id, + mapNum: row.map_num, + ownerAccountId: row.owner_account_id, + name: row.name, + description: row.description, + entry: { x: row.entry_x, y: row.entry_y }, + status: row.status as MapProposalStatus, + rejectionReason: row.rejection_reason, + autoCheckReport: normalizeAutoCheckReport(row.auto_check_report), + reviewedByAccountId: row.reviewed_by_account_id, + submittedAt: row.submitted_at ? row.submitted_at.toISOString() : null, + reviewedAt: row.reviewed_at ? row.reviewed_at.toISOString() : null, + publishedAt: row.published_at ? row.published_at.toISOString() : null, + createdAt: row.created_at.toISOString(), + updatedAt: row.updated_at.toISOString(), + }; +} + +async function getProposalRow(id: string): Promise { + const result = await pool.query( + `SELECT * FROM game_map_proposals WHERE id = $1 LIMIT 1`, + [id], + ); + + return result.rows[0] ?? null; +} + +async function getProposalRowByMap( + mapNum: number, +): Promise { + const result = await pool.query( + `SELECT * FROM game_map_proposals WHERE map_num = $1 LIMIT 1`, + [mapNum], + ); + + return result.rows[0] ?? null; +} + +async function countMapUploadedGraphics(mapNum: number): Promise { + const result = await pool.query<{ count: string }>( + `SELECT COUNT(DISTINCT grh_index)::text AS count + FROM game_map_tile_overrides + WHERE map_num = $1 AND grh_index >= $2`, + [mapNum, UPLOADED_GRAPHIC_INDEX_START], + ); + + return Number(result.rows[0]?.count ?? 0); +} + +async function listMapNpcNames(entities: MapTileEntity[]): Promise { + const ids = [ + ...new Set( + entities + .filter((entity) => entity.kind === "npc") + .map((entity) => entity.entityId), + ), + ]; + + if (ids.length === 0) { + return []; + } + + const result = await pool.query<{ name: string }>( + `SELECT name FROM game_npcs WHERE id = ANY($1::int[])`, + [ids], + ); + + return result.rows.map((row) => row.name); +} + +async function listMapGraphics( + overrides: MapTileOverride[], +): Promise { + const indices = [ + ...new Set( + overrides + .map((tile) => tile.grhIndex) + .filter( + (grhIndex): grhIndex is number => + grhIndex != null && + grhIndex >= UPLOADED_GRAPHIC_INDEX_START, + ), + ), + ]; + + if (indices.length === 0) { + return []; + } + + const result = await pool.query<{ + grh_index: number; + width: number; + height: number; + byte_size: number; + created_at: Date; + }>( + `SELECT grh_index, width, height, byte_size, created_at + FROM game_uploaded_graphics + WHERE grh_index = ANY($1::int[]) + ORDER BY grh_index ASC`, + [indices], + ); + + return result.rows.map((row) => ({ + grhIndex: row.grh_index, + width: row.width, + height: row.height, + byteSize: row.byte_size, + createdAt: row.created_at.toISOString(), + url: `/game-data/graphics/${row.grh_index}.png`, + })); +} + +/** + * Crea o actualiza el borrador de una propuesta. + * + * Un mapa sin propuesta arranca en 'draft'. Si ya existe y el autor la habia + * enviado o rechazado, se reescribe el contenido y vuelve a borrador: el autor + * corrige sobre lo mismo sin acumular filas. + */ +export async function saveProposalDraft( + payload: unknown, + ownerAccountId: string, +): Promise { + const parsed = proposalDraftSchema.parse(payload); + const existing = await getProposalRowByMap(parsed.mapNum); + + if (existing) { + if (existing.owner_account_id !== ownerAccountId) { + throw new Error("Ese mapa ya tiene una propuesta de otra cuenta."); + } + + if ( + existing.status === "proposed" || + existing.status === "in_review" || + existing.status === "published" + ) { + throw new Error( + "No se puede editar una propuesta enviada o publicada.", + ); + } + + const updated = await pool.query( + `UPDATE game_map_proposals + SET name = $2, + description = $3, + entry_x = $4, + entry_y = $5, + status = 'draft', + rejection_reason = NULL, + updated_at = NOW() + WHERE id = $1 + RETURNING *`, + [ + existing.id, + parsed.name, + parsed.description, + parsed.entryX ?? existing.entry_x, + parsed.entryY ?? existing.entry_y, + ], + ); + + return toMapProposal(updated.rows[0]); + } + + const inserted = await pool.query( + `INSERT INTO game_map_proposals + (map_num, owner_account_id, name, description, entry_x, entry_y) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *`, + [ + parsed.mapNum, + ownerAccountId, + parsed.name, + parsed.description, + parsed.entryX ?? 1, + parsed.entryY ?? 1, + ], + ); + + return toMapProposal(inserted.rows[0]); +} + +/** Propuestas de un autor, con el motivo de rechazo si lo hay. */ +export async function listOwnProposals( + ownerAccountId: string, +): Promise { + const result = await pool.query( + `SELECT * FROM game_map_proposals + WHERE owner_account_id = $1 + ORDER BY updated_at DESC`, + [ownerAccountId], + ); + + return result.rows.map(toMapProposal); +} + +/** Cola de moderacion: lo enviado, esperando o siendo revisado. */ +export async function listModerationQueue(): Promise { + const result = await pool.query( + `SELECT * FROM game_map_proposals + WHERE status IN ('proposed', 'in_review') + ORDER BY submitted_at ASC NULLS LAST, updated_at ASC`, + ); + + return result.rows.map(toMapProposal); +} + +/** + * Envia una propuesta a revision. + * + * Corre los chequeos automaticos antes de encolarla. Si algo falla no llega a + * un humano: queda rechazada automaticamente con el detalle para que el autor + * corrija. Si pasa, queda en 'proposed' esperando que un moderador la abra. + */ +export async function submitProposal( + id: string, + ownerAccountId: string, +): Promise { + const existing = await getProposalRow(id); + + if (!existing) { + throw new Error("La propuesta no existe."); + } + + if (existing.owner_account_id !== ownerAccountId) { + throw new Error("No sos el autor de esta propuesta."); + } + + if (existing.status !== "draft" && existing.status !== "rejected") { + throw new Error("La propuesta ya fue enviada."); + } + + const [overrides, entities] = await Promise.all([ + listMapOverrides(existing.map_num, true), + listMapTileEntities(existing.map_num, true), + ]); + + const [uploadedGraphicCount, npcNames] = await Promise.all([ + countMapUploadedGraphics(existing.map_num), + listMapNpcNames(entities), + ]); + + const report = runAutomaticChecks({ + name: existing.name, + description: existing.description, + texts: npcNames, + entry: { x: existing.entry_x, y: existing.entry_y }, + blockedTiles: overrides + .filter((tile) => tile.blocked === true) + .map((tile) => ({ x: tile.x, y: tile.y })), + overrideCount: overrides.length, + entityCount: entities.length, + uploadedGraphicCount, + }); + + const nextStatus: MapProposalStatus = report.ok ? "proposed" : "rejected"; + const rejectionReason = report.ok + ? null + : report.issues.map((issue) => issue.message).join(" "); + + const result = await pool.query( + `UPDATE game_map_proposals + SET status = $2, + rejection_reason = $3, + auto_check_report = $4::jsonb, + submitted_at = NOW(), + reviewed_by_account_id = NULL, + reviewed_at = NULL, + updated_at = NOW() + WHERE id = $1 + RETURNING *`, + [id, nextStatus, rejectionReason, JSON.stringify(report)], + ); + + return toMapProposal(result.rows[0]); +} + +/** + * Detalle para que un moderador revise sin entrar a jugar. + * + * Devuelve el mapa completo con borradores, las entidades colocadas y los + * graficos subidos que referencia. Abrir una propuesta en 'proposed' la marca + * como 'in_review': asi queda claro quien la tiene en la mano. + */ +export async function getModerationDetail( + id: string, +): Promise { + let existing = await getProposalRow(id); + + if (!existing) { + throw new Error("La propuesta no existe."); + } + + if (existing.status === "proposed") { + const claimed = await pool.query( + `UPDATE game_map_proposals + SET status = 'in_review', updated_at = NOW() + WHERE id = $1 + RETURNING *`, + [id], + ); + + existing = claimed.rows[0] ?? existing; + } + + const [overrides, entities] = await Promise.all([ + listMapOverrides(existing.map_num, true), + listMapTileEntities(existing.map_num, true), + ]); + + return { + proposal: toMapProposal(existing), + overrides, + entities, + graphics: await listMapGraphics(overrides), + }; +} + +/** Aprueba una propuesta: recien aca el mapa pasa a ser visible. */ +export async function approveProposal( + id: string, + moderatorAccountId: string, +): Promise { + const existing = await getProposalRow(id); + + if (!existing) { + throw new Error("La propuesta no existe."); + } + + if (existing.status !== "proposed" && existing.status !== "in_review") { + throw new Error("La propuesta no esta en revision."); + } + + await publishMap(existing.map_num, moderatorAccountId); + + const result = await pool.query( + `UPDATE game_map_proposals + SET status = 'published', + rejection_reason = NULL, + reviewed_by_account_id = $2, + reviewed_at = NOW(), + published_at = NOW(), + updated_at = NOW() + WHERE id = $1 + RETURNING *`, + [id, moderatorAccountId], + ); + + await pool.query( + `UPDATE game_map_reports + SET status = 'resolved', + resolved_by_account_id = $2, + resolved_at = NOW() + WHERE proposal_id = $1 AND status = 'open'`, + [id, moderatorAccountId], + ); + + return toMapProposal(result.rows[0]); +} + +/** + * Rechaza una propuesta con un motivo obligatorio. + * + * Si el mapa ya estaba publicado, ademas lo saca del mundo. El motivo viaja en + * la fila y es lo que ve el autor para corregir y volver a proponer. + */ +export async function rejectProposal( + id: string, + moderatorAccountId: string, + payload: unknown, +): Promise { + const { reason } = proposalReasonSchema.parse(payload); + const existing = await getProposalRow(id); + + if (!existing) { + throw new Error("La propuesta no existe."); + } + + if ( + existing.status !== "proposed" && + existing.status !== "in_review" && + existing.status !== "published" + ) { + throw new Error( + "La propuesta no se puede rechazar en su estado actual.", + ); + } + + if (existing.published_at) { + await unpublishMap(existing.map_num, moderatorAccountId); + } + + const result = await pool.query( + `UPDATE game_map_proposals + SET status = 'rejected', + rejection_reason = $2, + reviewed_by_account_id = $3, + reviewed_at = NOW(), + published_at = NULL, + updated_at = NOW() + WHERE id = $1 + RETURNING *`, + [id, reason, moderatorAccountId], + ); + + return toMapProposal(result.rows[0]); +} + +/** + * Cualquier jugador reporta un mapa publicado. + * + * El reporte lo devuelve a la cola para que un moderador lo mire, pero no lo + * despublica solo: mientras se revisa sigue visible. Bajar el mapa es una + * accion explicita de moderacion. + */ +export async function reportProposal( + id: string, + reporterAccountId: string, + payload: unknown, +): Promise<{ proposal: MapProposal; reportId: string }> { + const { reason } = proposalReasonSchema.parse(payload); + const existing = await getProposalRow(id); + + if (!existing) { + throw new Error("La propuesta no existe."); + } + + if (existing.status !== "published") { + throw new Error("Solo se puede reportar un mapa publicado."); + } + + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + const report = await client.query<{ id: string }>( + `INSERT INTO game_map_reports (proposal_id, reporter_account_id, reason) + VALUES ($1, $2, $3) + RETURNING id`, + [id, reporterAccountId, reason], + ); + + const updated = await client.query( + `UPDATE game_map_proposals + SET status = 'in_review', + reviewed_by_account_id = NULL, + reviewed_at = NULL, + updated_at = NOW() + WHERE id = $1 + RETURNING *`, + [id], + ); + + await client.query("COMMIT"); + + return { + proposal: toMapProposal(updated.rows[0]), + reportId: report.rows[0].id, + }; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +} + +/** + * Despublica un mapa que ya estaba vivo sin borrar el trabajo del autor. + * + * Los tiles vuelven a borrador, asi dejan de verse en el juego, y la propuesta + * queda rechazada con el motivo para que el autor pueda corregir. + */ +export async function unpublishProposal( + id: string, + moderatorAccountId: string, + payload: unknown, +): Promise { + const parsed = proposalReasonSchema.partial().parse(payload ?? {}); + const reason = parsed.reason ?? "Despublicado por moderacion."; + const existing = await getProposalRow(id); + + if (!existing) { + throw new Error("La propuesta no existe."); + } + + if (!existing.published_at) { + throw new Error("El mapa no esta publicado."); + } + + await unpublishMap(existing.map_num, moderatorAccountId); + + const result = await pool.query( + `UPDATE game_map_proposals + SET status = 'rejected', + rejection_reason = $2, + reviewed_by_account_id = $3, + reviewed_at = NOW(), + published_at = NULL, + updated_at = NOW() + WHERE id = $1 + RETURNING *`, + [id, reason, moderatorAccountId], + ); + + return toMapProposal(result.rows[0]); +} + +export async function isModerator(accountId: string): Promise { + const result = await pool.query( + `SELECT 1 FROM game_map_moderators WHERE account_id = $1 LIMIT 1`, + [accountId], + ); + + return (result.rowCount ?? 0) > 0; +} + +export async function listModerators(): Promise< + Array<{ accountId: string; name: string; email: string; createdAt: string }> +> { + const result = await pool.query<{ + account_id: string; + name: string; + email: string; + created_at: Date; + }>( + `SELECT m.account_id, a.name, a.email, m.created_at + FROM game_map_moderators m + JOIN accounts a ON a.id = m.account_id + ORDER BY m.created_at ASC`, + ); + + return result.rows.map((row) => ({ + accountId: row.account_id, + name: row.name, + email: row.email, + createdAt: row.created_at.toISOString(), + })); +} + +export async function addModerator( + accountId: string, + addedByAccountId: string, +): Promise { + const exists = await pool.query( + `SELECT 1 FROM accounts WHERE id = $1 LIMIT 1`, + [accountId], + ); + + if (exists.rowCount === 0) { + throw new Error("La cuenta no existe."); + } + + await pool.query( + `INSERT INTO game_map_moderators (account_id, added_by_account_id) + VALUES ($1, $2) + ON CONFLICT (account_id) DO NOTHING`, + [accountId, addedByAccountId], + ); +} + +export async function removeModerator(accountId: string): Promise { + const result = await pool.query( + `DELETE FROM game_map_moderators WHERE account_id = $1`, + [accountId], + ); + + return (result.rowCount ?? 0) > 0; +} diff --git a/api/src/repositories/worldBuilder.ts b/api/src/repositories/worldBuilder.ts index 669a02f4..78ba86e4 100644 --- a/api/src/repositories/worldBuilder.ts +++ b/api/src/repositories/worldBuilder.ts @@ -512,6 +512,74 @@ export async function publishMap( } } +/** + * Saca del mundo lo publicado de un mapa sin perder el trabajo. + * + * Los tiles y entidades publicados vuelven a estado borrador: dejan de ser + * visibles para los jugadores pero el autor los conserva para corregir. Es la + * operacion inversa de publishMap y la que usa un moderador para despublicar + * un mapa que ya estaba vivo. + */ +export async function unpublishMap( + mapNum: number, + accountId: string, +): Promise<{ unpublished: number; unpublishedEntities: number }> { + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + const result = await client.query( + `INSERT INTO game_map_tile_overrides + (map_num, x, y, layer, grh_index, blocked, status, updated_by_account_id, updated_at) + SELECT map_num, x, y, layer, grh_index, blocked, 'draft', $2, NOW() + FROM game_map_tile_overrides + WHERE map_num = $1 AND status = 'published' + ON CONFLICT (map_num, x, y, layer, status) DO UPDATE + SET grh_index = EXCLUDED.grh_index, + blocked = EXCLUDED.blocked, + updated_by_account_id = EXCLUDED.updated_by_account_id, + updated_at = NOW()`, + [mapNum, accountId], + ); + + await client.query( + `DELETE FROM game_map_tile_overrides WHERE map_num = $1 AND status = 'published'`, + [mapNum], + ); + + const entitiesResult = await client.query( + `INSERT INTO game_map_tile_entities + (map_num, x, y, kind, entity_id, status, updated_by_account_id, updated_at) + SELECT map_num, x, y, kind, entity_id, 'draft', $2, NOW() + FROM game_map_tile_entities + WHERE map_num = $1 AND status = 'published' + ON CONFLICT (map_num, x, y, kind, status) DO UPDATE + SET entity_id = EXCLUDED.entity_id, + updated_by_account_id = EXCLUDED.updated_by_account_id, + updated_at = NOW()`, + [mapNum, accountId], + ); + + await client.query( + `DELETE FROM game_map_tile_entities WHERE map_num = $1 AND status = 'published'`, + [mapNum], + ); + + await client.query("COMMIT"); + + return { + unpublished: result.rowCount ?? 0, + unpublishedEntities: entitiesResult.rowCount ?? 0, + }; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +} + /** Descarta los borradores sin tocar lo que ya esta publicado. */ export async function discardDrafts( mapNum: number, diff --git a/api/src/server.ts b/api/src/server.ts index b059d75f..7c528793 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -122,6 +122,21 @@ import { tileEntitySchema, uploadGraphic, } from "./repositories/worldBuilder"; +import { + addModerator, + approveProposal, + getModerationDetail, + isModerator, + listModerators, + listModerationQueue, + listOwnProposals, + rejectProposal, + removeModerator, + reportProposal, + saveProposalDraft, + submitProposal, + unpublishProposal, +} from "./repositories/mapModeration"; import { MAX_PNG_BYTES } from "./lib/pngValidation"; import { getGameCraftingRecipeById, @@ -247,6 +262,34 @@ async function requireAdminEmailSession( return authorized; } +/** + * Deja pasar a un admin de game-data o a un moderador de mapas anotado. + * + * Los moderadores se suman y se quitan desde la API de admin, asi que la cola + * no queda atada a una sola cuenta. + */ +async function requireMapModeratorSession( + request: express.Request, + response: express.Response, +): Promise | null> { + const authorized = await getAuthorizedSession(request); + + if (!authorized) { + response.status(401).json({ error: "Unauthorized" }); + return null; + } + + if ( + !isAuthorizedGameDataAdmin(authorized.session) && + !(await isModerator(authorized.session.account._id)) + ) { + response.status(403).json({ error: "No autorizado." }); + return null; + } + + return authorized; +} + async function ensurePgStatStatements(): Promise { try { await pool.query("CREATE EXTENSION IF NOT EXISTS pg_stat_statements"); @@ -1111,6 +1154,261 @@ app.get("/admin/game-data/maps/:mapNum/status", async (request, response) => { } }); +// ═══════════════════════════════════════════════════════════════════════════ +// Etapa 5: propuestas y moderacion de mapas de usuario +// ═══════════════════════════════════════════════════════════════════════════ + +/** Crea o actualiza el borrador de una propuesta propia. */ +app.post("/maps/proposals", async (request, response) => { + try { + const authorized = await getAuthorizedSession(request); + if (!authorized) { + response.status(401).json({ error: "Unauthorized" }); + return; + } + + response + .status(201) + .json( + await saveProposalDraft( + request.body, + authorized.session.account._id, + ), + ); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +/** Lista las propuestas del autor, con el motivo de rechazo si lo hay. */ +app.get("/maps/proposals/mine", async (request, response) => { + try { + const authorized = await getAuthorizedSession(request); + if (!authorized) { + response.status(401).json({ error: "Unauthorized" }); + return; + } + + response.json({ + proposals: await listOwnProposals( + authorized.session.account._id, + ), + }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +/** + * Envia una propuesta. Aca corren los chequeos automaticos: si fallan no llega + * a la cola humana y vuelve rechazada con el detalle. + */ +app.post("/maps/proposals/:id/submit", async (request, response) => { + try { + const authorized = await getAuthorizedSession(request); + if (!authorized) { + response.status(401).json({ error: "Unauthorized" }); + return; + } + + response.json( + await submitProposal( + request.params.id, + authorized.session.account._id, + ), + ); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +/** Cualquier jugador reporta un mapa publicado y lo devuelve a la cola. */ +app.post("/maps/proposals/:id/report", async (request, response) => { + try { + const authorized = await getAuthorizedSession(request); + if (!authorized) { + response.status(401).json({ error: "Unauthorized" }); + return; + } + + const result = await reportProposal( + request.params.id, + authorized.session.account._id, + request.body, + ); + + response.status(201).json(result); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +/** Cola de moderacion con lo enviado y pendiente de revision humana. */ +app.get( + "/moderation/maps/queue", + async (request, response) => { + try { + const authorized = await requireMapModeratorSession( + request, + response, + ); + if (!authorized) return; + + response.json({ proposals: await listModerationQueue() }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } + }, +); + +/** + * Vista previa de un mapa y sus assets para el moderador, sin entrar a jugar. + * Abrir una propuesta enviada la pasa a 'in_review'. + */ +app.get("/moderation/maps/:id", async (request, response) => { + try { + const authorized = await requireMapModeratorSession(request, response); + if (!authorized) return; + + response.json(await getModerationDetail(request.params.id)); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response + .status(message === "La propuesta no existe." ? 404 : 400) + .json({ error: message }); + } +}); + +/** Aprueba y publica un mapa propuesto. */ +app.post("/moderation/maps/:id/approve", async (request, response) => { + try { + const authorized = await requireMapModeratorSession(request, response); + if (!authorized) return; + + response.json( + await approveProposal( + request.params.id, + authorized.session.account._id, + ), + ); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +/** Rechaza un mapa propuesto. El motivo es obligatorio. */ +app.post("/moderation/maps/:id/reject", async (request, response) => { + try { + const authorized = await requireMapModeratorSession(request, response); + if (!authorized) return; + + response.json( + await rejectProposal( + request.params.id, + authorized.session.account._id, + request.body, + ), + ); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +/** Despublica un mapa que ya estaba vivo por un problema posterior. */ +app.post("/moderation/maps/:id/unpublish", async (request, response) => { + try { + const authorized = await requireMapModeratorSession(request, response); + if (!authorized) return; + + response.json( + await unpublishProposal( + request.params.id, + authorized.session.account._id, + request.body, + ), + ); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +/** Lista quienes pueden moderar mapas de usuario. */ +app.get("/admin/game-data/moderators", async (request, response) => { + try { + const authorized = await requireAdminEmailSession(request, response); + if (!authorized) return; + + response.json({ moderators: await listModerators() }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +/** Suma un moderador para que la cola no dependa de una sola persona. */ +app.post("/admin/game-data/moderators", async (request, response) => { + try { + const authorized = await requireAdminEmailSession(request, response); + if (!authorized) return; + + const accountId = + typeof request.body?.accountId === "string" + ? request.body.accountId.trim() + : ""; + + if (!accountId) { + response.status(400).json({ error: "accountId es requerido." }); + return; + } + + await addModerator(accountId, authorized.session.account._id); + response.status(201).json({ moderators: await listModerators() }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } +}); + +/** Quita un moderador. */ +app.delete( + "/admin/game-data/moderators/:accountId", + async (request, response) => { + try { + const authorized = await requireAdminEmailSession( + request, + response, + ); + if (!authorized) return; + + const removed = await removeModerator(request.params.accountId); + response.json({ removed, moderators: await listModerators() }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unexpected error"; + response.status(400).json({ error: message }); + } + }, +); + /** * Paleta de tiles disponibles para el mapa actual: las entradas de la paleta * fuente (terrain.json) mas los graficos subidos por administradores. diff --git a/api/src/tests/map-moderation-checks.test.ts b/api/src/tests/map-moderation-checks.test.ts new file mode 100644 index 00000000..34bfd63d --- /dev/null +++ b/api/src/tests/map-moderation-checks.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../db", () => ({ + default: { query: vi.fn(), connect: vi.fn() }, +})); + +import { + checkQuotas, + checkReachability, + findForbiddenWords, + runAutomaticChecks, + MAX_MAP_ENTITIES, +} from "../repositories/mapModeration"; + +describe("findForbiddenWords", () => { + it("finds forbidden words ignoring case and accents", () => { + const issues = findForbiddenWords(["Ciudad Prohibida", "VIVA HITLER"]); + + expect(issues.map((issue) => issue.code)).toEqual(["forbidden_word"]); + expect(issues[0]?.message).toContain("hitler"); + }); + + it("reports each forbidden word once even if it repeats", () => { + const issues = findForbiddenWords(["puta puta puta"], ["puta"]); + + expect(issues).toHaveLength(1); + }); + + it("does not flag substrings inside longer words", () => { + // "hitleriano" no es la palabra prohibida "hitler": la comparacion es + // por palabra completa, no por substring. + const issues = findForbiddenWords(["hitleriano"], ["hitler"]); + + expect(issues).toHaveLength(0); + }); +}); + +describe("checkReachability", () => { + function gridFrom( + width: number, + height: number, + blocked: Array<[number, number]>, + ) { + const blockedSet = new Set(blocked.map(([x, y]) => `${x},${y}`)); + + return { + width, + height, + entry: { x: 1, y: 1 }, + isBlocked: (x: number, y: number) => blockedSet.has(`${x},${y}`), + }; + } + + it("passes on an open map", () => { + expect(checkReachability(gridFrom(5, 5, []))).toHaveLength(0); + }); + + it("flags a walkable tile walled off from the entry", () => { + const issues = checkReachability(gridFrom(5, 5, [[4, 5], [5, 4]])); + + expect(issues.map((issue) => issue.code)).toEqual(["isolated_region"]); + }); + + it("flags a blocked entry point", () => { + const issues = checkReachability(gridFrom(5, 5, [[1, 1]])); + + expect(issues.map((issue) => issue.code)).toEqual(["entry_blocked"]); + }); +}); + +describe("checkQuotas", () => { + it("flags a map over the entity quota", () => { + const issues = checkQuotas({ + overrideCount: 10, + entityCount: MAX_MAP_ENTITIES + 1, + uploadedGraphicCount: 0, + }); + + expect(issues.map((issue) => issue.code)).toEqual([ + "too_many_entities", + ]); + }); +}); + +describe("runAutomaticChecks", () => { + it("approves a clean map", () => { + const report = runAutomaticChecks({ + name: "Ciudad de prueba", + description: "Una ciudad tranquila", + entry: { x: 1, y: 1 }, + blockedTiles: [], + overrideCount: 10, + entityCount: 5, + uploadedGraphicCount: 0, + }); + + expect(report.ok).toBe(true); + expect(report.issues).toHaveLength(0); + }); + + it("blocks a map with forbidden text before it reaches a human", () => { + const report = runAutomaticChecks({ + name: "Mapa mierda", + description: "", + entry: { x: 1, y: 1 }, + blockedTiles: [], + overrideCount: 0, + entityCount: 0, + uploadedGraphicCount: 0, + }); + + expect(report.ok).toBe(false); + expect(report.issues.some((issue) => issue.code === "forbidden_word")).toBe( + true, + ); + }); +}); diff --git a/api/src/tests/map-moderation.integration.test.ts b/api/src/tests/map-moderation.integration.test.ts new file mode 100644 index 00000000..8cdc957d --- /dev/null +++ b/api/src/tests/map-moderation.integration.test.ts @@ -0,0 +1,234 @@ +import assert from "node:assert/strict"; +import { beforeAll, test } from "vitest"; +import pool from "../db"; +import { createAccountFixture, ensureApiReady, requestJson } from "./helpers/api"; + +beforeAll(async () => { + await ensureApiReady(); +}); + +type ProposalPayload = { + id: string; + mapNum: number; + status: string; + rejectionReason: string | null; + autoCheckReport: { + ok: boolean; + issues: Array<{ code: string; message: string }>; + } | null; +}; + +function authHeaders(token: string): Record { + return { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }; +} + +async function createProposal( + token: string, + mapNum: number, + name: string, +): Promise { + const response = await requestJson("/maps/proposals", { + method: "POST", + headers: authHeaders(token), + body: JSON.stringify({ + mapNum, + name, + description: "Propuesta de prueba", + entryX: 1, + entryY: 1, + }), + }); + + assert.equal(response.status, 201, JSON.stringify(response.data)); + return response.data; +} + +async function submitProposal( + token: string, + proposalId: string, +): Promise<{ status: number; data: ProposalPayload }> { + const response = await requestJson( + `/maps/proposals/${proposalId}/submit`, + { method: "POST", headers: authHeaders(token) }, + ); + + return response; +} + +test("full map moderation flow: draft, auto-checks, review, rejection, publish and report", async () => { + const author = await createAccountFixture(); + const moderator = await createAccountFixture(); + const reporter = await createAccountFixture(); + const authorToken = author.session.sessionToken; + const moderatorToken = moderator.session.sessionToken; + const reporterToken = reporter.session.sessionToken; + + // El moderador se suma por fuera del admin de game-data para no depender de + // las variables de entorno del proxy de admin en los tests. + await pool.query( + `INSERT INTO game_map_moderators (account_id) + VALUES ($1) + ON CONFLICT (account_id) DO NOTHING`, + [moderator.session.account._id], + ); + + const mapNum = 900000 + Math.floor(Math.random() * 100000); + + // Un mapa propuesto arranca en borrador y no esta en la cola. + const created = await createProposal( + authorToken, + mapNum, + "Ciudad de prueba", + ); + assert.equal(created.status, "draft"); + + const emptyQueue = await requestJson<{ proposals: ProposalPayload[] }>( + "/moderation/maps/queue", + { headers: authHeaders(moderatorToken) }, + ); + assert.equal(emptyQueue.status, 200); + assert.equal( + emptyQueue.data.proposals.some((entry) => entry.id === created.id), + false, + ); + + // Enviar corre los chequeos automaticos y pasa a propuesto. + const submitted = await submitProposal(authorToken, created.id); + assert.equal(submitted.status, 200); + assert.equal(submitted.data.status, "proposed"); + assert.equal(submitted.data.autoCheckReport?.ok, true); + + // El autor ve su propuesta. + const mine = await requestJson<{ proposals: ProposalPayload[] }>( + "/maps/proposals/mine", + { headers: authHeaders(authorToken) }, + ); + assert.equal(mine.status, 200); + assert.equal( + mine.data.proposals.find((entry) => entry.id === created.id)?.status, + "proposed", + ); + + // La cola es solo para moderadores. + const forbidden = await requestJson<{ error?: string }>( + "/moderation/maps/queue", + { headers: authHeaders(authorToken) }, + ); + assert.equal(forbidden.status, 403); + + const queue = await requestJson<{ proposals: ProposalPayload[] }>( + "/moderation/maps/queue", + { headers: authHeaders(moderatorToken) }, + ); + assert.equal(queue.status, 200); + assert.equal( + queue.data.proposals.some((entry) => entry.id === created.id), + true, + ); + + // Abrir el detalle lo pasa a "en revision" y trae mapa y assets. + const detail = await requestJson<{ + proposal: ProposalPayload; + overrides: unknown[]; + entities: unknown[]; + graphics: unknown[]; + }>(`/moderation/maps/${created.id}`, { + headers: authHeaders(moderatorToken), + }); + assert.equal(detail.status, 200); + assert.equal(detail.data.proposal.status, "in_review"); + assert.equal(Array.isArray(detail.data.overrides), true); + assert.equal(Array.isArray(detail.data.entities), true); + assert.equal(Array.isArray(detail.data.graphics), true); + + // Rechazar sin motivo falla. + const noReason = await requestJson<{ error?: string }>( + `/moderation/maps/${created.id}/reject`, + { + method: "POST", + headers: authHeaders(moderatorToken), + body: JSON.stringify({}), + }, + ); + assert.equal(noReason.status, 400); + + const rejected = await requestJson( + `/moderation/maps/${created.id}/reject`, + { + method: "POST", + headers: authHeaders(moderatorToken), + body: JSON.stringify({ + reason: "Los graficos no son originales.", + }), + }, + ); + assert.equal(rejected.status, 200); + assert.equal(rejected.data.status, "rejected"); + + // El autor recibe el motivo para corregir. + const mineAfterReject = await requestJson<{ proposals: ProposalPayload[] }>( + "/maps/proposals/mine", + { headers: authHeaders(authorToken) }, + ); + assert.equal( + mineAfterReject.data.proposals.find( + (entry) => entry.id === created.id, + )?.rejectionReason, + "Los graficos no son originales.", + ); + + // Reenviar con una palabra prohibida se rechaza solo, sin llegar a la cola. + await createProposal(authorToken, mapNum, "Mapa mierda"); + const autoRejected = await submitProposal(authorToken, created.id); + assert.equal(autoRejected.status, 200); + assert.equal(autoRejected.data.status, "rejected"); + assert.equal(autoRejected.data.autoCheckReport?.ok, false); + assert.equal( + autoRejected.data.autoCheckReport?.issues.some( + (issue) => issue.code === "forbidden_word", + ), + true, + ); + + // Corregir y reenviar. + await createProposal(authorToken, mapNum, "Ciudad de prueba"); + const resubmitted = await submitProposal(authorToken, created.id); + assert.equal(resubmitted.status, 200); + assert.equal(resubmitted.data.status, "proposed"); + + const approved = await requestJson( + `/moderation/maps/${created.id}/approve`, + { method: "POST", headers: authHeaders(moderatorToken) }, + ); + assert.equal(approved.status, 200); + assert.equal(approved.data.status, "published"); + + // Cualquier jugador reporta y el mapa vuelve a la cola. + const report = await requestJson<{ + proposal: ProposalPayload; + reportId: string; + }>(`/maps/proposals/${created.id}/report`, { + method: "POST", + headers: authHeaders(reporterToken), + body: JSON.stringify({ reason: "Contenido ofensivo." }), + }); + assert.equal(report.status, 201); + assert.equal(report.data.proposal.status, "in_review"); + assert.ok(report.data.reportId); + + // Despublicar un mapa que ya estaba vivo. + const unpublished = await requestJson( + `/moderation/maps/${created.id}/unpublish`, + { + method: "POST", + headers: authHeaders(moderatorToken), + body: JSON.stringify({ reason: "Se confirmo el reporte." }), + }, + ); + assert.equal(unpublished.status, 200); + assert.equal(unpublished.data.status, "rejected"); + assert.equal(unpublished.data.rejectionReason, "Se confirmo el reporte."); +});