From 385dfeb80fe05637f3e23e35713425cb58d748fb Mon Sep 17 00:00:00 2001 From: Rodrigoue9 Date: Wed, 19 Aug 2026 13:04:10 -0300 Subject: [PATCH 1/2] feat(world-builder): stage 5 user map sandbox with ownership and quotas (#88) --- api/src/repositories/userMaps.ts | 161 +++++++++++++++++++++++++++++++ api/src/server.ts | 88 +++++++++++++++++ 2 files changed, 249 insertions(+) create mode 100644 api/src/repositories/userMaps.ts diff --git a/api/src/repositories/userMaps.ts b/api/src/repositories/userMaps.ts new file mode 100644 index 00000000..177735f7 --- /dev/null +++ b/api/src/repositories/userMaps.ts @@ -0,0 +1,161 @@ +import pool from "../db"; + +export const USER_MAP_START_ID = 600; +export const USER_MAP_END_ID = 1999; +export const DEFAULT_MAX_USER_MAPS = 5; + +export type UserMapStatus = "draft" | "proposed" | "published" | "archived"; + +export type UserMapRecord = { + id: number; + accountId: number; + name: string; + terrain: string; + zone: string; + status: UserMapStatus; + npcCount: number; + objectCount: number; + assetBytes: number; + createdAt: string; + updatedAt: string; +}; + +export type UserMapQuota = { + mapsUsed: number; + maxMaps: number; + assetBytesUsed: number; + maxAssetBytes: number; + npcCountUsed: number; + maxNpcCount: number; + objectCountUsed: number; + maxObjectCount: number; +}; + +export async function getUserMapQuota(accountId: number): Promise { + const res = await pool.query( + `SELECT COUNT(*)::int AS "mapsCount", + COALESCE(SUM(asset_bytes), 0)::bigint AS "totalAssetBytes", + COALESCE(SUM(npc_count), 0)::int AS "totalNpcs", + COALESCE(SUM(object_count), 0)::int AS "totalObjects" + FROM user_maps + WHERE account_id = $1 AND status != 'archived'`, + [accountId] + ); + const row = res.rows[0] || {}; + return { + mapsUsed: row.mapsCount || 0, + maxMaps: DEFAULT_MAX_USER_MAPS, + assetBytesUsed: Number(row.totalAssetBytes || 0), + maxAssetBytes: 10 * 1024 * 1024, + npcCountUsed: row.totalNpcs || 0, + maxNpcCount: 20, + objectCountUsed: row.totalObjects || 0, + maxObjectCount: 50, + }; +} + +export async function checkMapOwnership(mapId: number, accountId: number): Promise { + const res = await pool.query( + `SELECT 1 FROM user_maps WHERE id = $1 AND account_id = $2`, + [mapId, accountId] + ); + return (res.rowCount ?? 0) > 0; +} + +export async function createUserMap( + accountId: number, + name: string, + terrain: string = "PRADERA", + zone: string = "CAMPO" +): Promise { + const quota = await getUserMapQuota(accountId); + if (quota.mapsUsed >= quota.maxMaps) { + throw new Error(`Se ha alcanzado la cuota maxima de mapas (${quota.maxMaps})`); + } + + const nextIdRes = await pool.query( + `SELECT COALESCE(MAX(id) + 1, $1) AS "nextId" + FROM user_maps + WHERE id >= $1 AND id <= $2`, + [USER_MAP_START_ID, USER_MAP_END_ID] + ); + const nextId = Math.max(USER_MAP_START_ID, Number(nextIdRes.rows[0]?.nextId || USER_MAP_START_ID)); + if (nextId > USER_MAP_END_ID) { + throw new Error("No hay identificadores de mapa disponibles en el rango de usuarios"); + } + + const res = await pool.query( + `INSERT INTO user_maps (id, account_id, name, terrain, zone, status) + VALUES ($1, $2, $3, $4, $5, 'draft') + RETURNING id, account_id AS "accountId", name, terrain, zone, status, + npc_count AS "npcCount", object_count AS "objectCount", + asset_bytes AS "assetBytes", created_at AS "createdAt", updated_at AS "updatedAt"`, + [nextId, accountId, name.trim(), terrain, zone] + ); + return res.rows[0]; +} + +export async function getUserMap(mapId: number): Promise { + const res = await pool.query( + `SELECT id, account_id AS "accountId", name, terrain, zone, status, + npc_count AS "npcCount", object_count AS "objectCount", + asset_bytes AS "assetBytes", created_at AS "createdAt", updated_at AS "updatedAt" + FROM user_maps + WHERE id = $1`, + [mapId] + ); + return res.rows[0] || null; +} + +export async function listUserMaps(accountId: number): Promise { + const res = await pool.query( + `SELECT id, account_id AS "accountId", name, terrain, zone, status, + npc_count AS "npcCount", object_count AS "objectCount", + asset_bytes AS "assetBytes", created_at AS "createdAt", updated_at AS "updatedAt" + FROM user_maps + WHERE account_id = $1 + ORDER BY id ASC`, + [accountId] + ); + return res.rows; +} + +export async function listPublishedUserMaps(page = 1, limit = 20): Promise<{ maps: UserMapRecord[]; total: number }> { + const offset = (Math.max(1, page) - 1) * limit; + const countRes = await pool.query(`SELECT COUNT(*)::int AS total FROM user_maps WHERE status = 'published'`); + const total = countRes.rows[0]?.total || 0; + + const res = await pool.query( + `SELECT id, account_id AS "accountId", name, terrain, zone, status, + npc_count AS "npcCount", object_count AS "objectCount", + asset_bytes AS "assetBytes", created_at AS "createdAt", updated_at AS "updatedAt" + FROM user_maps + WHERE status = 'published' + ORDER BY id ASC + LIMIT $1 OFFSET $2`, + [limit, offset] + ); + return { maps: res.rows, total }; +} + +export async function updateUserMapStatus( + mapId: number, + accountId: number, + newStatus: UserMapStatus +): Promise { + const isOwner = await checkMapOwnership(mapId, accountId); + if (!isOwner) { + throw new Error("No tienes permiso para modificar este mapa"); + } + + const res = await pool.query( + `UPDATE user_maps + SET status = $1, updated_at = NOW() + WHERE id = $2 AND account_id = $3 + RETURNING id, account_id AS "accountId", name, terrain, zone, status, + npc_count AS "npcCount", object_count AS "objectCount", + asset_bytes AS "assetBytes", created_at AS "createdAt", updated_at AS "updatedAt"`, + [newStatus, mapId, accountId] + ); + return res.rows[0] || null; +} diff --git a/api/src/server.ts b/api/src/server.ts index d6f97469..1955dd07 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -2891,4 +2891,92 @@ app.get("/user-online-stats", async (request, response) => { } }); +app.post("/internal/user-maps", requireAuth, async (request, response) => { + try { + const { name, terrain, zone } = request.body; + const accountId = (request as any).user?.accountId || 1; + const { createUserMap } = await import("./repositories/userMaps"); + const result = await createUserMap(accountId, name, terrain, zone); + response.status(201).json(result); + } catch (error) { + response.status(400).json({ + error: error instanceof Error ? error.message : "Unexpected error", + }); + } +}); + +app.get("/internal/user-maps", requireAuth, async (request, response) => { + try { + const accountId = (request as any).user?.accountId || 1; + const { listUserMaps } = await import("./repositories/userMaps"); + const result = await listUserMaps(accountId); + response.json(result); + } catch (error) { + response.status(500).json({ + error: error instanceof Error ? error.message : "Unexpected error", + }); + } +}); + +app.get("/internal/user-maps/quota", requireAuth, async (request, response) => { + try { + const accountId = (request as any).user?.accountId || 1; + const { getUserMapQuota } = await import("./repositories/userMaps"); + const result = await getUserMapQuota(accountId); + response.json(result); + } catch (error) { + response.status(500).json({ + error: error instanceof Error ? error.message : "Unexpected error", + }); + } +}); + +app.get("/internal/user-maps/published", async (request, response) => { + try { + const page = Number(request.query.page || 1); + const limit = Number(request.query.limit || 20); + const { listPublishedUserMaps } = await import("./repositories/userMaps"); + const result = await listPublishedUserMaps(page, limit); + response.json(result); + } catch (error) { + response.status(500).json({ + error: error instanceof Error ? error.message : "Unexpected error", + }); + } +}); + +app.get("/internal/user-maps/:id", requireAuth, async (request, response) => { + try { + const id = Number(request.params.id); + const { getUserMap } = await import("./repositories/userMaps"); + const result = await getUserMap(id); + if (!result) { + return response.status(404).json({ error: "Mapa no encontrado" }); + } + response.json(result); + } catch (error) { + response.status(500).json({ + error: error instanceof Error ? error.message : "Unexpected error", + }); + } +}); + +app.patch("/internal/user-maps/:id/status", requireAuth, async (request, response) => { + try { + const id = Number(request.params.id); + const { status } = request.body; + const accountId = (request as any).user?.accountId || 1; + const { updateUserMapStatus } = await import("./repositories/userMaps"); + const result = await updateUserMapStatus(id, accountId, status); + if (!result) { + return response.status(404).json({ error: "Mapa no encontrado" }); + } + response.json(result); + } catch (error) { + response.status(400).json({ + error: error instanceof Error ? error.message : "Unexpected error", + }); + } +}); + void start(); From 5f1315274f720ea0a50a886b96ac52977f850292 Mon Sep 17 00:00:00 2001 From: Rodrigoue9 Date: Wed, 19 Aug 2026 14:05:21 -0300 Subject: [PATCH 2/2] fix(world-builder): add user_maps schema, atomic quota locks and session auth (#88) --- api/schema.sql | 17 +++++ api/src/repositories/userMaps.ts | 111 ++++++++++++++++++++++--------- api/src/server.ts | 38 ++++++++--- 3 files changed, 125 insertions(+), 41 deletions(-) diff --git a/api/schema.sql b/api/schema.sql index c5103206..11b91eb3 100644 --- a/api/schema.sql +++ b/api/schema.sql @@ -627,3 +627,20 @@ CREATE INDEX IF NOT EXISTS idx_game_map_tile_overrides_map ON game_map_tile_overrides(map_num, status); CREATE INDEX IF NOT EXISTS idx_game_uploaded_graphics_created_at ON game_uploaded_graphics(created_at DESC); + +CREATE TABLE IF NOT EXISTS user_maps ( + id INTEGER PRIMARY KEY CHECK (id BETWEEN 600 AND 1999), + account_id UUID REFERENCES accounts(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + terrain VARCHAR(50) NOT NULL DEFAULT 'PRADERA', + zone VARCHAR(50) NOT NULL DEFAULT 'CAMPO', + status VARCHAR(20) NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'proposed', 'published', 'archived')), + npc_count INTEGER NOT NULL DEFAULT 0 CHECK (npc_count >= 0), + object_count INTEGER NOT NULL DEFAULT 0 CHECK (object_count >= 0), + asset_bytes BIGINT NOT NULL DEFAULT 0 CHECK (asset_bytes >= 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_user_maps_account_id ON user_maps(account_id); +CREATE INDEX IF NOT EXISTS idx_user_maps_status ON user_maps(status); diff --git a/api/src/repositories/userMaps.ts b/api/src/repositories/userMaps.ts index 177735f7..f8e94fcc 100644 --- a/api/src/repositories/userMaps.ts +++ b/api/src/repositories/userMaps.ts @@ -31,7 +31,20 @@ export type UserMapQuota = { maxObjectCount: number; }; -export async function getUserMapQuota(accountId: number): Promise { +export const ALLOWED_USER_MAP_STATUSES: UserMapStatus[] = [ + "draft", + "proposed", + "published", + "archived", +]; + +export function validateUserMapStatus(status: unknown): asserts status is UserMapStatus { + if (typeof status !== "string" || !ALLOWED_USER_MAP_STATUSES.includes(status as UserMapStatus)) { + throw new Error(`Estado no valido. Permitidos: ${ALLOWED_USER_MAP_STATUSES.join(", ")}`); + } +} + +export async function getUserMapQuota(accountId: string): Promise { const res = await pool.query( `SELECT COUNT(*)::int AS "mapsCount", COALESCE(SUM(asset_bytes), 0)::bigint AS "totalAssetBytes", @@ -54,7 +67,7 @@ export async function getUserMapQuota(accountId: number): Promise }; } -export async function checkMapOwnership(mapId: number, accountId: number): Promise { +export async function checkMapOwnership(mapId: number, accountId: string): Promise { const res = await pool.query( `SELECT 1 FROM user_maps WHERE id = $1 AND account_id = $2`, [mapId, accountId] @@ -63,39 +76,62 @@ export async function checkMapOwnership(mapId: number, accountId: number): Promi } export async function createUserMap( - accountId: number, + accountId: string, name: string, terrain: string = "PRADERA", zone: string = "CAMPO" ): Promise { - const quota = await getUserMapQuota(accountId); - if (quota.mapsUsed >= quota.maxMaps) { - throw new Error(`Se ha alcanzado la cuota maxima de mapas (${quota.maxMaps})`); + if (!name || !name.trim()) { + throw new Error("El nombre del mapa es obligatorio"); } - const nextIdRes = await pool.query( - `SELECT COALESCE(MAX(id) + 1, $1) AS "nextId" - FROM user_maps - WHERE id >= $1 AND id <= $2`, - [USER_MAP_START_ID, USER_MAP_END_ID] - ); - const nextId = Math.max(USER_MAP_START_ID, Number(nextIdRes.rows[0]?.nextId || USER_MAP_START_ID)); - if (nextId > USER_MAP_END_ID) { - throw new Error("No hay identificadores de mapa disponibles en el rango de usuarios"); - } + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await client.query("SELECT pg_advisory_xact_lock(6001999)"); - const res = await pool.query( - `INSERT INTO user_maps (id, account_id, name, terrain, zone, status) - VALUES ($1, $2, $3, $4, $5, 'draft') - RETURNING id, account_id AS "accountId", name, terrain, zone, status, - npc_count AS "npcCount", object_count AS "objectCount", - asset_bytes AS "assetBytes", created_at AS "createdAt", updated_at AS "updatedAt"`, - [nextId, accountId, name.trim(), terrain, zone] - ); - return res.rows[0]; + const quotaRes = await client.query( + `SELECT COUNT(*)::int AS "mapsCount" FROM user_maps WHERE account_id = $1 AND status != 'archived'`, + [accountId] + ); + const mapsCount = quotaRes.rows[0]?.mapsCount || 0; + if (mapsCount >= DEFAULT_MAX_USER_MAPS) { + throw new Error(`Se ha alcanzado la cuota maxima de mapas (${DEFAULT_MAX_USER_MAPS})`); + } + + const nextIdRes = await client.query( + `SELECT COALESCE(MAX(id) + 1, $1) AS "nextId" + FROM user_maps + WHERE id >= $1 AND id <= $2`, + [USER_MAP_START_ID, USER_MAP_END_ID] + ); + const nextId = Math.max(USER_MAP_START_ID, Number(nextIdRes.rows[0]?.nextId || USER_MAP_START_ID)); + if (nextId > USER_MAP_END_ID) { + throw new Error("No hay identificadores de mapa disponibles en el rango de usuarios"); + } + + const res = await client.query( + `INSERT INTO user_maps (id, account_id, name, terrain, zone, status) + VALUES ($1, $2, $3, $4, $5, 'draft') + RETURNING id, account_id AS "accountId", name, terrain, zone, status, + npc_count AS "npcCount", object_count AS "objectCount", + asset_bytes AS "assetBytes", created_at AS "createdAt", updated_at AS "updatedAt"`, + [nextId, accountId, name.trim(), terrain, zone] + ); + await client.query("COMMIT"); + return res.rows[0]; + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } } -export async function getUserMap(mapId: number): Promise { +export async function getUserMap(mapId: number, callerAccountId?: string): Promise { + if (!Number.isInteger(mapId) || mapId < 1) { + throw new Error("mapId debe ser un entero positivo"); + } const res = await pool.query( `SELECT id, account_id AS "accountId", name, terrain, zone, status, npc_count AS "npcCount", object_count AS "objectCount", @@ -104,10 +140,16 @@ export async function getUserMap(mapId: number): Promise { WHERE id = $1`, [mapId] ); - return res.rows[0] || null; + const map = res.rows[0]; + if (!map) return null; + + if (map.status !== "published" && (!callerAccountId || map.accountId !== callerAccountId)) { + return null; + } + return map; } -export async function listUserMaps(accountId: number): Promise { +export async function listUserMaps(accountId: string): Promise { const res = await pool.query( `SELECT id, account_id AS "accountId", name, terrain, zone, status, npc_count AS "npcCount", object_count AS "objectCount", @@ -121,7 +163,10 @@ export async function listUserMaps(accountId: number): Promise } export async function listPublishedUserMaps(page = 1, limit = 20): Promise<{ maps: UserMapRecord[]; total: number }> { - const offset = (Math.max(1, page) - 1) * limit; + const safePage = Math.max(1, Number.isInteger(page) ? page : 1); + const safeLimit = Math.min(100, Math.max(1, Number.isInteger(limit) ? limit : 20)); + const offset = (safePage - 1) * safeLimit; + const countRes = await pool.query(`SELECT COUNT(*)::int AS total FROM user_maps WHERE status = 'published'`); const total = countRes.rows[0]?.total || 0; @@ -133,16 +178,20 @@ export async function listPublishedUserMaps(page = 1, limit = 20): Promise<{ map WHERE status = 'published' ORDER BY id ASC LIMIT $1 OFFSET $2`, - [limit, offset] + [safeLimit, offset] ); return { maps: res.rows, total }; } export async function updateUserMapStatus( mapId: number, - accountId: number, + accountId: string, newStatus: UserMapStatus ): Promise { + if (!Number.isInteger(mapId) || mapId < 1) { + throw new Error("mapId debe ser un entero positivo"); + } + validateUserMapStatus(newStatus); const isOwner = await checkMapOwnership(mapId, accountId); if (!isOwner) { throw new Error("No tienes permiso para modificar este mapa"); diff --git a/api/src/server.ts b/api/src/server.ts index 1955dd07..98438a32 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -2891,10 +2891,14 @@ app.get("/user-online-stats", async (request, response) => { } }); -app.post("/internal/user-maps", requireAuth, async (request, response) => { +app.post("/internal/user-maps", async (request, response) => { try { + const authorized = await getAuthorizedSession(request); + if (!authorized) { + return response.status(401).json({ error: "No autorizado" }); + } + const accountId = authorized.session.account._id; const { name, terrain, zone } = request.body; - const accountId = (request as any).user?.accountId || 1; const { createUserMap } = await import("./repositories/userMaps"); const result = await createUserMap(accountId, name, terrain, zone); response.status(201).json(result); @@ -2905,9 +2909,13 @@ app.post("/internal/user-maps", requireAuth, async (request, response) => { } }); -app.get("/internal/user-maps", requireAuth, async (request, response) => { +app.get("/internal/user-maps", async (request, response) => { try { - const accountId = (request as any).user?.accountId || 1; + const authorized = await getAuthorizedSession(request); + if (!authorized) { + return response.status(401).json({ error: "No autorizado" }); + } + const accountId = authorized.session.account._id; const { listUserMaps } = await import("./repositories/userMaps"); const result = await listUserMaps(accountId); response.json(result); @@ -2918,9 +2926,13 @@ app.get("/internal/user-maps", requireAuth, async (request, response) => { } }); -app.get("/internal/user-maps/quota", requireAuth, async (request, response) => { +app.get("/internal/user-maps/quota", async (request, response) => { try { - const accountId = (request as any).user?.accountId || 1; + const authorized = await getAuthorizedSession(request); + if (!authorized) { + return response.status(401).json({ error: "No autorizado" }); + } + const accountId = authorized.session.account._id; const { getUserMapQuota } = await import("./repositories/userMaps"); const result = await getUserMapQuota(accountId); response.json(result); @@ -2945,11 +2957,13 @@ app.get("/internal/user-maps/published", async (request, response) => { } }); -app.get("/internal/user-maps/:id", requireAuth, async (request, response) => { +app.get("/internal/user-maps/:id", async (request, response) => { try { const id = Number(request.params.id); + const authorized = await getAuthorizedSession(request); + const callerAccountId = authorized?.session.account._id; const { getUserMap } = await import("./repositories/userMaps"); - const result = await getUserMap(id); + const result = await getUserMap(id, callerAccountId); if (!result) { return response.status(404).json({ error: "Mapa no encontrado" }); } @@ -2961,11 +2975,15 @@ app.get("/internal/user-maps/:id", requireAuth, async (request, response) => { } }); -app.patch("/internal/user-maps/:id/status", requireAuth, async (request, response) => { +app.patch("/internal/user-maps/:id/status", async (request, response) => { try { const id = Number(request.params.id); const { status } = request.body; - const accountId = (request as any).user?.accountId || 1; + const authorized = await getAuthorizedSession(request); + if (!authorized) { + return response.status(401).json({ error: "No autorizado" }); + } + const accountId = authorized.session.account._id; const { updateUserMapStatus } = await import("./repositories/userMaps"); const result = await updateUserMapStatus(id, accountId, status); if (!result) {