send-gift: rediseño al original — es la tienda, pero regalando

El original lo dice en su propio texto ("los objetos disponibles son los mismos
de la tienda") y lo confirma su JS, que usa #store-list y .store-add-button: el
regalo NO tiene catálogo propio, es la tienda con el correo a otro personaje.
Nosotros teníamos una tabla aparte (home_item) con 8 objetos y precios en euros.

Ahora send-gift monta StoreBrowser en modo `gift`: mismo catálogo (3068 ítems,
PD/PV), mismo carrito con cantidades y las cuatro formas de pago. Antes NO tenía
Stripe: solo SumUp, PD y PV.

Dos pasos, como el original: primero #char-select-div (personaje de origen,
destino, confirmación y token) y solo al pulsar "Mostrar Regalos" aparece el
catálogo. Se borran SendGiftForm, /api/gift/checkout y lib/gift (ya no los usa
nadie); la tabla home_item se queda en la BD, sin usar.

Tres cosas que el usuario pidió y que estaban mal:
- El formulario va con `noValidate`: los avisos los damos nosotros en rojo
  (#show-gif-response), no el navegador.
- "Mostrar Regalos" ahora valida contra el SERVIDOR (que el personaje de origen
  sea tuyo, que el destino exista y que el token sea correcto). Antes elegías
  objetos para descubrir al final que el destino no existía.
- BUG REAL: el nombre del destino distinguía mayúsculas. `characters.name` es
  `utf8mb4_bin`, así que "innakh" NO encontraba a "Innakh" (comprobado: 0
  resultados; con COLLATE, 1). `findCharacterByName` busca sin distinguir y
  devuelve el nombre CANÓNICO, que es el que va al comando SOAP.

La validación vive en lib/gift-check y la usan las dos rutas: /api/gift/check (el
botón) y /api/gift/send, que revalida porque el cliente puede saltarse el paso 1.

También se quita del texto "El pago se realiza mediante SumUp": el original no lo
dice y además ya era falso con cuatro formas de pago.

Verificado: innakh / INNAKH / InNaKh resuelven a "Innakh"; token malo y destino
inexistente salen en rojo sin pasar al catálogo. Con 500 PD de saldo de prueba,
2 copias de un ítem de 200 pasan el cobro (deliveryFailed por el worldserver
caído, con su reembolso) y 3 dan insufficientPd: el servidor cobra por copias.
Datos de prueba borrados.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 12:45:01 +00:00
parent 2c78489e4d
commit 67faa1a266
15 changed files with 533 additions and 642 deletions
+24
View File
@@ -23,6 +23,30 @@ export async function getGameCharacters(accountId: number): Promise<GameCharacte
}
/** Id de la cuenta dueña de un personaje por su nombre (único), o null. */
/**
* Busca un personaje por nombre SIN distinguir mayúsculas y devuelve su nombre
* CANÓNICO (el de la BD) además de su cuenta.
*
* El COLLATE no es adorno: `characters.name` es `utf8mb4_bin`, o sea sensible a
* mayúsculas, y sin él "innakh" no encuentra a "Innakh". Y hay que quedarse con
* el nombre de la BD porque es el que va al comando SOAP del correo.
*/
export async function findCharacterByName(
name: string,
): Promise<{ name: string; account: number } | null> {
const trimmed = name.trim()
if (!trimmed) return null
try {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
'SELECT name, account FROM characters WHERE name = ? COLLATE utf8mb4_general_ci LIMIT 1',
[trimmed],
)
return rows[0] ? { name: String(rows[0].name), account: Number(rows[0].account) } : null
} catch {
return null
}
}
export async function getAccountIdByCharacterName(name: string): Promise<number | null> {
const trimmed = name.trim()
if (!trimmed) return null
+36
View File
@@ -0,0 +1,36 @@
import { getGameCharacters, findCharacterByName } from './characters'
import { checkSecurityToken } from './security-token'
import { isValidCharName } from './store'
/**
* Comprueba que un regalo se puede enviar: personaje de origen tuyo, personaje
* de destino existente y token de seguridad correcto.
*
* La usan las DOS rutas: `/api/gift/check` (el botón "Mostrar Regalos", que
* valida antes de enseñar el catálogo, como el original) y `/api/gift/send`, que
* vuelve a validarlo porque el cliente puede saltarse el primer paso.
*
* Devuelve el nombre CANÓNICO del destino: el usuario puede escribirlo en
* mayúsculas, minúsculas o mezcladas, y al correo tiene que ir el de la BD.
*/
export async function checkGift(
accountId: number,
body: { source?: string; destination?: string; security_token?: string },
): Promise<{ source: string; destination: string } | { error: string }> {
const source = String(body.source ?? '').trim()
const destination = String(body.destination ?? '').trim()
const token = String(body.security_token ?? '').trim()
if (!source || !destination || !token) return { error: 'missingFields' }
if (!isValidCharName(destination)) return { error: 'invalidDestination' }
const chars = await getGameCharacters(accountId)
if (!chars.find((c) => c.name === source)) return { error: 'invalidSource' }
const dest = await findCharacterByName(destination)
if (!dest) return { error: 'destinationNotFound' }
if (!(await checkSecurityToken(accountId, token))) return { error: 'invalidToken' }
return { source, destination: dest.name }
}
-138
View File
@@ -1,138 +0,0 @@
import type { RowDataPacket } from 'mysql2'
import { db, DB } from './db'
import { executeSoapCommand } from './soap'
export interface GiftItem {
id: number // PK en home_item (identificador del carrito)
itemId: number // id del ítem en el juego
name: string
price: number // precio en euros
imageUrl: string
}
export interface GiftCategory {
id: number
name: string
items: GiftItem[]
}
/** Nombre de personaje válido de WoW (letras, 1-12). Evita inyección en el SOAP. */
const SAFE_NAME = /^[A-Za-z]{1,12}$/
/** Cantidad máxima por línea del carrito. */
export const MAX_QTY = 100
/** AzerothCore entrega como máximo 12 ítems por correo. */
const MAIL_ITEM_LIMIT = 12
export function isValidCharName(name: string): boolean {
return SAFE_NAME.test(name)
}
/** Catálogo de regalos (= ítems de la tienda), agrupado por categoría. */
export async function getGiftCatalog(): Promise<GiftCategory[]> {
try {
const [rows] = await db(DB.default).query<RowDataPacket[]>(
`SELECT c.id AS cat_id, c.name AS cat_name, i.id, i.item_id, i.name, i.price, i.image_url
FROM home_category c
JOIN home_item i ON i.category_id = c.id
ORDER BY c.name, i.name`,
)
const byCat = new Map<number, GiftCategory>()
for (const r of rows) {
let cat = byCat.get(r.cat_id)
if (!cat) {
cat = { id: r.cat_id, name: r.cat_name, items: [] }
byCat.set(r.cat_id, cat)
}
cat.items.push({ id: r.id, itemId: r.item_id, name: r.name, price: Number(r.price), imageUrl: r.image_url })
}
return [...byCat.values()]
} catch {
return []
}
}
export interface CartLine {
id: number
qty: number
}
export interface PricedLine {
itemId: number
name: string
qty: number
unit: number
subtotal: number
}
export interface PricedCart {
lines: PricedLine[]
total: number
}
/**
* Valida un carrito contra los PRECIOS REALES de la BD y calcula el total.
* Nunca se confía en el precio que envía el cliente. Devuelve null si el
* carrito está vacío, tiene cantidades no válidas o algún ítem no existe.
*/
export async function priceCart(cart: CartLine[]): Promise<PricedCart | null> {
const qtyById = new Map<number, number>()
for (const l of cart) {
const id = Math.floor(Number(l?.id))
const qty = Math.floor(Number(l?.qty))
if (!Number.isInteger(id) || id <= 0) return null
if (!Number.isInteger(qty) || qty < 1 || qty > MAX_QTY) return null
qtyById.set(id, Math.min(MAX_QTY, (qtyById.get(id) ?? 0) + qty))
}
if (qtyById.size === 0) return null
const ids = [...qtyById.keys()]
let rows: RowDataPacket[]
try {
;[rows] = await db(DB.default).query<RowDataPacket[]>(
`SELECT id, item_id, name, price FROM home_item WHERE id IN (${ids.map(() => '?').join(',')})`,
ids,
)
} catch {
return null
}
if (rows.length !== ids.length) return null // algún id no existe en el catálogo
const lines: PricedLine[] = []
let total = 0
for (const r of rows) {
const qty = qtyById.get(Number(r.id))!
const unit = Number(r.price)
const subtotal = Math.round(unit * qty * 100) / 100
total += subtotal
lines.push({ itemId: Number(r.item_id), name: r.name, qty, unit, subtotal })
}
return { lines, total: Math.round(total * 100) / 100 }
}
/**
* Entrega el regalo: envía los ítems por correo al personaje destino con SOAP
* (`.send items`, igual que la tienda). Trocea en correos de máximo 12 ítems
* (límite de AzerothCore). `items` = pares { i: item_id, q: cantidad }.
* Requiere el worldserver arriba; devuelve false si algún correo falla.
*/
export async function sendGiftByMail(
destination: string,
sender: string,
items: { i: number; q: number }[],
): Promise<boolean> {
if (!SAFE_NAME.test(destination)) return false
const clean = (Array.isArray(items) ? items : [])
.map((it) => ({ i: Math.floor(Number(it?.i)), q: Math.floor(Number(it?.q)) }))
.filter((it) => Number.isInteger(it.i) && it.i > 0 && Number.isInteger(it.q) && it.q >= 1 && it.q <= MAX_QTY)
if (clean.length === 0) return false
const subject = 'Has recibido un regalo'
const fromLabel = SAFE_NAME.test(sender) ? sender : 'un amigo'
const body = `Regalo enviado por ${fromLabel}. Que lo disfrutes.`
for (let n = 0; n < clean.length; n += MAIL_ITEM_LIMIT) {
const chunk = clean.slice(n, n + MAIL_ITEM_LIMIT)
const itemsStr = chunk.map((it) => `${it.i}:${it.q}`).join(' ')
const res = await executeSoapCommand(`.send items "${destination}" "${subject}" "${body}" ${itemsStr}`)
if (res === null) return false
}
return true
}
+6 -14
View File
@@ -1,8 +1,7 @@
import { executeSoapCommand } from './soap'
import { creditDPoints } from './dpoints'
import { sendGiftByMail } from './gift'
import { renameGuildBySoap, GUILD_RENAME_EUR } from './guild'
import { fulfillStoreOrder } from './store'
import { fulfillStoreOrder, giftMail } from './store'
import { checkFactionChangeEligibility } from './change-faction'
import { checkTransferEligibility } from './transfer-character'
import {
@@ -125,21 +124,14 @@ export const PAID_SERVICES: Record<string, PaidServiceConfig> = {
extraFields: ['destination_account'],
precheck: ({ accountId, email, character, body }) => checkTransferEligibility(accountId, email, character, body),
},
// Enviar regalo: al pagar por SumUp, envía por correo los ítems del carrito al
// personaje destino (a un amigo). `sender` = personaje de origen; `items` = JSON
// [{ i: item_id, q: cantidad }] que guardó el checkout en metadata.
// Enviar regalo: es una compra de la tienda cuyo correo va a OTRO personaje y
// dice quién lo manda. El carrito se guarda en home_store_order (order_ref) y
// se entrega igual que la tienda, con su reembolso parcial si falla a medias.
// `sender` = personaje de origen, para redactar el correo.
'send-gift': {
price: (m) => Promise.resolve(Number(m.amount)),
productName: (c) => `Regalo para ${c}`,
fulfill: (c, m) => {
let items: { i: number; q: number }[] = []
try {
items = JSON.parse(m.items || '[]')
} catch {
return Promise.resolve(false)
}
return sendGiftByMail(c, m.sender || '', items)
},
fulfill: (c, m) => fulfillStoreOrder(c, m.order_ref || '', giftMail(m.sender || '')),
extraFields: [],
},
// Renombrar hermandad (1000 PD = 10 €): al pagar, aplica `.guild rename`.
+33 -5
View File
@@ -14,6 +14,11 @@ import { MAX_COPIES, PD_PER_EUR, PV_PER_EUR, storeEuroTotal as pureEuroTotal } f
export type Currency = 'pd' | 'pv'
const SAFE_NAME = /^[A-Za-z]{1,12}$/
/** Nombre de personaje válido de WoW. Evita inyección en el comando SOAP. */
export function isValidCharName(name: string): boolean {
return SAFE_NAME.test(name)
}
const MAIL_ITEM_LIMIT = 12 // máximo de ítems por correo en AzerothCore
export interface StoreItem {
@@ -291,6 +296,23 @@ export async function priceStoreCart(cart: StoreCartLine[]): Promise<PricedCart
return { lines, pdTotal: sum('pd'), vpTotal: sum('pv') }
}
/**
* Texto del correo. El regalo lo cambia para decir quién lo envía; sin esto el
* destinatario recibiría un correo de "Tienda" sin saber de quién viene.
*/
export interface Mail {
subject: string
body: string
}
/** Correo de un regalo: dice quién lo manda (como el original). */
export function giftMail(sender: string): Mail {
return {
subject: 'Regalo',
body: `¡Has recibido un regalo de ${sender}! ¡Que lo disfrutes!`,
}
}
/** Entrada de correo: un lote de un ítem, con lo que costó (para reembolsar). */
interface CartEntry {
i: number
@@ -320,6 +342,7 @@ export async function purchaseStoreCart(
accountId: number,
character: string,
cart: PricedCart,
mail?: Mail,
): Promise<{ success: boolean; error?: string }> {
if (!SAFE_NAME.test(character)) return { success: false, error: 'invalidCharacter' }
if (cart.pdTotal <= 0 && cart.vpTotal <= 0) return { success: false, error: 'emptyCart' }
@@ -351,7 +374,7 @@ export async function purchaseStoreCart(
// lo que no se envió: los correos que ya salieron no se pueden recuperar, y
// devolver el carrito entero regalaría lo ya entregado.
const entries = cartEntries(cart)
const sent = await sendStoreItems(character, entries)
const sent = await sendStoreItems(character, entries, mail)
if (sent < entries.length) {
const left = entries.slice(sent)
const back = (c: Currency) => left.filter((e) => e.currency === c).reduce((s, e) => s + e.price, 0)
@@ -429,6 +452,7 @@ export async function createStoreOrder(
export async function fulfillStoreOrder(
character: string,
ref: string,
mail?: Mail,
): Promise<{ ok: boolean; refundEur?: number }> {
if (!ref) return { ok: false }
let rows: RowDataPacket[] = []
@@ -457,7 +481,7 @@ export async function fulfillStoreOrder(
if (entries.length === 0) return { ok: false }
const priced = entries.every((e) => e.price > 0 && (e.currency === 'pd' || e.currency === 'pv'))
const sent = await sendStoreItems(target, entries)
const sent = await sendStoreItems(target, entries, mail)
if (sent === entries.length) return { ok: true }
if (!priced) {
@@ -486,13 +510,17 @@ export async function fulfillStoreOrder(
* resto. Cada correo es un `.send items`, así que el troceo es atómico por
* correo: un chunk sale entero o no sale.
*/
async function sendStoreItems(character: string, items: { i: number; q: number }[]): Promise<number> {
async function sendStoreItems(
character: string,
items: { i: number; q: number }[],
mail?: Mail,
): Promise<number> {
if (!SAFE_NAME.test(character)) return 0
// Los datos salen de la BD: si algo no cuadra es un bug, y se prefiere no
// enviar nada a enviar de menos y descuadrar el reembolso por índice.
if (items.length === 0 || items.some((it) => !(Number(it.i) > 0) || !(Number(it.q) >= 1))) return 0
const subject = 'Tienda'
const body = 'Has recibido los objetos de la tienda. ¡Que los disfrutes!'
const subject = mail?.subject ?? 'Tienda'
const body = mail?.body ?? 'Has recibido los objetos de la tienda. ¡Que los disfrutes!'
let sent = 0
for (let n = 0; n < items.length; n += MAIL_ITEM_LIMIT) {
const chunk = items.slice(n, n + MAIL_ITEM_LIMIT)