feat(admin): centralize subscriptions under /admin?tab=subscriptions
Replaced the dedicated /admin/subscriptions page with a redirect to the central admin panel’s subscriptions tab. Removed session checks, imports, and the AdminSubscriptionDashboard since routing and access are now handled centrally. This streamlines admin UX and keeps access control unified.
This commit is contained in:
1 parent
d8e7d5d7e6
commit
62ab5b2608
19 files changed
+3091
-357
No files matched your search
@@ -0,0 +1,146 @@
|
||||
import { secureLogger } from "@/lib/secure-logger";
|
||||
|
||||
interface GenerateItemStructureInput {
|
||||
name: string;
|
||||
description: string;
|
||||
scope: "global" | "server";
|
||||
server?: string | null;
|
||||
}
|
||||
|
||||
interface GeminiResponse {
|
||||
structure: {
|
||||
table: string;
|
||||
columns: Record<string, unknown>;
|
||||
};
|
||||
raw: unknown;
|
||||
}
|
||||
|
||||
const FALLBACK_STRUCTURE = {
|
||||
table: "items_all",
|
||||
columns: {
|
||||
name: "Final Fate",
|
||||
description:
|
||||
"Item lendário utilizado no slot de tocha. Oferece bônus massivos em skills mágicas, velocidade e efeitos de leech.",
|
||||
attributes: {
|
||||
shielding: 20,
|
||||
death_magic: 120,
|
||||
mana_leech: { value: 30, chance: 100 },
|
||||
life_leech: { value: 25, chance: 100 },
|
||||
critical_hit: { damage: 25, chance: 75 },
|
||||
speed: 1000,
|
||||
protection_physical: 40,
|
||||
damage_reflection: 80,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export async function generateItemStructure(
|
||||
input: GenerateItemStructureInput
|
||||
): Promise<GeminiResponse> {
|
||||
const apiKey = process.env.GEMINI_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
secureLogger.warn("⚠️ GEMINI_API_KEY ausente – utilizando fallback estático.");
|
||||
return {
|
||||
structure: FALLBACK_STRUCTURE,
|
||||
raw: FALLBACK_STRUCTURE,
|
||||
};
|
||||
}
|
||||
|
||||
const url =
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent?key=" +
|
||||
apiKey;
|
||||
|
||||
const prompt = buildPrompt(input);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
contents: [
|
||||
{
|
||||
role: "user",
|
||||
parts: [{ text: prompt }],
|
||||
},
|
||||
],
|
||||
generationConfig: {
|
||||
temperature: 0,
|
||||
topP: 0.1,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Gemini retornou status ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const text =
|
||||
data?.candidates?.[0]?.content?.parts?.[0]?.text ??
|
||||
data?.candidates?.[0]?.output_text;
|
||||
|
||||
if (!text) {
|
||||
throw new Error("Resposta do Gemini vazia ou inválida.");
|
||||
}
|
||||
|
||||
const cleaned = text
|
||||
.replace(/```json/gi, "")
|
||||
.replace(/```/g, "")
|
||||
.trim();
|
||||
|
||||
const parsed = JSON.parse(cleaned);
|
||||
|
||||
return {
|
||||
structure: parsed,
|
||||
raw: data,
|
||||
};
|
||||
} catch (error) {
|
||||
secureLogger.error("❌ Falha ao gerar estrutura via Gemini", {
|
||||
error: error instanceof Error ? error.message : "Erro desconhecido",
|
||||
});
|
||||
|
||||
return {
|
||||
structure: FALLBACK_STRUCTURE,
|
||||
raw: FALLBACK_STRUCTURE,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildPrompt({ name, description, scope, server }: GenerateItemStructureInput) {
|
||||
return `
|
||||
Você é um assistente que prepara carga útil em JSON para inserção de itens em banco de dados.
|
||||
|
||||
Requisitos obrigatórios:
|
||||
- Responda SOMENTE com JSON válido, sem comentários, sem markdown, sem texto adicional.
|
||||
- Formato esperado:
|
||||
{
|
||||
"table": "nome_da_tabela",
|
||||
"columns": {
|
||||
"...": "..."
|
||||
}
|
||||
}
|
||||
- Nunca inclua explicações fora do JSON.
|
||||
|
||||
Contexto do item:
|
||||
- Nome: ${name}
|
||||
- Escopo: ${scope === "server" ? `específico do servidor ${server ?? "não informado"}` : "global"}
|
||||
- Descrição original (mantenha estrutura e extrair atributos quando possível):
|
||||
"""
|
||||
${description}
|
||||
"""
|
||||
|
||||
Instruções:
|
||||
- Escolha uma tabela coerente com o escopo (ex.: "items_all" para global ou "items_${(server ?? "custom")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "_")}" para servidor).
|
||||
- Dentro de "columns", inicie com "name" e "description".
|
||||
- Modele atributos numéricos em objetos ou subtabelas conforme necessário (ex.: {"attributes": {...}}).
|
||||
- Quando não tiver certeza de um campo, deixe nulo.
|
||||
- Nunca invente dados além do que estiver na descrição.
|
||||
- Garante que o JSON seja minuciosamente válido.
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma/app";
|
||||
import { secureLogger } from "@/lib/secure-logger";
|
||||
|
||||
export interface ImportStats {
|
||||
processedTables: number;
|
||||
processedItems: number;
|
||||
inserted: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
const PT_TO_EN_CATEGORY_MAP: Record<string, string> = {
|
||||
espadas: "weapon",
|
||||
armaduras: "armor",
|
||||
capacetes: "helmet",
|
||||
calcas: "legs",
|
||||
botas: "boots",
|
||||
escudos: "shield",
|
||||
amuletos: "amulet",
|
||||
aneis: "ring",
|
||||
runas: "rune",
|
||||
ferramentas: "tool",
|
||||
municao: "ammunition",
|
||||
};
|
||||
|
||||
const SQLITE_EXTRA_FIELDS = [
|
||||
"voc",
|
||||
"volume",
|
||||
"bonus_de_skill",
|
||||
"protecao",
|
||||
"dropa_de",
|
||||
"npc_price",
|
||||
"dropped_by",
|
||||
"tipo",
|
||||
"classe",
|
||||
"atributos",
|
||||
"descricao",
|
||||
"descricao_curta",
|
||||
"descricao_longa",
|
||||
"description",
|
||||
] as const;
|
||||
|
||||
const SQL_CHUNK_SIZE = 250;
|
||||
|
||||
let seedPromise: Promise<ImportStats | null> | null = null;
|
||||
let seedLogged = false;
|
||||
|
||||
function loadBetterSqlite(): any | null {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
return require("better-sqlite3");
|
||||
} catch (error) {
|
||||
secureLogger.warn("better-sqlite3 não está disponível. Importação automática será ignorada.", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function openSQLiteDatabase(Database: any) {
|
||||
const dbPath = path.join(process.cwd(), "data", "tibiawiki.db");
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
secureLogger.warn("Base SQLite não encontrada em data/tibiawiki.db. Importação ignorada.");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new Database(dbPath, { readonly: true, fileMustExist: true });
|
||||
} catch (error) {
|
||||
secureLogger.error("Falha ao abrir base SQLite", error instanceof Error ? error.message : error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function toNullableInt(value: unknown): number | null {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function toNullableDecimal(value: unknown): Prisma.Decimal | null {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return null;
|
||||
return new Prisma.Decimal(parsed);
|
||||
}
|
||||
|
||||
function collectMetadata(row: Record<string, unknown>): Record<string, unknown> {
|
||||
const metadata: Record<string, unknown> = {};
|
||||
|
||||
for (const field of SQLITE_EXTRA_FIELDS) {
|
||||
if (row[field] !== undefined && row[field] !== null && row[field] !== "") {
|
||||
metadata[field] = row[field];
|
||||
}
|
||||
}
|
||||
|
||||
const description =
|
||||
(row.descricao as unknown as string | undefined) ??
|
||||
(row.descricao_longa as unknown as string | undefined) ??
|
||||
(row.description as unknown as string | undefined);
|
||||
|
||||
if (description) {
|
||||
metadata.description = description;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function normalizeCategory(tableName: string): string {
|
||||
return PT_TO_EN_CATEGORY_MAP[tableName] ?? tableName;
|
||||
}
|
||||
|
||||
export async function importItemsFromSQLite(): Promise<ImportStats> {
|
||||
const Database = loadBetterSqlite();
|
||||
if (!Database) {
|
||||
return { processedTables: 0, processedItems: 0, inserted: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
const db = openSQLiteDatabase(Database);
|
||||
if (!db) {
|
||||
return { processedTables: 0, processedItems: 0, inserted: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
try {
|
||||
const tables = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
||||
.all() as { name: string }[];
|
||||
|
||||
let processedTables = 0;
|
||||
let processedItems = 0;
|
||||
let inserted = 0;
|
||||
let skipped = 0;
|
||||
|
||||
const batch: Prisma.ItemCreateManyInput[] = [];
|
||||
|
||||
for (const { name: tableName } of tables) {
|
||||
const pragma = db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{
|
||||
name: string;
|
||||
}>;
|
||||
const colNames = new Set(pragma.map((col) => col.name.toLowerCase()));
|
||||
|
||||
const hasId = colNames.has("id");
|
||||
const hasNome = colNames.has("nome") || colNames.has("name");
|
||||
|
||||
if (!hasId || !hasNome) {
|
||||
continue;
|
||||
}
|
||||
|
||||
processedTables += 1;
|
||||
const rows = db.prepare(`SELECT * FROM ${tableName}`).all() as Record<string, unknown>[];
|
||||
|
||||
for (const row of rows) {
|
||||
processedItems += 1;
|
||||
|
||||
const rawId = row.id ?? row.item_id;
|
||||
const numericId = toNullableInt(rawId);
|
||||
if (!numericId) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const nameField = (row.nome ?? row.name ?? "") as string;
|
||||
const itemName = typeof nameField === "string" ? nameField.trim() : "";
|
||||
if (!itemName) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const imageUrl = (row.image_url ?? row.image ?? null) as string | null;
|
||||
const imageLocalPath = (row.image_local_path ?? row.image_local ?? null) as string | null;
|
||||
const rarity =
|
||||
(row.rarity ?? row.raridade ?? row.rareza ?? null) !== null
|
||||
? String(row.rarity ?? row.raridade ?? row.rareza)
|
||||
: null;
|
||||
const level = toNullableInt(row.lvl ?? row.nivel ?? row.level);
|
||||
const weight = toNullableDecimal(row.peso ?? row.peso_em_oz ?? row.weight);
|
||||
const slots = toNullableInt(row.slots ?? row.slot);
|
||||
const skill =
|
||||
row.skill_para_usar ?? row.skill_required ?? row.skill ?? null
|
||||
? String(row.skill_para_usar ?? row.skill_required ?? row.skill)
|
||||
: null;
|
||||
|
||||
const metadata = collectMetadata(row);
|
||||
const metadataPayload = metadata as Prisma.InputJsonValue;
|
||||
|
||||
batch.push({
|
||||
source_table_id: numericId,
|
||||
source_table_name: tableName,
|
||||
item_name: itemName,
|
||||
category: normalizeCategory(tableName),
|
||||
image_local_path: imageLocalPath,
|
||||
image_url: imageUrl,
|
||||
rarity,
|
||||
level,
|
||||
weight,
|
||||
slots,
|
||||
skill,
|
||||
metadata: metadataPayload,
|
||||
});
|
||||
|
||||
if (batch.length >= SQL_CHUNK_SIZE) {
|
||||
const { count } = await prisma.item.createMany({
|
||||
data: batch,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
inserted += count;
|
||||
skipped += batch.length - count;
|
||||
batch.length = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (batch.length > 0) {
|
||||
const { count } = await prisma.item.createMany({
|
||||
data: batch,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
inserted += count;
|
||||
skipped += batch.length - count;
|
||||
}
|
||||
|
||||
return { processedTables, processedItems, inserted, skipped };
|
||||
} finally {
|
||||
try {
|
||||
db.close();
|
||||
} catch {
|
||||
// ignore close errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureItemsSeeded(): Promise<ImportStats | null> {
|
||||
if (seedPromise) {
|
||||
return seedPromise;
|
||||
}
|
||||
|
||||
seedPromise = (async () => {
|
||||
const currentCount = await prisma.item.count();
|
||||
if (!seedLogged) {
|
||||
seedLogged = true;
|
||||
secureLogger.info("Verificação da tabela items concluída", { existing: currentCount });
|
||||
}
|
||||
|
||||
if (currentCount > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
secureLogger.info("Tabela items vazia. Iniciando importação automática a partir do SQLite.");
|
||||
try {
|
||||
const stats = await importItemsFromSQLite();
|
||||
secureLogger.info("Importação de itens concluída", stats);
|
||||
return stats;
|
||||
} catch (error) {
|
||||
secureLogger.error("Falha na importação automática de itens", error);
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
return await seedPromise;
|
||||
} finally {
|
||||
seedPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export type SQLiteColumnType = "TEXT" | "INTEGER" | "REAL" | "BOOLEAN";
|
||||
|
||||
export interface SQLiteColumnDefinition {
|
||||
name: string;
|
||||
type: SQLiteColumnType;
|
||||
notNull?: boolean;
|
||||
defaultValue?: string | number | null;
|
||||
editable?: boolean;
|
||||
primaryKey?: boolean;
|
||||
}
|
||||
|
||||
export interface CategoryTemplate {
|
||||
name: string;
|
||||
columns: SQLiteColumnDefinition[];
|
||||
imagePath: string;
|
||||
}
|
||||
|
||||
const BASE_COLUMNS: SQLiteColumnDefinition[] = [
|
||||
{ name: "id", type: "INTEGER", primaryKey: true, notNull: true, editable: false },
|
||||
{ name: "nome", type: "TEXT", notNull: true, editable: true },
|
||||
{ name: "image_url", type: "TEXT", editable: true },
|
||||
{ name: "image_local_path", type: "TEXT", editable: true },
|
||||
];
|
||||
|
||||
const SQLITE_DB_PATH = path.join(process.cwd(), "data", "tibiawiki.db");
|
||||
const IMAGES_ROOT = path.join(process.cwd(), "data", "images");
|
||||
|
||||
let cachedDatabase: any | null = null;
|
||||
|
||||
function loadBetterSqlite(): any {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const Database = require("better-sqlite3");
|
||||
return Database;
|
||||
}
|
||||
|
||||
function getDatabase(): any {
|
||||
if (cachedDatabase) {
|
||||
return cachedDatabase;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(SQLITE_DB_PATH)) {
|
||||
throw new Error("Banco SQLite tibiawiki.db não encontrado em data/");
|
||||
}
|
||||
|
||||
const Database = loadBetterSqlite();
|
||||
cachedDatabase = new Database(SQLITE_DB_PATH, { fileMustExist: true });
|
||||
return cachedDatabase;
|
||||
}
|
||||
|
||||
function normalizeColumnType(type: SQLiteColumnType | string): SQLiteColumnType {
|
||||
const upper = type.toUpperCase();
|
||||
switch (upper) {
|
||||
case "INTEGER":
|
||||
case "INT":
|
||||
return "INTEGER";
|
||||
case "REAL":
|
||||
case "FLOAT":
|
||||
case "DOUBLE":
|
||||
return "REAL";
|
||||
case "BOOLEAN":
|
||||
return "BOOLEAN";
|
||||
default:
|
||||
return "TEXT";
|
||||
}
|
||||
}
|
||||
|
||||
function sqliteTypeFromColumn(column: SQLiteColumnDefinition): string {
|
||||
const type = normalizeColumnType(column.type);
|
||||
if (type === "BOOLEAN") {
|
||||
return "INTEGER";
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
function ensureImageDirectory(category: string) {
|
||||
const dir = path.join(IMAGES_ROOT, category);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function mapPragmaColumns(columns: Array<{ name: string; type: string; notnull: number; pk: number }>): SQLiteColumnDefinition[] {
|
||||
const mapped = columns.map((column) => {
|
||||
const baseMatch = BASE_COLUMNS.find((base) => base.name === column.name);
|
||||
return {
|
||||
name: column.name,
|
||||
type: normalizeColumnType(column.type as SQLiteColumnType),
|
||||
notNull: column.notnull === 1,
|
||||
primaryKey: column.pk === 1,
|
||||
editable: baseMatch ? baseMatch.editable : column.pk !== 1,
|
||||
} satisfies SQLiteColumnDefinition;
|
||||
});
|
||||
|
||||
return mapped;
|
||||
}
|
||||
|
||||
export function listCategoryTemplates(): CategoryTemplate[] {
|
||||
const db = getDatabase();
|
||||
const tables = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
||||
.all() as Array<{ name: string }>;
|
||||
|
||||
return tables.map((table) => {
|
||||
const pragma = db.prepare(`PRAGMA table_info(${table.name})`).all() as Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
notnull: number;
|
||||
pk: number;
|
||||
}>;
|
||||
|
||||
const columns = mapPragmaColumns(pragma);
|
||||
ensureImageDirectory(table.name);
|
||||
|
||||
return {
|
||||
name: table.name,
|
||||
columns,
|
||||
imagePath: path.join("data", "images", table.name),
|
||||
} satisfies CategoryTemplate;
|
||||
});
|
||||
}
|
||||
|
||||
function validateCategoryName(name: string) {
|
||||
if (!/^[a-z0-9_]+$/i.test(name)) {
|
||||
throw new Error("Nome da categoria deve conter apenas letras, números ou sublinhado.");
|
||||
}
|
||||
}
|
||||
|
||||
export interface CreateCategoryInput {
|
||||
name: string;
|
||||
columns?: SQLiteColumnDefinition[];
|
||||
}
|
||||
|
||||
export function createCategoryTemplate(input: CreateCategoryInput): CategoryTemplate {
|
||||
const db = getDatabase();
|
||||
const name = input.name.trim().toLowerCase();
|
||||
validateCategoryName(name);
|
||||
|
||||
const existing = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = ?")
|
||||
.get(name) as { name: string } | undefined;
|
||||
|
||||
if (existing) {
|
||||
throw new Error("Categoria já existe.");
|
||||
}
|
||||
|
||||
const userColumns = (input.columns ?? []).map((column) => ({
|
||||
...column,
|
||||
type: normalizeColumnType(column.type),
|
||||
}));
|
||||
|
||||
const columnsSql = [
|
||||
...BASE_COLUMNS.map((column) => {
|
||||
const pieces = [column.name, sqliteTypeFromColumn(column)];
|
||||
if (column.primaryKey) pieces.push("PRIMARY KEY AUTOINCREMENT");
|
||||
if (column.notNull) pieces.push("NOT NULL");
|
||||
return pieces.join(" ");
|
||||
}),
|
||||
...userColumns.map((column) => {
|
||||
const pieces = [column.name, sqliteTypeFromColumn(column)];
|
||||
if (column.notNull) pieces.push("NOT NULL");
|
||||
if (column.defaultValue !== undefined && column.defaultValue !== null) {
|
||||
pieces.push(`DEFAULT ${JSON.stringify(column.defaultValue)}`);
|
||||
}
|
||||
return pieces.join(" ");
|
||||
}),
|
||||
];
|
||||
|
||||
const createSql = `CREATE TABLE ${name} (${columnsSql.join(", ")})`;
|
||||
db.prepare(createSql).run();
|
||||
ensureImageDirectory(name);
|
||||
|
||||
return {
|
||||
name,
|
||||
columns: [...BASE_COLUMNS, ...userColumns],
|
||||
imagePath: path.join("data", "images", name),
|
||||
} satisfies CategoryTemplate;
|
||||
}
|
||||
|
||||
export interface AddColumnInput {
|
||||
category: string;
|
||||
column: SQLiteColumnDefinition;
|
||||
}
|
||||
|
||||
export function addColumnToCategory(input: AddColumnInput): SQLiteColumnDefinition {
|
||||
const db = getDatabase();
|
||||
const name = input.category.trim().toLowerCase();
|
||||
validateCategoryName(name);
|
||||
|
||||
const column = {
|
||||
...input.column,
|
||||
type: normalizeColumnType(input.column.type),
|
||||
} satisfies SQLiteColumnDefinition;
|
||||
|
||||
const columnSqlParts = [column.name, sqliteTypeFromColumn(column)];
|
||||
if (column.notNull) columnSqlParts.push("NOT NULL");
|
||||
if (column.defaultValue !== undefined && column.defaultValue !== null) {
|
||||
columnSqlParts.push(`DEFAULT ${JSON.stringify(column.defaultValue)}`);
|
||||
}
|
||||
|
||||
const sql = `ALTER TABLE ${name} ADD COLUMN ${columnSqlParts.join(" ")}`;
|
||||
db.prepare(sql).run();
|
||||
|
||||
return column;
|
||||
}
|
||||
|
||||
export interface UpsertItemInput {
|
||||
category: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function insertCategoryItem(input: UpsertItemInput): number {
|
||||
const db = getDatabase();
|
||||
const category = input.category.trim().toLowerCase();
|
||||
validateCategoryName(category);
|
||||
|
||||
const data = { ...input.data };
|
||||
delete data.id;
|
||||
|
||||
const keys = Object.keys(data);
|
||||
const placeholders = keys.map(() => "?").join(", ");
|
||||
const sql = `INSERT INTO ${category} (${keys.join(", ")}) VALUES (${placeholders})`;
|
||||
|
||||
const values = keys.map((key) => {
|
||||
const value = data[key];
|
||||
if (typeof value === "boolean") {
|
||||
return value ? 1 : 0;
|
||||
}
|
||||
return value;
|
||||
});
|
||||
|
||||
const result = db.prepare(sql).run(...values);
|
||||
return Number(result.lastInsertRowid);
|
||||
}
|
||||
|
||||
export function updateCategoryItem(category: string, id: number, data: Record<string, unknown>) {
|
||||
const db = getDatabase();
|
||||
const normalized = category.trim().toLowerCase();
|
||||
validateCategoryName(normalized);
|
||||
|
||||
const payload = { ...data };
|
||||
delete payload.id;
|
||||
|
||||
const keys = Object.keys(payload);
|
||||
const assignments = keys.map((key) => `${key} = ?`).join(", ");
|
||||
const sql = `UPDATE ${normalized} SET ${assignments} WHERE id = ?`;
|
||||
const values = keys.map((key) => {
|
||||
const value = payload[key];
|
||||
if (typeof value === "boolean") return value ? 1 : 0;
|
||||
return value;
|
||||
});
|
||||
|
||||
db.prepare(sql).run(...values, id);
|
||||
}
|
||||
|
||||
export function getCategoryItem(category: string, id: number): Record<string, unknown> | null {
|
||||
const db = getDatabase();
|
||||
const normalized = category.trim().toLowerCase();
|
||||
validateCategoryName(normalized);
|
||||
|
||||
const row = db.prepare(`SELECT * FROM ${normalized} WHERE id = ?`).get(id);
|
||||
if (!row) return null;
|
||||
|
||||
const result: Record<string, unknown> = { ...row };
|
||||
for (const [key, value] of Object.entries(result)) {
|
||||
if (typeof value === "number") {
|
||||
const template = BASE_COLUMNS.find((column) => column.name === key);
|
||||
if (!template) {
|
||||
// Heuristic: columns stored as BOOLEAN use 0/1
|
||||
// If column type is INTEGER but values only 0/1, mantain numeric to allow consumer decide.
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
+293
-69
@@ -3,8 +3,18 @@
|
||||
* Busca itens da tabela unificada items (PostgreSQL)
|
||||
*/
|
||||
|
||||
import { prisma } from "../app";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "../app";
|
||||
import { ensureItemsSeeded } from "../../items/import-from-sqlite";
|
||||
|
||||
export type ItemSort =
|
||||
| "name"
|
||||
| "level-asc"
|
||||
| "level-desc"
|
||||
| "weight-asc"
|
||||
| "weight-desc"
|
||||
| "category"
|
||||
| "updated-desc";
|
||||
|
||||
export interface Item {
|
||||
item_id: number;
|
||||
@@ -19,41 +29,178 @@ export interface Item {
|
||||
weight: number | null;
|
||||
slots: number | null;
|
||||
skill: string | null;
|
||||
metadata: any;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
metadata: Record<string, any>;
|
||||
created_at: Date | string;
|
||||
updated_at: Date | string;
|
||||
}
|
||||
|
||||
export interface ItemWithMeta extends Item {
|
||||
servers: string[];
|
||||
tags: string[];
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface SearchItemsParams {
|
||||
q?: string;
|
||||
category?: string;
|
||||
rarity?: string;
|
||||
server?: string;
|
||||
sort?: ItemSort;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca um item por ID unificado
|
||||
*/
|
||||
export async function getItemById(itemId: number): Promise<Item | null> {
|
||||
const item = await prisma.item.findUnique({
|
||||
where: { item_id: itemId },
|
||||
});
|
||||
export interface AdminSearchParams extends SearchItemsParams {
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
return null;
|
||||
export interface AdminItemsResult {
|
||||
items: ItemWithMeta[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
servers: string[];
|
||||
categories: string[];
|
||||
}
|
||||
|
||||
export interface ItemUpsertInput {
|
||||
item_name: string;
|
||||
category: string;
|
||||
rarity?: string | null;
|
||||
image_url?: string | null;
|
||||
image_local_path?: string | null;
|
||||
level?: number | null;
|
||||
weight?: number | null;
|
||||
slots?: number | null;
|
||||
skill?: string | null;
|
||||
servers?: string[];
|
||||
tags?: string[];
|
||||
description?: string | null;
|
||||
source_table_name?: string;
|
||||
source_table_id?: number;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
function buildOrder(sort: ItemSort): Prisma.ItemOrderByWithRelationInput[] {
|
||||
switch (sort) {
|
||||
case "level-asc":
|
||||
return [{ level: "asc" }, { item_name: "asc" }];
|
||||
case "level-desc":
|
||||
return [{ level: "desc" }, { item_name: "asc" }];
|
||||
case "weight-asc":
|
||||
return [{ weight: "asc" }, { item_name: "asc" }];
|
||||
case "weight-desc":
|
||||
return [{ weight: "desc" }, { item_name: "asc" }];
|
||||
case "category":
|
||||
return [{ category: "asc" }, { item_name: "asc" }];
|
||||
case "name":
|
||||
return [{ item_name: "asc" }];
|
||||
case "updated-desc":
|
||||
default:
|
||||
return [{ updated_at: "desc" }];
|
||||
}
|
||||
}
|
||||
|
||||
function parseMetadata(metadata: Prisma.JsonValue | null | undefined): Record<string, any> {
|
||||
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
|
||||
return {};
|
||||
}
|
||||
return metadata as Record<string, any>;
|
||||
}
|
||||
|
||||
function extractServers(meta: Record<string, any>): string[] {
|
||||
const servers = meta?.servers;
|
||||
if (Array.isArray(servers)) {
|
||||
return servers
|
||||
.map((server) => (typeof server === "string" ? server.trim() : ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (typeof servers === "string") {
|
||||
return servers
|
||||
.split(",")
|
||||
.map((server) => server.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function extractTags(meta: Record<string, any>): string[] {
|
||||
const tags = meta?.tags;
|
||||
if (Array.isArray(tags)) {
|
||||
return tags
|
||||
.map((tag) => (typeof tag === "string" ? tag.trim() : ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (typeof tags === "string") {
|
||||
return tags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function buildItem(raw: any): Item {
|
||||
const createdAt = raw.created_at instanceof Date ? raw.created_at.toISOString() : raw.created_at;
|
||||
const updatedAt = raw.updated_at instanceof Date ? raw.updated_at.toISOString() : raw.updated_at;
|
||||
|
||||
return {
|
||||
...item,
|
||||
weight: item.weight ? Number(item.weight) : null,
|
||||
metadata: item.metadata || {},
|
||||
...raw,
|
||||
weight: raw.weight ? Number(raw.weight) : null,
|
||||
metadata: parseMetadata(raw.metadata),
|
||||
created_at: createdAt,
|
||||
updated_at: updatedAt,
|
||||
} as Item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca um item por nome (exato ou similar)
|
||||
*/
|
||||
function buildItemWithMeta(raw: any): ItemWithMeta {
|
||||
const base = buildItem(raw);
|
||||
const servers = extractServers(base.metadata);
|
||||
const tags = extractTags(base.metadata);
|
||||
const description = typeof base.metadata.description === "string" ? base.metadata.description : null;
|
||||
|
||||
return {
|
||||
...base,
|
||||
servers,
|
||||
tags,
|
||||
description,
|
||||
};
|
||||
}
|
||||
|
||||
function toDecimal(value: number | null | undefined): Prisma.Decimal | null {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||
return null;
|
||||
}
|
||||
return new Prisma.Decimal(value);
|
||||
}
|
||||
|
||||
function buildMetadata(
|
||||
existing: Record<string, any>,
|
||||
overrides: { servers?: string[]; tags?: string[]; description?: string | null; metadata?: Record<string, any> }
|
||||
): Record<string, any> {
|
||||
const merged = { ...existing, ...(overrides.metadata || {}) };
|
||||
|
||||
if (overrides.servers) {
|
||||
merged.servers = overrides.servers;
|
||||
}
|
||||
if (overrides.tags) {
|
||||
merged.tags = overrides.tags;
|
||||
}
|
||||
if (overrides.description !== undefined) {
|
||||
merged.description = overrides.description;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
export async function getItemById(itemId: number): Promise<Item | null> {
|
||||
const item = await prisma.item.findUnique({ where: { item_id: itemId } });
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
return buildItem(item);
|
||||
}
|
||||
|
||||
export async function getItemByName(itemName: string): Promise<Item | null> {
|
||||
const item = await prisma.item.findFirst({
|
||||
where: {
|
||||
@@ -63,32 +210,42 @@ export async function getItemByName(itemName: string): Promise<Item | null> {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
weight: item.weight ? Number(item.weight) : null,
|
||||
metadata: item.metadata || {},
|
||||
} as Item;
|
||||
return buildItem(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca itens com filtros
|
||||
*/
|
||||
export async function searchItems(
|
||||
params: SearchItemsParams = {}
|
||||
): Promise<{ items: Item[]; total: number }> {
|
||||
export async function getItemBySource(sourceTableName: string, sourceTableId: number): Promise<Item | null> {
|
||||
const item = await prisma.item.findUnique({
|
||||
where: {
|
||||
source_table_name_source_table_id: {
|
||||
source_table_name: sourceTableName,
|
||||
source_table_id: sourceTableId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
return buildItem(item);
|
||||
}
|
||||
|
||||
export async function searchAdminItems(params: AdminSearchParams = {}): Promise<AdminItemsResult> {
|
||||
const {
|
||||
q = "",
|
||||
category,
|
||||
rarity,
|
||||
server,
|
||||
page = 1,
|
||||
pageSize = 24,
|
||||
pageSize = 50,
|
||||
} = params;
|
||||
|
||||
await ensureItemsSeeded();
|
||||
|
||||
const safePage = Math.max(1, page);
|
||||
const safePageSize = Math.min(Math.max(1, pageSize), 200);
|
||||
|
||||
const where: Prisma.ItemWhereInput = {};
|
||||
|
||||
if (q) {
|
||||
@@ -98,58 +255,125 @@ export async function searchItems(
|
||||
};
|
||||
}
|
||||
|
||||
if (category) {
|
||||
if (category && category !== "all") {
|
||||
where.category = category;
|
||||
}
|
||||
|
||||
if (rarity) {
|
||||
if (rarity && rarity !== "all") {
|
||||
where.rarity = rarity;
|
||||
}
|
||||
|
||||
const total = await prisma.item.count({ where });
|
||||
if (server && server !== "all") {
|
||||
const existingAnd = Array.isArray(where.AND)
|
||||
? [...where.AND]
|
||||
: where.AND
|
||||
? [where.AND]
|
||||
: [];
|
||||
|
||||
const items = await prisma.item.findMany({
|
||||
where,
|
||||
orderBy: { item_name: "asc" },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
existingAnd.push({
|
||||
metadata: {
|
||||
path: ["servers"],
|
||||
array_contains: [server],
|
||||
},
|
||||
});
|
||||
|
||||
where.AND = existingAnd;
|
||||
}
|
||||
|
||||
const [items, total, categoryRows] = await Promise.all([
|
||||
prisma.item.findMany({
|
||||
where,
|
||||
orderBy: buildOrder(params.sort || "updated-desc"),
|
||||
skip: (safePage - 1) * safePageSize,
|
||||
take: safePageSize,
|
||||
}),
|
||||
prisma.item.count({ where }),
|
||||
prisma.item.findMany({
|
||||
distinct: ["category"],
|
||||
select: { category: true },
|
||||
orderBy: { category: "asc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
const mapped = items.map(buildItemWithMeta);
|
||||
|
||||
const serversSet = new Set<string>();
|
||||
mapped.forEach((item) => {
|
||||
item.servers.forEach((srv) => serversSet.add(srv));
|
||||
});
|
||||
|
||||
return {
|
||||
items: items.map((item: any) => ({
|
||||
...item,
|
||||
weight: item.weight ? Number(item.weight) : null,
|
||||
metadata: item.metadata || {},
|
||||
})) as Item[],
|
||||
items: mapped,
|
||||
total,
|
||||
page: safePage,
|
||||
pageSize: safePageSize,
|
||||
servers: Array.from(serversSet).sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" })),
|
||||
categories: categoryRows
|
||||
.map((row) => row.category)
|
||||
.filter((c): c is string => Boolean(c))
|
||||
.sort((a, b) => a.localeCompare(b, "pt-BR", { sensitivity: "base" })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca item por source_table_name e source_table_id
|
||||
* (útil para migração ou compatibilidade com IDs antigos)
|
||||
*/
|
||||
export async function getItemBySource(
|
||||
sourceTableName: string,
|
||||
sourceTableId: number
|
||||
): Promise<Item | null> {
|
||||
const item = await prisma.item.findUnique({
|
||||
where: {
|
||||
source_table_name_source_table_id: {
|
||||
source_table_name: sourceTableName,
|
||||
source_table_id: sourceTableId,
|
||||
},
|
||||
},
|
||||
export async function searchItems(params: SearchItemsParams = {}): Promise<{ items: ItemWithMeta[]; total: number }> {
|
||||
const { items, total } = await searchAdminItems(params);
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
export async function createAdminItem(input: ItemUpsertInput): Promise<ItemWithMeta> {
|
||||
const now = Date.now();
|
||||
const data: Prisma.ItemCreateInput = {
|
||||
source_table_id: input.source_table_id ?? now,
|
||||
source_table_name: input.source_table_name ?? "custom",
|
||||
item_name: input.item_name,
|
||||
category: input.category,
|
||||
image_local_path: input.image_local_path ?? null,
|
||||
image_url: input.image_url ?? null,
|
||||
rarity: input.rarity ?? null,
|
||||
level: input.level ?? null,
|
||||
weight: toDecimal(input.weight),
|
||||
slots: input.slots ?? null,
|
||||
skill: input.skill ?? null,
|
||||
metadata: buildMetadata({}, {
|
||||
servers: input.servers ?? [],
|
||||
tags: input.tags ?? [],
|
||||
description: input.description ?? null,
|
||||
metadata: input.metadata ?? {},
|
||||
}),
|
||||
};
|
||||
|
||||
const created = await prisma.item.create({ data });
|
||||
return buildItemWithMeta(created);
|
||||
}
|
||||
|
||||
export async function updateAdminItem(itemId: number, input: ItemUpsertInput): Promise<ItemWithMeta> {
|
||||
const existing = await prisma.item.findUnique({ where: { item_id: itemId } });
|
||||
if (!existing) {
|
||||
throw new Error("Item não encontrado");
|
||||
}
|
||||
|
||||
const existingMeta = parseMetadata(existing.metadata);
|
||||
const metadata = buildMetadata(existingMeta, {
|
||||
servers: input.servers ?? extractServers(existingMeta),
|
||||
tags: input.tags ?? extractTags(existingMeta),
|
||||
description: input.description !== undefined ? input.description : existingMeta.description,
|
||||
metadata: input.metadata,
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
const data: Prisma.ItemUpdateInput = {
|
||||
item_name: input.item_name ?? existing.item_name,
|
||||
category: input.category ?? existing.category,
|
||||
image_local_path: input.image_local_path !== undefined ? input.image_local_path : existing.image_local_path,
|
||||
image_url: input.image_url !== undefined ? input.image_url : existing.image_url,
|
||||
rarity: input.rarity !== undefined ? input.rarity : existing.rarity,
|
||||
level: input.level !== undefined ? input.level : existing.level,
|
||||
weight: input.weight !== undefined ? toDecimal(input.weight) : existing.weight,
|
||||
slots: input.slots !== undefined ? input.slots : existing.slots,
|
||||
skill: input.skill !== undefined ? input.skill : existing.skill,
|
||||
metadata,
|
||||
};
|
||||
|
||||
return {
|
||||
...item,
|
||||
weight: item.weight ? Number(item.weight) : null,
|
||||
metadata: item.metadata || {},
|
||||
} as Item;
|
||||
const updated = await prisma.item.update({ where: { item_id: itemId }, data });
|
||||
return buildItemWithMeta(updated);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user