1a2d40ee9c
Envía ítems de la tienda por correo al personaje de un amigo, pagado por SumUp. lib/gift.ts (catálogo por categoría, priceCart con precios reales de BD, sendGiftByMail vía SOAP .send items troceado a 12/correo + anti-inyección); servicio send-gift en PAID_SERVICES; ruta dedicada /api/gift/checkout (origen propio, destino existe, token de seguridad, carrito); SendGiftForm + página con el diseño; i18n Paid[send-gift]; seed_giftitems.sql (4 cat, 8 ítems). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
139 lines
4.5 KiB
TypeScript
139 lines
4.5 KiB
TypeScript
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
|
|
}
|