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.
278 lines
8.0 KiB
TypeScript
278 lines
8.0 KiB
TypeScript
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;
|
|
}
|