feat(items): add server field and improve item management

refactor(ai): enhance item structure generation with schema validation
feat(ai): add token usage tracking and cost calculation
fix(ui): improve item card number formatting with locale support
docs(items): add documentation for item value separation
test(items): add tests for value field validation
This commit is contained in:
Evandro Fonseca Junior committed 2025-11-11 03:04:49 -03:00
1 parent dc169f9eba
commit d3704602fd
22 files changed
+1317 -95

No files matched your search

+6 -2
View File
@@ -6,6 +6,7 @@ import { AdminItemCreatePage } from "@/components/admin/items/AdminItemCreatePag
import { getSession } from "@/lib/redis/session";
import { getUserById } from "@/lib/prisma/queries/users";
import { searchAdminItems } from "@/lib/prisma/queries/items";
import { getActiveServers } from "@/lib/prisma/queries/servers";
export async function generateMetadata(): Promise<Metadata> {
return {
@@ -38,7 +39,10 @@ export default async function AdminNewItemPage({
redirect(`/${locale}`);
}
const initialItems = await searchAdminItems({ page: 1, pageSize: 1 });
const [initialItems, activeServers] = await Promise.all([
searchAdminItems({ page: 1, pageSize: 1 }),
getActiveServers(),
]);
return (
<div className="min-h-screen bg-background">
@@ -47,7 +51,7 @@ export default async function AdminNewItemPage({
<AdminItemCreatePage
locale={locale}
categories={initialItems.categories ?? []}
serverOptions={initialItems.servers ?? []}
serverOptions={(activeServers ?? []).map((s) => s.server_name)}
/>
</main>
</div>
+44 -2
View File
@@ -1,6 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { requireAdminUser } from "@/lib/auth/middleware";
import { generateItemStructure } from "@/lib/ai/generate-item-structure";
import { calculateTokenCost } from "@/lib/ai/token-pricing";
import { recordTokenUsage } from "@/lib/ai/token-usage";
export async function POST(request: NextRequest) {
const auth = await requireAdminUser(request);
@@ -26,12 +28,16 @@ export async function POST(request: NextRequest) {
const images: string[] = Array.isArray(body?.images)
? body.images.filter((item: unknown) => typeof item === "string")
: [];
const hasDescription = !!description;
const hasImages = images.length > 0;
if (!name || !description) {
// Nova regra: aceitar quando houver descrição OU ao menos uma imagem.
// Nome pode ser vazio; o assistente do front lida com sugestão/confirmar_nome.
if (!hasDescription && !hasImages) {
return NextResponse.json(
{
success: false,
error: "Informe nome e descrição para gerar o JSON estrutural.",
error: "Forneça descrição ou ao menos uma imagem para análise.",
},
{ status: 400 }
);
@@ -57,10 +63,46 @@ export async function POST(request: NextRequest) {
.filter((image) => image.base64Data),
});
// Cálculo de custo baseado em usageMetadata
const model = (result.model ?? "gemini-2.5-flash") as keyof typeof import("@/lib/ai/token-pricing").TOKEN_PRICING;
const breakdown = calculateTokenCost("gemini-2.5-flash", result.usage);
// Persistir histórico para relatórios (não bloqueia resposta; erros são logados no Redis util)
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
try {
await recordTokenUsage({
id: requestId,
timestamp: Date.now(),
model: result.model ?? "gemini-2.5-flash",
inputTokens: breakdown.inputTokens,
outputTokens: breakdown.outputTokens,
totalTokens: breakdown.totalTokens,
costUsd: breakdown.totalUsd,
context: {
route: "/api/admin/items/analyze",
scope,
server,
hasImages,
hasDescription,
categoriesCount: categories.length,
},
});
} catch {
// Falhas de Redis não devem impedir a análise
}
return NextResponse.json(
{
success: true,
structure: result.structure,
usage: {
promptTokens: breakdown.inputTokens,
outputTokens: breakdown.outputTokens,
totalTokens: breakdown.totalTokens,
},
costUsd: breakdown.totalUsd,
model: result.model ?? "gemini-2.5-flash",
requestId,
},
{ status: 200 }
);
+10 -1
View File
@@ -129,6 +129,7 @@ export async function POST(request: NextRequest) {
const body = await request.json();
const name = typeof body?.name === "string" ? body.name.trim() : "";
const category = typeof body?.category === "string" ? body.category.trim().toLowerCase() : "";
const originServer = typeof body?.server === "string" ? body.server.trim() || null : null;
if (!name || !category) {
return NextResponse.json(
@@ -150,11 +151,19 @@ export async function POST(request: NextRequest) {
...dynamicFields,
} as Record<string, unknown>;
// Se enviado servidor de origem, inclui na linha do SQLite e garante presença na lista de servidores do item
if (originServer) {
sqliteData.server = originServer;
}
const sqliteId = insertCategoryItem({ category, data: sqliteData });
const metadataBase = typeof body?.metadata === "object" && body.metadata !== null ? body.metadata : {};
const metadata = { ...metadataBase, ...dynamicFields } as Record<string, unknown>;
const itemServers = parseList(body?.servers);
const finalServers = originServer ? Array.from(new Set([originServer, ...itemServers])) : itemServers;
const item = await createAdminItem({
item_name: name,
category,
@@ -165,7 +174,7 @@ export async function POST(request: NextRequest) {
weight: typeof body?.weight === "number" ? body.weight : null,
slots: typeof body?.slots === "number" ? body.slots : null,
skill: typeof body?.skill === "string" ? body.skill.trim() || null : null,
servers: parseList(body?.servers),
servers: finalServers,
tags: parseList(body?.tags),
description:
typeof body?.description === "string" ? body.description.trim() || null : null,
@@ -43,6 +43,7 @@ export function AdminItemCreatePage({ locale, categories, serverOptions }: Admin
tags: payload.tags,
metadata: payload.metadata,
fields: payload.fields,
server: payload.server ?? null,
};
const response = await fetch("/api/admin/items", {
@@ -32,6 +32,7 @@ export function AdminItemCreateView({ locale, categories, serverOptions }: Admin
tags: payload.tags,
metadata: payload.metadata,
fields: payload.fields,
server: payload.server ?? null,
};
const response = await fetch("/api/admin/items", {
+609 -43
View File
@@ -67,6 +67,7 @@ export interface ItemFormPayload {
tags: string[];
metadata?: Record<string, any>;
fields?: Record<string, string | boolean | null>;
server?: string | null;
}
interface ItemReference {
@@ -190,10 +191,22 @@ export function AdminItemForm({
const [skill, setSkill] = useState("");
const [serversCsv, setServersCsv] = useState("");
const [tagsCsv, setTagsCsv] = useState("");
const [originServer, setOriginServer] = useState<string>("");
const [analysis, setAnalysis] = useState<AnalysisResult>(null);
const [analysisLoading, setAnalysisLoading] = useState(false);
const [analysisError, setAnalysisError] = useState<string | null>(null);
const [tokenUsage, setTokenUsage] = useState<{
promptTokens: number;
outputTokens: number;
totalTokens: number;
costUsd: number;
} | null>(null);
const [accumulatedCostUsd, setAccumulatedCostUsd] = useState<number>(() => {
if (typeof window === "undefined") return 0;
const stored = window.localStorage.getItem("aiCostAccumulatedUsd");
return stored ? Number(stored) || 0 : 0;
});
const [submitStatus, setSubmitStatus] = useState<string | null>(null);
const [categoryTemplates, setCategoryTemplates] = useState<CategoryTemplate[]>([]);
@@ -216,6 +229,27 @@ export function AdminItemForm({
const dynamicInitializedRef = useRef(false);
const lastCategoryRef = useRef<string | null>(null);
const [pendingPrefill, setPendingPrefill] = useState<PrefillPayload | null>(null);
const [unsavedFields, setUnsavedFields] = useState<Set<string>>(new Set());
const [showPrefillModal, setShowPrefillModal] = useState(false);
type DiffEntry = { field: string; current: string | null; next: string | null };
const [prefillDiff, setPrefillDiff] = useState<{ changed: DiffEntry[]; added: DiffEntry[]; unchanged: DiffEntry[] }>({
changed: [],
added: [],
unchanged: [],
});
const [showExitConfirm, setShowExitConfirm] = useState(false);
const [aiSuggestedName, setAiSuggestedName] = useState(false);
const [confirmNome, setConfirmNome] = useState(false);
const markUnsaved = useCallback((key: string) => {
setUnsavedFields((prev) => {
const next = new Set(prev);
next.add(key);
return next;
});
}, []);
const isUnsaved = useCallback((key: string) => unsavedFields.has(key), [unsavedFields]);
const resetFormState = useCallback(
(templateList: CategoryTemplate[]) => {
@@ -415,7 +449,7 @@ export function AdminItemForm({
name: name.trim(),
description: description.trim(),
scope: serverOptions.length > 0 ? "server" : "global",
server: serverOptions[0] ?? null,
server: originServer || (serverOptions[0] ?? null),
}),
});
@@ -425,6 +459,23 @@ export function AdminItemForm({
const data = await res.json();
setAnalysis(data.structure ?? null);
if (data?.usage && typeof data?.costUsd === "number") {
setTokenUsage({
promptTokens: Number(data.usage.promptTokens || 0),
outputTokens: Number(data.usage.outputTokens || 0),
totalTokens: Number(data.usage.totalTokens || 0),
costUsd: Number(data.costUsd || 0),
});
setAccumulatedCostUsd((prev) => {
const next = Number(prev) + Number(data.costUsd || 0);
try {
if (typeof window !== "undefined") {
window.localStorage.setItem("aiCostAccumulatedUsd", String(next));
}
} catch {}
return next;
});
}
} catch (error) {
console.error(error);
setAnalysisError(
@@ -449,6 +500,7 @@ export function AdminItemForm({
dynamicInitializedRef.current = false;
lastCategoryRef.current = null;
setCategory(value.toLowerCase());
markUnsaved("category");
}, []);
const handleAddCategoryField = useCallback(() => {
@@ -587,20 +639,134 @@ export function AdminItemForm({
const submitLabel = isEdit ? "Salvar alterações" : "Criar item";
const normalizeValue = (value: unknown): string | null => {
if (value === null || value === undefined) return null;
if (Array.isArray(value)) return value.join(", ");
return String(value);
};
const getCurrentSnapshot = useCallback(() => {
return {
name,
category,
rarity,
imageUrl,
imageLocalPath,
level,
weight,
slots,
skill,
servers: parseCsv(serversCsv),
tags: parseCsv(tagsCsv),
description,
metadata: { ...prefilledMetadata, ...dynamicValues },
server: originServer || null,
};
}, [name, category, rarity, imageUrl, imageLocalPath, level, weight, slots, skill, serversCsv, tagsCsv, description, prefilledMetadata, dynamicValues, originServer]);
const computePrefillDiff = useCallback((prefill: PrefillPayload) => {
const current = getCurrentSnapshot();
const changed: DiffEntry[] = [];
const added: DiffEntry[] = [];
const unchanged: DiffEntry[] = [];
const topKeys: Array<keyof PrefillPayload> = [
"name",
"category",
"rarity",
"imageUrl",
"imageLocalPath",
"level",
"weight",
"slots",
"skill",
"servers",
"tags",
"description",
];
for (const key of topKeys) {
if (prefill[key] === undefined) continue;
const next = normalizeValue(
key === "servers" || key === "tags"
? Array.isArray(prefill[key])
? (prefill[key] as string[])
: (String(prefill[key] ?? "").split(",").map((v) => v.trim()).filter(Boolean))
: prefill[key]
);
const currentVal = normalizeValue((current as any)[key]);
const field = key === "servers" ? "servers" : key === "tags" ? "tags" : (key as string);
if (currentVal !== next) {
if (currentVal === null || currentVal === "") {
added.push({ field, current: currentVal, next });
} else {
changed.push({ field, current: currentVal, next });
}
} else {
unchanged.push({ field, current: currentVal, next });
}
}
const meta = prefill.metadata && typeof prefill.metadata === "object" ? prefill.metadata : {};
for (const [k, v] of Object.entries(meta as Record<string, unknown>)) {
if (k === "category") continue;
const currentVal = normalizeValue(current.metadata[k]);
const next = normalizeValue(v);
const field = `meta.${k}`;
if (currentVal !== next) {
if (currentVal === null || currentVal === "") {
added.push({ field, current: currentVal, next });
} else {
changed.push({ field, current: currentVal, next });
}
} else {
unchanged.push({ field, current: currentVal, next });
}
}
return { changed, added, unchanged };
}, [getCurrentSnapshot]);
const markUnsavedFromDiff = useCallback((diff: { changed: DiffEntry[]; added: DiffEntry[] }) => {
const next = new Set<string>();
for (const d of [...diff.changed, ...diff.added]) {
next.add(d.field);
}
setUnsavedFields(next);
}, []);
const renderUnsavedTag = (field: string) =>
unsavedFields.has(field) ? (
<span className="ml-2 text-[10px] text-amber-600">Não salvo</span>
) : null;
const handlePrimarySubmit = useCallback(async () => {
if (!name.trim()) {
setSubmitStatus("Informe o nome do item.");
return;
}
if (aiSuggestedName && !confirmNome) {
setSubmitStatus("Confirme o nome sugerido pela IA.");
return;
}
if (!category.trim()) {
setSubmitStatus("Informe a categoria do item.");
return;
}
if (!originServer || !originServer.trim()) {
setSubmitStatus("Selecione o servidor de origem.");
return;
}
setSubmitStatus(null);
try {
const metadataPayload: Record<string, unknown> = { ...prefilledMetadata };
if (aiSuggestedName) {
metadataPayload.confirmar_nome = confirmNome;
}
if (allowAiTools && analysis) {
metadataPayload.aiStructure = analysis;
}
@@ -624,6 +790,7 @@ export function AdminItemForm({
tags: parseCsv(tagsCsv),
metadata: metadataPayload,
fields: dynamicValues,
server: originServer,
});
setSubmitStatus(isEdit ? "Alterações salvas com sucesso." : "Item criado com sucesso.");
@@ -671,9 +838,39 @@ export function AdminItemForm({
void handlePrimarySubmit();
};
const handleCancelClick = useCallback(() => {
if (unsavedFields.size > 0) {
setShowExitConfirm(true);
return;
}
onCancel?.();
}, [unsavedFields, onCancel]);
const formClassName = className ?? "flex h-full flex-col";
const bodyClassName = contentClassName ?? "flex-1 min-h-0 overflow-y-auto px-6 py-4";
const [prefillLoading, setPrefillLoading] = useState(false);
const [prefillAnimating, setPrefillAnimating] = useState(false);
const [prefillStatus, setPrefillStatus] = useState<string | null>(null);
const formattedAnalysis = useMemo(() => {
if (!analysis) return null;
try {
const cloned: any = JSON.parse(JSON.stringify(analysis));
const cols = cloned?.columns;
if (cols && typeof cols.description === "string") {
const s = cols.description as string;
if (s.length > 140) {
// Insere quebras de linha após vírgulas para melhorar legibilidade
cols.description = s.replace(/,\s+/g, ",\n");
}
}
return cloned;
} catch {
return analysis;
}
}, [analysis]);
const applyPrefill = useCallback(
(prefill: PrefillPayload) => {
if (prefill.name !== undefined) setName(prefill.name ?? "");
@@ -717,6 +914,11 @@ export function AdminItemForm({
if (template) {
setDynamicValues(buildDynamicInitialValues(template, prefill.metadata));
}
const origem = typeof prefill.metadata?.origem_dados === "string" ? (prefill.metadata as any).origem_dados : null;
const hasAutoName = prefill.name !== undefined && String(prefill.name ?? "").trim().length > 0;
setAiSuggestedName(Boolean(hasAutoName && origem === "automatico"));
setConfirmNome(false);
}
},
[
@@ -735,13 +937,36 @@ export function AdminItemForm({
useEffect(() => {
if (!pendingPrefill) return;
if (categoryTemplates.length === 0) return;
const diff = computePrefillDiff(pendingPrefill);
setPrefillDiff(diff);
setShowPrefillModal(true);
}, [pendingPrefill, categoryTemplates, computePrefillDiff]);
const handleConfirmPrefill = useCallback(() => {
if (!pendingPrefill) return;
applyPrefill(pendingPrefill);
const diff = computePrefillDiff(pendingPrefill);
markUnsavedFromDiff(diff);
setPendingPrefill(null);
}, [applyPrefill, pendingPrefill, categoryTemplates]);
setShowPrefillModal(false);
}, [pendingPrefill, applyPrefill, computePrefillDiff, markUnsavedFromDiff]);
const hasUnsaved = unsavedFields.size > 0;
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (hasUnsaved) {
e.preventDefault();
e.returnValue = "";
}
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, [hasUnsaved]);
return (
<>
<form onSubmit={handleFormSubmit} className={formClassName}>
<form onSubmit={handleFormSubmit} className={formClassName} aria-busy={prefillLoading} aria-live="polite">
{headerSlot?.({ title, description: descriptionText }) ??
(
<div className="shrink-0 px-6 pt-6">
@@ -754,18 +979,42 @@ export function AdminItemForm({
<div className="grid min-w-0 gap-6 lg:grid-cols-[1.15fr,0.85fr]">
<div className="space-y-4 min-w-0">
<div className="space-y-2">
<Label htmlFor="item-name">Nome</Label>
<Label htmlFor="item-name">Nome {isUnsaved("name") && (
<Badge variant="outline" className="ml-2 text-amber-600 border-amber-500">Não salvo</Badge>
)}</Label>
<Input
id="item-name"
placeholder="Ex.: Final Fate"
value={name}
onChange={(event) => setName(event.target.value)}
onChange={(event) => {
setName(event.target.value);
setAiSuggestedName(false);
setConfirmNome(false);
markUnsaved("name");
}}
/>
{aiSuggestedName && (
<div className="flex items-center gap-2">
<Checkbox
id="confirm-ai-name"
checked={confirmNome}
onCheckedChange={(checked) => {
setConfirmNome(Boolean(checked));
markUnsaved("confirmar_nome");
}}
/>
<Label htmlFor="confirm-ai-name" className="text-xs">
Confirmar nome sugerido pela IA
</Label>
</div>
)}
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label>Categoria</Label>
<Label>Categoria {isUnsaved("category") && (
<Badge variant="outline" className="ml-2 text-amber-600 border-amber-500">Não salvo</Badge>
)}</Label>
<div className="flex flex-wrap items-center gap-2">
<Select
value={category}
@@ -823,75 +1072,110 @@ export function AdminItemForm({
)}
</div>
<div className="space-y-2">
<Label htmlFor="item-rarity">Raridade</Label>
<Label htmlFor="item-rarity">Raridade {isUnsaved("rarity") && (
<Badge variant="outline" className="ml-2 text-amber-600 border-amber-500">Não salvo</Badge>
)}</Label>
<Input
id="item-rarity"
placeholder="Ex.: legendary"
value={rarity}
onChange={(event) => setRarity(event.target.value)}
onChange={(event) => {
setRarity(event.target.value);
markUnsaved("rarity");
}}
/>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="item-image-url">Imagem (URL)</Label>
<Label htmlFor="item-image-url">Imagem (URL) {isUnsaved("imageUrl") && (
<Badge variant="outline" className="ml-2 text-amber-600 border-amber-500">Não salvo</Badge>
)}</Label>
<Input
id="item-image-url"
placeholder="https://..."
value={imageUrl}
onChange={(event) => setImageUrl(event.target.value)}
onChange={(event) => {
setImageUrl(event.target.value);
markUnsaved("imageUrl");
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="item-image-local">Imagem local (caminho)</Label>
<Label htmlFor="item-image-local">Imagem local (caminho) {isUnsaved("imageLocalPath") && (
<Badge variant="outline" className="ml-2 text-amber-600 border-amber-500">Não salvo</Badge>
)}</Label>
<Input
id="item-image-local"
placeholder="Opcional"
value={imageLocalPath}
onChange={(event) => setImageLocalPath(event.target.value)}
onChange={(event) => {
setImageLocalPath(event.target.value);
markUnsaved("imageLocalPath");
}}
/>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="item-level">Level requerido</Label>
<Label htmlFor="item-level">Level requerido {isUnsaved("level") && (
<Badge variant="outline" className="ml-2 text-amber-600 border-amber-500">Não salvo</Badge>
)}</Label>
<Input
id="item-level"
placeholder="Ex.: 250"
value={level}
onChange={(event) => setLevel(event.target.value)}
onChange={(event) => {
setLevel(event.target.value);
markUnsaved("level");
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="item-weight">Peso</Label>
<Label htmlFor="item-weight">Peso {isUnsaved("weight") && (
<Badge variant="outline" className="ml-2 text-amber-600 border-amber-500">Não salvo</Badge>
)}</Label>
<Input
id="item-weight"
placeholder="Ex.: 45"
value={weight}
onChange={(event) => setWeight(event.target.value)}
onChange={(event) => {
setWeight(event.target.value);
markUnsaved("weight");
}}
/>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="item-slots">Slots</Label>
<Label htmlFor="item-slots">Slots {isUnsaved("slots") && (
<Badge variant="outline" className="ml-2 text-amber-600 border-amber-500">Não salvo</Badge>
)}</Label>
<Input
id="item-slots"
placeholder="Ex.: 2"
value={slots}
onChange={(event) => setSlots(event.target.value)}
onChange={(event) => {
setSlots(event.target.value);
markUnsaved("slots");
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="item-skill">Skill requerido</Label>
<Label htmlFor="item-skill">Skill requerido {isUnsaved("skill") && (
<Badge variant="outline" className="ml-2 text-amber-600 border-amber-500">Não salvo</Badge>
)}</Label>
<Input
id="item-skill"
placeholder="Ex.: Sword 115"
value={skill}
onChange={(event) => setSkill(event.target.value)}
onChange={(event) => {
setSkill(event.target.value);
markUnsaved("skill");
}}
/>
</div>
</div>
@@ -899,7 +1183,11 @@ export function AdminItemForm({
{dynamicColumns.length > 0 && (
<div className="space-y-4">
<div>
<Label className="text-sm font-semibold">Campos específicos da categoria</Label>
<Label className="text-sm font-semibold">
Campos específicos da categoria {isUnsaved("fields") && (
<Badge variant="outline" className="ml-2 text-amber-600 border-amber-500">Não salvo</Badge>
)}
</Label>
<p className="text-xs text-muted-foreground">
Defina valores adicionais conforme o modelo da categoria selecionada.
</p>
@@ -918,7 +1206,9 @@ export function AdminItemForm({
}
/>
<Label htmlFor={`dynamic-${column.name}`} className="capitalize">
{column.name.replace(/_/g, " ")}
{column.name.replace(/_/g, " ")}{isUnsaved(`fields.${column.name}`) && (
<Badge variant="outline" className="ml-2 text-amber-600 border-amber-500">Não salvo</Badge>
)}
</Label>
</div>
);
@@ -930,7 +1220,9 @@ export function AdminItemForm({
return (
<div key={column.name} className="space-y-2">
<Label htmlFor={`dynamic-${column.name}`} className="capitalize">
{column.name.replace(/_/g, " ")}
{column.name.replace(/_/g, " ")}{isUnsaved(`fields.${column.name}`) && (
<Badge variant="outline" className="ml-2 text-amber-600 border-amber-500">Não salvo</Badge>
)}
</Label>
<Input
id={`dynamic-${column.name}`}
@@ -943,9 +1235,10 @@ export function AdminItemForm({
? ""
: String(value)
}
onChange={(event) =>
handleDynamicValueChange(column, event.target.value)
}
onChange={(event) => {
handleDynamicValueChange(column, event.target.value);
markUnsaved(`fields.${column.name}`);
}}
/>
</div>
);
@@ -955,12 +1248,42 @@ export function AdminItemForm({
)}
<div className="space-y-2">
<Label htmlFor="item-servers">Servidores (separados por vírgula)</Label>
<Label htmlFor="origin-server">Servidor de origem {isUnsaved("server") && (
<Badge variant="outline" className="ml-2 text-amber-600 border-amber-500">Não salvo</Badge>
)}</Label>
<Select
value={originServer}
onValueChange={(value) => {
setOriginServer(value);
markUnsaved("server");
}}
>
<SelectTrigger id="origin-server" className="w-full">
<SelectValue placeholder="Selecione um servidor" />
</SelectTrigger>
<SelectContent>
{serverOptions.map((opt) => (
<SelectItem key={opt} value={opt}>{opt}</SelectItem>
))}
</SelectContent>
</Select>
{!originServer && (
<p className="text-[11px] text-destructive">Campo obrigatório. Selecione o servidor de origem.</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="item-servers">Servidores (separados por vírgula) {isUnsaved("servers") && (
<Badge variant="outline" className="ml-2 text-amber-600 border-amber-500">Não salvo</Badge>
)}</Label>
<Input
id="item-servers"
placeholder="Ex.: Ferobra, Boneka"
value={serversCsv}
onChange={(event) => setServersCsv(event.target.value)}
onChange={(event) => {
setServersCsv(event.target.value);
markUnsaved("servers");
}}
/>
{serverSuggestions && (
<p className="text-[11px] text-muted-foreground">
@@ -970,24 +1293,34 @@ export function AdminItemForm({
</div>
<div className="space-y-2">
<Label htmlFor="item-tags">Tags adicionais</Label>
<Label htmlFor="item-tags">Tags adicionais {isUnsaved("tags") && (
<Badge variant="outline" className="ml-2 text-amber-600 border-amber-500">Não salvo</Badge>
)}</Label>
<Input
id="item-tags"
placeholder="Ex.: endgame, two-handed"
value={tagsCsv}
onChange={(event) => setTagsCsv(event.target.value)}
onChange={(event) => {
setTagsCsv(event.target.value);
markUnsaved("tags");
}}
/>
</div>
<div className="space-y-2">
<Label htmlFor="item-description">Descrição detalhada</Label>
<Textarea
id="item-description"
placeholder="Cole aqui a descrição oficial do item..."
rows={6}
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
<Label htmlFor="item-description">Descrição detalhada {isUnsaved("description") && (
<Badge variant="outline" className="ml-2 text-amber-600 border-amber-500">Não salvo</Badge>
)}</Label>
<Textarea
id="item-description"
placeholder="Cole aqui a descrição oficial do item..."
rows={6}
value={description}
onChange={(event) => {
setDescription(event.target.value);
markUnsaved("description");
}}
/>
</div>
<div className="flex flex-wrap gap-2 pt-2">
@@ -996,12 +1329,100 @@ export function AdminItemForm({
{analysisLoading ? "Analisando..." : "Analisar com IA"}
</Button>
)}
{allowAiTools && (
<Button
type="button"
variant="secondary"
onClick={async () => {
try {
setPrefillStatus(null);
if (!analysis) {
setPrefillStatus("Gere o JSON com IA antes de preencher automaticamente.");
return;
}
setPrefillLoading(true);
// Construir prefill a partir de analysis { table, columns }
const columns = (analysis as any)?.columns ?? {};
const table = (analysis as any)?.table ?? null;
const pick = (obj: any, keyList: string[]): any => {
for (const k of keyList) {
if (obj && obj[k] !== undefined && obj[k] !== null) return obj[k];
}
return undefined;
};
const topName = pick(columns, ["nome", "name", "item_name"]);
const imageUrlPref = pick(columns, ["image_url", "url", "imagem"]);
const imageLocalPref = pick(columns, ["image_local_path", "local_path"]);
const rarityPref = pick(columns, ["rarity", "raridade"]);
const levelPref = pick(columns, ["level", "nivel"]);
const weightPref = pick(columns, ["weight", "peso"]);
const slotsPref = pick(columns, ["slots"]);
const skillPref = pick(columns, ["skill", "habilidade"]);
const descriptionPref = pick(columns, ["description", "descricao"]);
const serversPref = pick(columns, ["servers", "servidores"]);
const tagsPref = pick(columns, ["tags", "etiquetas"]);
// Montar metadata com demais chaves
const baseKeys = new Set([
"nome","name","item_name","image_url","url","imagem","image_local_path","local_path","rarity","raridade","level","nivel","weight","peso","slots","skill","habilidade","description","descricao","servers","servidores","tags","etiquetas","category","categoria"
]);
const metadata: Record<string, unknown> = {};
for (const [k, v] of Object.entries(columns)) {
if (!baseKeys.has(k)) {
metadata[k] = v as unknown;
}
}
if (table && typeof table === "string") {
metadata.category = table.toLowerCase();
}
const prefill: PrefillPayload = {
name: topName !== undefined ? String(topName) : undefined,
category: table ? String(table).toLowerCase() : undefined,
rarity: rarityPref !== undefined ? String(rarityPref) : undefined,
imageUrl: imageUrlPref !== undefined ? String(imageUrlPref) : undefined,
imageLocalPath: imageLocalPref !== undefined ? String(imageLocalPref) : undefined,
level: levelPref !== undefined ? (typeof levelPref === "number" ? levelPref : String(levelPref)) : undefined,
weight: weightPref !== undefined ? (typeof weightPref === "number" ? weightPref : String(weightPref)) : undefined,
slots: slotsPref !== undefined ? (typeof slotsPref === "number" ? slotsPref : String(slotsPref)) : undefined,
skill: skillPref !== undefined ? String(skillPref) : undefined,
description: descriptionPref !== undefined ? String(descriptionPref) : undefined,
servers: serversPref !== undefined ? (Array.isArray(serversPref) ? serversPref : String(serversPref)) : undefined,
tags: tagsPref !== undefined ? (Array.isArray(tagsPref) ? tagsPref : String(tagsPref)) : undefined,
metadata,
};
// Aplicar prefill e marcar campos alterados
const diff = computePrefillDiff(prefill);
setPrefillAnimating(true);
applyPrefill(prefill);
markUnsavedFromDiff(diff);
setPrefillStatus("Campos preenchidos automaticamente com base no JSON.");
setTimeout(() => setPrefillAnimating(false), 600);
} catch (e) {
console.error(e);
setPrefillStatus(e instanceof Error ? e.message : "Falha ao preencher automaticamente.");
} finally {
setPrefillLoading(false);
}
}}
disabled={analysisLoading || prefillLoading}
aria-label="Preencher automaticamente a partir do JSON"
aria-disabled={analysisLoading || prefillLoading}
>
{prefillLoading ? "Preenchendo..." : "Preencher Automaticamente"}
</Button>
)}
<Button type="submit" disabled={submitting}>
{submitting ? "Salvando..." : submitLabel}
</Button>
</div>
{submitStatus && <p className="text-xs text-muted-foreground">{submitStatus}</p>}
{prefillStatus && <p className="text-xs text-muted-foreground">{prefillStatus}</p>}
</div>
<div className="space-y-4 min-w-0">
@@ -1019,10 +1440,130 @@ export function AdminItemForm({
{analysisError}
</div>
)}
<div className="max-h-64 overflow-auto rounded-md border bg-muted/40 p-3 text-[11px]">
<pre className="whitespace-pre-wrap">
{analysis ? JSON.stringify(analysis, null, 2) : "// Aguarde a análise para visualizar o JSON"}
{tokenUsage && (
<div className="flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
<Badge variant="outline" className="px-2 py-1">
Tokens: in {tokenUsage.promptTokens} · out {tokenUsage.outputTokens} · total {tokenUsage.totalTokens}
</Badge>
<Badge variant="outline" className="px-2 py-1">
Custo (req): US$ {tokenUsage.costUsd.toFixed(6)}
</Badge>
<Badge variant="outline" className="px-2 py-1">
Acumulado (sessão): US$ {accumulatedCostUsd.toFixed(6)}
</Badge>
</div>
)}
<div className={`max-w-full w-full rounded-md border bg-muted/40 p-0 ${prefillAnimating ? "animate-pulse" : ""}`} role="region" aria-label="Pré-visualização do JSON gerado">
<pre className="whitespace-pre overflow-x-auto max-w-full w-full min-w-0 p-4 font-mono rounded-md shadow-sm bg-muted text-sm" aria-live="polite">
{formattedAnalysis ? JSON.stringify(formattedAnalysis, null, 2) : "// Aguarde a análise para visualizar o JSON"}
</pre>
{formattedAnalysis && (formattedAnalysis as any)?.schemaSuggestion && (
<div className="p-3 border-t bg-background">
<p className="text-xs text-muted-foreground mb-2">Sugestão de esquema (SQLite)</p>
<div className="flex flex-wrap items-center gap-2 mb-2">
<Badge variant="outline" className="px-2 py-1 uppercase">
{(formattedAnalysis as any).schemaSuggestion.table || "tabela"}
</Badge>
</div>
<div className="text-[11px] text-muted-foreground">
{Array.isArray((formattedAnalysis as any).schemaSuggestion.columns) ? (
<ul className="list-disc ml-5">
{(formattedAnalysis as any).schemaSuggestion.columns.map((col: any, idx: number) => (
<li key={idx}>
{String(col?.name ?? "coluna")} : {String(col?.type ?? "TEXT")}
</li>
))}
</ul>
) : (
<p>Sem colunas sugeridas.</p>
)}
</div>
</div>
)}
<div className="p-3 border-t bg-background flex items-center gap-2">
<Button
type="button"
variant="secondary"
onClick={async () => {
try {
setPrefillStatus(null);
if (!analysis) {
setPrefillStatus("Gere o JSON com IA antes de preencher automaticamente.");
return;
}
setPrefillLoading(true);
const columns = (analysis as any)?.columns ?? {};
const table = (analysis as any)?.table ?? null;
const pick = (obj: any, keyList: string[]): any => {
for (const k of keyList) {
if (obj && obj[k] !== undefined && obj[k] !== null) return obj[k];
}
return undefined;
};
const topName = pick(columns, ["nome", "name", "item_name"]);
const imageUrlPref = pick(columns, ["image_url", "url", "imagem"]);
const imageLocalPref = pick(columns, ["image_local_path", "local_path"]);
const rarityPref = pick(columns, ["rarity", "raridade"]);
const levelPref = pick(columns, ["level", "nivel"]);
const weightPref = pick(columns, ["weight", "peso"]);
const slotsPref = pick(columns, ["slots"]);
const skillPref = pick(columns, ["skill", "habilidade"]);
const descriptionPref = pick(columns, ["description", "descricao"]);
const serversPref = pick(columns, ["servers", "servidores"]);
const tagsPref = pick(columns, ["tags", "etiquetas"]);
const baseKeys = new Set([
"nome","name","item_name","image_url","url","imagem","image_local_path","local_path","rarity","raridade","level","nivel","weight","peso","slots","skill","habilidade","description","descricao","servers","servidores","tags","etiquetas","category","categoria"
]);
const metadata: Record<string, unknown> = {};
for (const [k, v] of Object.entries(columns)) {
if (!baseKeys.has(k)) {
metadata[k] = v as unknown;
}
}
if (table && typeof table === "string") {
metadata.category = table.toLowerCase();
}
const prefill: PrefillPayload = {
name: topName !== undefined ? String(topName) : undefined,
category: table ? String(table).toLowerCase() : undefined,
rarity: rarityPref !== undefined ? String(rarityPref) : undefined,
imageUrl: imageUrlPref !== undefined ? String(imageUrlPref) : undefined,
imageLocalPath: imageLocalPref !== undefined ? String(imageLocalPref) : undefined,
level: levelPref !== undefined ? (typeof levelPref === "number" ? levelPref : String(levelPref)) : undefined,
weight: weightPref !== undefined ? (typeof weightPref === "number" ? weightPref : String(weightPref)) : undefined,
slots: slotsPref !== undefined ? (typeof slotsPref === "number" ? slotsPref : String(slotsPref)) : undefined,
skill: skillPref !== undefined ? String(skillPref) : undefined,
description: descriptionPref !== undefined ? String(descriptionPref) : undefined,
servers: serversPref !== undefined ? (Array.isArray(serversPref) ? serversPref : String(serversPref)) : undefined,
tags: tagsPref !== undefined ? (Array.isArray(tagsPref) ? tagsPref : String(tagsPref)) : undefined,
metadata,
};
const diff = computePrefillDiff(prefill);
setPrefillAnimating(true);
applyPrefill(prefill);
markUnsavedFromDiff(diff);
setPrefillStatus("Campos preenchidos automaticamente com base no JSON.");
setTimeout(() => setPrefillAnimating(false), 600);
} catch (e) {
console.error(e);
setPrefillStatus(e instanceof Error ? e.message : "Falha ao preencher automaticamente.");
} finally {
setPrefillLoading(false);
}
}}
disabled={analysisLoading || prefillLoading}
aria-label="Preencher automaticamente a partir do JSON"
aria-disabled={analysisLoading || prefillLoading}
>
{prefillLoading ? "Preenchendo..." : "Preencher Automaticamente"}
</Button>
</div>
</div>
</CardContent>
</Card>
@@ -1056,11 +1597,11 @@ export function AdminItemForm({
isEdit,
submitting,
submitLabel,
onCancel,
onCancel: handleCancelClick,
onSubmitClick: handlePrimarySubmit,
}) ?? (
<div className="flex items-center justify-end gap-2 px-6 pb-6">
<Button type="button" variant="outline" onClick={onCancel} disabled={submitting}>
<Button type="button" variant="outline" onClick={handleCancelClick} disabled={submitting}>
Cancelar
</Button>
<Button type="submit" disabled={submitting}>
@@ -1070,6 +1611,31 @@ export function AdminItemForm({
)}
</form>
<Dialog open={showExitConfirm} onOpenChange={setShowExitConfirm}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Descartar alterações?</DialogTitle>
<DialogDescription>
Existem alterações não salvas. Deseja sair sem salvar?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowExitConfirm(false)}>
Continuar editando
</Button>
<Button
variant="destructive"
onClick={() => {
setShowExitConfirm(false);
onCancel?.();
}}
>
Descartar alterações
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showNewCategoryModal} onOpenChange={setShowNewCategoryModal}>
<DialogContent className="max-w-lg max-h-[80vh] overflow-y-auto">
<DialogHeader>
+122 -16
View File
@@ -55,6 +55,7 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
const [data, setData] = useState<AdminItemsResponse>(initialData);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [apiStatus, setApiStatus] = useState<{ code: number | null; ok: boolean | null; durationMs: number | null; checkedAt: string | null }>({ code: null, ok: null, durationMs: null, checkedAt: null });
const [serverFilter, setServerFilter] = useState<string>("all");
const [search, setSearch] = useState<string>("");
@@ -66,33 +67,132 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
const [modalOpen, setModalOpen] = useState(false);
const [selectedItem, setSelectedItem] = useState<AdminItemRecord | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [activeServers, setActiveServers] = useState<string[]>([]);
const [serversLoading, setServersLoading] = useState<boolean>(false);
const searchDebounce = useRef<number | undefined>(undefined);
function isAdminItemsResponse(val: any): val is AdminItemsResponse {
return (
val &&
Array.isArray(val.items) &&
typeof val.total === "number" &&
typeof val.page === "number" &&
typeof val.pageSize === "number" &&
Array.isArray(val.servers) &&
Array.isArray(val.categories)
);
}
// Carrega servidores ativos independentemente dos itens cadastrados
useEffect(() => {
let cancelled = false;
async function loadServers() {
try {
setServersLoading(true);
const res = await fetch("/api/servers", {
credentials: "include",
headers: { Accept: "application/json" },
cache: "no-store",
});
if (!res.ok) {
console.warn("[AdminItemManager] falha ao buscar servidores ativos:", res.status);
return;
}
const json = await res.json();
const names: string[] = Array.isArray(json?.servers)
? json.servers
.map((s: any) => (typeof s?.serverName === "string" ? s.serverName : null))
.filter((n: string | null): n is string => Boolean(n))
: [];
if (!cancelled) {
setActiveServers(names);
}
} catch (e) {
console.warn("[AdminItemManager] erro ao buscar /api/servers:", e);
} finally {
if (!cancelled) setServersLoading(false);
}
}
loadServers();
return () => {
cancelled = true;
};
}, []);
const fetchItems = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (search.trim()) params.set("q", search.trim());
if (serverFilter !== "all") params.set("server", serverFilter);
if (categoryFilter !== "all") params.set("category", categoryFilter);
if (rarityFilter !== "all") params.set("rarity", rarityFilter);
params.set("page", String(page));
params.set("pageSize", String(pageSize));
const params = new URLSearchParams();
if (search.trim()) params.set("q", search.trim());
if (serverFilter !== "all") params.set("server", serverFilter);
if (categoryFilter !== "all") params.set("category", categoryFilter);
if (rarityFilter !== "all") params.set("rarity", rarityFilter);
params.set("page", String(page));
params.set("pageSize", String(pageSize));
const res = await fetch(`/api/admin/items?${params.toString()}`, {
const url = `/api/admin/items?${params.toString()}`;
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), 15000);
const start = performance.now();
console.group("[AdminItemManager] fetchItems");
console.log("URL:", url);
console.log("QueryParams:", Object.fromEntries(params.entries()));
try {
const res = await fetch(url, {
credentials: "include",
headers: { Accept: "application/json" },
cache: "no-store",
signal: controller.signal,
});
const durationMs = Math.round(performance.now() - start);
setApiStatus({ code: res.status, ok: res.ok, durationMs, checkedAt: new Date().toISOString() });
console.log("Status:", res.status, res.statusText, "Duration:", `${durationMs}ms`);
console.log("Headers:", {
"content-type": res.headers.get("content-type"),
"x-powered-by": res.headers.get("x-powered-by"),
});
if (!res.ok) {
throw new Error("Não foi possível carregar os itens.");
let errorPayload: any = null;
try {
errorPayload = await res.json();
} catch {
// pode não ser JSON
}
const baseMsg =
res.status === 401
? "Sessão expirada ou não autenticado. Faça login e tente novamente."
: res.status === 403
? "Acesso restrito ao administrador."
: "Não foi possível carregar os itens.";
const apiMsg = errorPayload?.error || errorPayload?.message || null;
const composed = apiMsg ? `${baseMsg} (${apiMsg})` : `${baseMsg} (HTTP ${res.status})`;
console.error("Erro na API:", composed, "Payload:", errorPayload);
throw new Error(composed);
}
const json = await res.json();
if (!isAdminItemsResponse(json)) {
console.error("Resposta inválida da API:", json);
throw new Error("Resposta inválida da API de itens.");
}
const json = (await res.json()) as AdminItemsResponse;
setData(json);
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Falha ao carregar itens.");
console.log("Itens carregados:", { total: json.total, page: json.page, pageSize: json.pageSize });
} catch (err: any) {
const durationMs = Math.round(performance.now() - start);
setApiStatus((prev) => ({ code: prev.code, ok: false, durationMs, checkedAt: new Date().toISOString() }));
if (err?.name === "AbortError") {
console.error("Requisição abortada por timeout");
setError("Tempo de resposta excedido. Tente novamente.");
} else {
console.error("Falha ao carregar itens:", err);
setError(err instanceof Error ? err.message : "Falha ao carregar itens.");
}
} finally {
console.groupEnd();
window.clearTimeout(timeoutId);
setLoading(false);
}
}, [search, serverFilter, categoryFilter, rarityFilter, page, pageSize]);
@@ -235,8 +335,14 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
))}
</SelectContent>
</Select>
<div className="text-sm text-muted-foreground flex items-center">
<div className="text-sm text-muted-foreground flex items-center gap-2">
{loading ? "Carregando..." : `${data.total} itens encontrados`}
{apiStatus.code !== null && (
<Badge variant="outline" className="text-xs">
API: {apiStatus.code} {apiStatus.ok ? "OK" : "Erro"}
{apiStatus.durationMs !== null ? ` • ${apiStatus.durationMs}ms` : ""}
</Badge>
)}
</div>
</div>
</CardContent>
@@ -397,7 +503,7 @@ export function AdminItemManager({ initialData, locale }: AdminItemManagerProps)
mode="edit"
item={selectedItem ?? undefined}
categories={data.categories}
serverOptions={data.servers}
serverOptions={activeServers.length ? activeServers : data.servers}
onSubmit={handleSubmit}
submitting={isSubmitting}
/>
+80 -7
View File
@@ -40,15 +40,28 @@ export function ItemAiAssistant({
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [rawJson, setRawJson] = useState<string | null>(null);
const [logs, setLogs] = useState<string[]>([]);
const disabled = useMemo(() => !name.trim() || !description.trim(), [name, description]);
// Habilita geração se houver ao menos uma imagem válida OU nome+descrição preenchidos
const canGenerate = useMemo(() => {
const hasValidImage = images.length > 0;
const hasTextInputs = name.trim() && description.trim();
return Boolean(hasValidImage || hasTextInputs);
}, [images.length, name, description]);
const disabled = useMemo(() => !canGenerate, [canGenerate]);
const handleGenerate = useCallback(async () => {
if (disabled || loading) return;
setLoading(true);
setError(null);
setLogs((prev) => [...prev, "Iniciando geração com IA..."]);
try {
setLogs((prev) => [
...prev,
`Payload: nome='${name.trim()}', escopo='${scope}', imagens=${images.length}, servidor='${scope === "server" ? server : "(global)"}'`,
]);
const response = await fetch("/api/admin/items/analyze", {
method: "POST",
credentials: "include",
@@ -73,13 +86,57 @@ export function ItemAiAssistant({
const data = await response.json();
const structure = data?.structure ?? data;
const columns = structure?.columns;
setLogs((prev) => [...prev, `Resposta recebida. Tamanho ~${JSON.stringify(structure).length} bytes.`]);
setRawJson(JSON.stringify(structure, null, 2));
// Heurística: sugerir nomes com base em imagens quando não houver texto detectado
const suggestNamesFromImages = (imgs: ProcessedImage[]): string[] => {
if (!Array.isArray(imgs) || imgs.length === 0) return [];
const first = imgs[0];
const base = first.originalName
.replace(/\.[a-zA-Z0-9]+$/, "")
.toLowerCase()
.replace(/[_\s]+/g, "-");
const common = new Set(["image", "img", "screenshot", "captura", "photo", "foto", "unknown"]);
const orientation = first.height > first.width ? "retrato" : "paisagem";
const suggestions: string[] = [];
if (base && !common.has(base)) suggestions.push(base);
suggestions.push(`${orientation}-1`);
suggestions.push("foto-1");
return Array.from(new Set(suggestions)).slice(0, 3);
};
const autoNameDetected = typeof columns?.name === "string" && Boolean(String(columns.name).trim());
const autoDescDetected = typeof columns?.description === "string" && Boolean(String(columns.description).trim());
const origemDados: string = autoNameDetected || autoDescDetected ? "automatico" : "nao_detectado";
const sugestoesNome = !autoNameDetected ? suggestNamesFromImages(images) : [];
// Estrutura final com campos obrigatórios, origem e sugestões
const finalStructure = {
...structure,
columns: {
...columns,
origem_dados: origemDados,
confirmar_nome: autoNameDetected ? false : undefined,
mensagens: {
obrigatorio: ["nome"],
dicas: autoNameDetected
? "Confirme o nome sugerido ou ajuste se necessário."
: "Informe um nome para o item. Descrição é opcional, mas ajuda.",
},
sugestoes: sugestoesNome.length > 0 ? { nome: sugestoesNome } : undefined,
},
};
setRawJson(JSON.stringify(finalStructure, null, 2));
const prefill: ItemAiPrefill = {
name: typeof columns?.name === "string" ? columns.name : undefined,
description: typeof columns?.description === "string" ? columns.description : undefined,
metadata: typeof columns === "object" && columns !== null ? columns : undefined,
name: autoNameDetected ? (columns!.name as string) : undefined,
description: autoDescDetected ? (columns!.description as string) : undefined,
metadata: typeof columns === "object" && columns !== null
? { ...columns, origem_dados: origemDados, confirmar_nome: autoNameDetected ? false : undefined, sugestoes: sugestoesNome.length ? { nome: sugestoesNome } : undefined }
: { origem_dados: origemDados, confirmar_nome: autoNameDetected ? false : undefined, sugestoes: sugestoesNome.length ? { nome: sugestoesNome } : undefined },
};
if (columns) {
@@ -103,12 +160,15 @@ export function ItemAiAssistant({
}
}
onGenerated({ json: structure, prefill });
setLogs((prev) => [...prev, "Sugestões de preenchimento geradas. Aguardando confirmação do usuário."]);
onGenerated({ json: finalStructure, prefill });
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Erro ao processar análise de IA.");
setLogs((prev) => [...prev, `Erro: ${err instanceof Error ? err.message : String(err)}`]);
} finally {
setLoading(false);
setLogs((prev) => [...prev, "Processo de geração concluído."]);
}
}, [
categories,
@@ -199,6 +259,9 @@ export function ItemAiAssistant({
<div className="space-y-2">
<label className="text-sm font-medium">Prints / evidências visuais</label>
<ItemImageCapture onImagesChange={setImages} />
{images.length > 0 && (
<p className="text-xs text-emerald-600">Imagem recebida — pronta para processamento.</p>
)}
</div>
{error && (
@@ -213,7 +276,7 @@ export function ItemAiAssistant({
Gerar com IA
</Button>
<p className="text-xs text-muted-foreground">
A IA retornará um JSON com sugestões de campos. Eles serão aplicados automaticamente no formulário.
A IA retornará um JSON com sugestões de campos. As alterações só serão aplicadas após sua confirmação.
</p>
</div>
</div>
@@ -233,6 +296,16 @@ export function ItemAiAssistant({
campos do formulário.
</p>
)}
{logs.length > 0 && (
<div className="space-y-1">
<p className="text-[11px] text-muted-foreground">Logs do processo</p>
<div className="rounded-md border bg-muted/30 p-2">
{logs.map((line, idx) => (
<p key={idx} className="text-[11px] leading-relaxed">• {line}</p>
))}
</div>
</div>
)}
</CardContent>
</Card>
</div>
@@ -202,6 +202,10 @@ export function ItemImageCapture({
>
<X className="h-4 w-4" />
</button>
<span className="absolute left-2 top-2 inline-flex items-center gap-1 rounded-full bg-emerald-600/90 px-2 py-0.5 text-[11px] text-white">
<span className="h-1.5 w-1.5 rounded-full bg-white" />
Pronta
</span>
<img
src={image.previewUrl}
alt={image.originalName}
+6 -4
View File
@@ -5,7 +5,7 @@ import { MagicCard } from "@/components/ui/magic-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { PriceDisplay } from "@/components/ui/price-display";
import { useTranslations } from "next-intl";
import { useTranslations, useLocale } from "next-intl";
import {
TrendingUp,
TrendingDown,
@@ -39,6 +39,7 @@ import {
getRarityBgColor,
getRarityGlow,
} from "@/lib/currency-utils";
import { formatNumber } from "@/lib/utils/formatters";
interface ItemCardProps {
item: TibiaItem;
@@ -47,6 +48,7 @@ interface ItemCardProps {
export function ItemCard({ item, mode = "market" }: ItemCardProps) {
const t = useTranslations("home.item");
const locale = useLocale();
const [isFavorited, setIsFavorited] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const formatVocation = (v: string | undefined): string | undefined => {
@@ -659,21 +661,21 @@ export function ItemCard({ item, mode = "market" }: ItemCardProps) {
<TrendingUp className="h-3 w-3" />
<span className="font-medium">{t("highest")}</span>
</div>
<div className="font-bold">{item.priceHistory.highest.toLocaleString()} gp</div>
<div className="font-bold">{formatNumber(item.priceHistory.highest, locale)} gp</div>
</div>
<div className="text-center space-y-1">
<div className="flex items-center justify-center gap-1 text-red-600">
<TrendingDown className="h-3 w-3" />
<span className="font-medium">{t("lowest")}</span>
</div>
<div className="font-bold">{item.priceHistory.lowest.toLocaleString()} gp</div>
<div className="font-bold">{formatNumber(item.priceHistory.lowest, locale)} gp</div>
</div>
<div className="text-center space-y-1">
<div className="flex items-center justify-center gap-1 text-muted-foreground">
<Minus className="h-3 w-3" />
<span className="font-medium">{t("average")}</span>
</div>
<div className="font-bold">{item.priceHistory.average.toLocaleString()} gp</div>
<div className="font-bold">{formatNumber(item.priceHistory.average, locale)} gp</div>
</div>
</div>
</div>
Binary file not shown.
Binary file not shown.
+42
View File
@@ -0,0 +1,42 @@
# Itens sem valores associados
Esta entidade (`Item`) foi projetada para armazenar somente dados estáticos do item (nome, categoria, atributos e metadados) e **não** deve conter qualquer informação de preço/valor. Valores de compra, venda, lance, histórico de preços e afins pertencem ao modelo `Listing`.
## Estrutura do Item (obrigatórios e opcionais)
- `item_name` (obrigatório): Nome do item.
- `category` (obrigatório): Categoria normalizada (ex.: `weapon`, `armor`, `helmet`, `accessory`, etc.).
- `image_local_path` (opcional)
- `image_url` (opcional)
- `rarity` (opcional)
- `level` (opcional)
- `weight` (opcional)
- `slots` (opcional)
- `skill` (opcional)
- `metadata` (opcional): Campos dinâmicos e específicos do item (atributos, servidores, tags, descrição). **Nunca inclua campos de valor aqui.**
## Validação contra campos de valor
Qualquer tentativa de incluir campos de valor/preço no `metadata` do item é **rejeitada** pela validação no servidor.
Chaves bloqueadas (exemplos):
- `price`, `prices`, `valor`, `value`, `sell_value`, `sellPrice`, `buy`, `buy_price`
- Chaves de `Listing`: `price_pix`, `price_tibia_coins`, `price_gold_coins`, `initial_price_*`, `current_bid_amount`
Mensagem retornada: _"Itens não possuem campos de valor/preço no cadastro. Use listings para preços."_
## Por que separar?
- Consistência: O mesmo item pode ter múltiplos anúncios com preços diferentes.
- Integridade: Evita confusão e mistura de responsabilidades entre `Item` e `Listing`.
- Evolução: Permite histórico de preços e leilões sem alterar o cadastro estático do item.
## Fluxo recomendado
1. **Cadastre o Item** com seus atributos e metadados (sem preços).
2. **Crie Listings** para anunciar o item com preço, quantidade, leilão, etc.
---
Documentação criada para clarificar que `Item` é livre de valores associados e que as funções de criação/atualização validam esse contrato.
+92 -9
View File
@@ -1,5 +1,6 @@
import { secureLogger } from "@/lib/secure-logger";
import { buildGeminiParts, buildItemPrompt, InlineImageData } from "@/lib/ai/item-prompt";
import { listCategoryTemplates } from "@/lib/items/sqlite-manager";
interface GenerateItemStructureInput {
name: string;
@@ -17,6 +18,12 @@ interface GeminiResponse {
columns: Record<string, unknown>;
};
raw: unknown;
usage?: {
promptTokenCount?: number;
candidatesTokenCount?: number;
totalTokenCount?: number;
};
model?: string;
}
const FALLBACK_STRUCTURE = {
@@ -24,7 +31,7 @@ const FALLBACK_STRUCTURE = {
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.",
"Usado no slot de tocha. Bônus em leech, velocidade e proteção física.",
attributes: {
shielding: 20,
death_magic: 120,
@@ -52,8 +59,37 @@ export async function generateItemStructure(
}
const url =
"https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent?key=" +
apiKey;
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent";
// Coleta estrutura do SQLite (somente nomes de tabelas, colunas e tipos) e injeta no prompt.
let sqliteSchemaText = "- Nenhuma tabela encontrada";
try {
const templates = listCategoryTemplates();
if (templates.length > 0) {
const lines: string[] = ["Estruturas atuais no SQLite:"];
templates.forEach((tpl) => {
lines.push(`- Tabela: ${tpl.name}`);
tpl.columns.forEach((col) => {
lines.push(` - ${col.name}: ${col.type}`);
});
});
sqliteSchemaText = lines.join("\n");
}
} catch (e) {
sqliteSchemaText = "[Falha ao listar SQLite]";
}
const extra = [
// Instruções específicas para exigência de saída fixa
"Inclua SEMPRE o campo fixo 'schemaSuggestion' com a melhor tabela existente ou a sugestão de nova, e a lista de colunas com tipos (TEXT|INTEGER|REAL|BOOLEAN).",
"Nunca inclua dados reais do SQLite; use somente a estrutura listada.",
// Reforços de consistência e proibições
"Para escopo global, a tabela deve ser 'items_all'. Para escopo de servidor, use 'items_<slug_do_servidor>' com slug minúsculo, '_' como separador, sem caracteres não alfanuméricos.",
"Servidores em 'columns.servers' DEVEM pertencer à lista fornecida no contexto; prefira um array de strings.",
"Em 'columns.metadata', insira apenas atributos descritivos. É proibido incluir campos de valor/preço/mercado: price, value, buy, sell, currency, npc_price, marketPrice, minPrice, maxPrice, avgPrice.",
"Garanta que 'columns.description' tenha no máximo 100 caracteres; se maior, recorte exatamente os primeiros 100 sem sufixos.",
sqliteSchemaText,
].join("\n");
const prompt = await buildItemPrompt(
{
@@ -64,18 +100,24 @@ export async function generateItemStructure(
categories: input.categories,
servers: input.servers,
},
input.images && input.images.length > 0
? "Duas imagens de contexto foram fornecidas como partes inline (após o prompt). Utilize-as para compreender atributos visuais."
: undefined,
[
input.images && input.images.length > 0
? "Duas imagens de contexto foram fornecidas como partes inline (após o prompt). Utilize-as para compreender atributos visuais."
: null,
extra,
]
.filter(Boolean)
.join("\n"),
);
const parts = await buildGeminiParts(prompt, input.images);
try {
async function callOnce(): Promise<any> {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-goog-api-key": apiKey,
},
body: JSON.stringify({
contents: [
@@ -91,11 +133,46 @@ export async function generateItemStructure(
}),
});
const bodyText = await response.text();
const bodyJson = (() => {
try {
return JSON.parse(bodyText);
} catch {
return { raw: bodyText };
}
})();
if (!response.ok) {
throw new Error(`Gemini retornou status ${response.status}`);
const status = response.status;
const msg = bodyJson?.error?.message || `Gemini retornou status ${status}`;
const err = new Error(msg);
(err as any).status = status;
(err as any).response = bodyJson;
throw err;
}
const data = await response.json();
return bodyJson;
}
try {
// Retry simples para 429 (rate limit): até 2 tentativas com backoff.
let attempts = 0;
let data: any;
while (true) {
try {
data = await callOnce();
break;
} catch (e) {
const status = (e as any)?.status;
attempts++;
if (status === 429 && attempts <= 2) {
const delayMs = 500 * attempts;
await new Promise((r) => setTimeout(r, delayMs));
continue;
}
throw e;
}
}
const text =
data?.candidates?.[0]?.content?.parts?.[0]?.text ??
data?.candidates?.[0]?.output_text;
@@ -114,15 +191,21 @@ export async function generateItemStructure(
return {
structure: parsed,
raw: data,
usage: data?.usageMetadata,
model: "gemini-2.5-flash",
};
} catch (error) {
secureLogger.error("❌ Falha ao gerar estrutura via Gemini", {
error: error instanceof Error ? error.message : "Erro desconhecido",
status: (error as any)?.status,
response: (error as any)?.response,
});
return {
structure: FALLBACK_STRUCTURE,
raw: FALLBACK_STRUCTURE,
usage: undefined,
model: "gemini-2.5-flash",
};
}
}
+34 -10
View File
@@ -56,7 +56,7 @@ Front-end fields (AdminItemForm):
- skill: string opcional
- servers: lista de servidores (separados por vírgula)
- tags: lista de tags (texto)
- description: texto longo
- description: texto (máximo 100 caracteres)
- dynamic metadata: campos variáveis definidos por categoria (servidores, atributos, etc)
`.trim();
@@ -79,7 +79,9 @@ Você é um assistente que prepara payloads JSON para cadastro de itens no banco
INSTRUÇÕES IMPORTANTES:
- Responda SOMENTE com JSON válido. Nenhum texto fora do JSON.
- Se não tiver certeza, retorne null nos campos desconhecidos.
- Utilize ao máximo a estrutura de tabelas e campos informada abaixo.
- Utilize ao máximo a estrutura de tabelas e campos informada abaixo.
- Você receberá somente a estrutura das tabelas SQLite (nomes, colunas e tipos). Use esse esquema para escolher uma tabela existente adequada ou sugerir uma nova. Nunca inclua dados reais.
- Campo "description": limite máximo de 100 caracteres. Se a descrição original exceder 100 caracteres, retorne apenas os primeiros 100 caracteres sem acrescentar sufixos (sem "..."), e nunca gere texto além desse limite.
Contexto fornecido pelo usuário:
- Nome: ${context.name}
@@ -103,19 +105,41 @@ Formato obrigatório da resposta:
{
"table": "nome_da_tabela",
"columns": {
"name": "...",
"description": "...",
"rarity": "...",
"metadata": {...},
"outros_campos": ...
"name": string | null,
"description": string | null,
"category": string | null,
"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[] | null,
"tags": string[] | null,
"metadata": object | null
},
"schemaSuggestion": {
"table": "nome_da_tabela_existente_ou_nova",
"columns": [
{ "name": "coluna_1", "type": "TEXT|INTEGER|REAL|BOOLEAN" },
{ "name": "coluna_2", "type": "TEXT|INTEGER|REAL|BOOLEAN" }
]
}
}
Consistência:
- Escolha a tabela coerente com o escopo. Para global, use "items" ou equivalente.
- Escolha a tabela coerente com o escopo. Para global, use "items_all".
- Para escopo de servidor, use "items_<slug_do_servidor>", onde <slug> é o nome do servidor em minúsculas, com espaços transformados em "_" e removendo caracteres não alfanuméricos.
- Inclua sempre os campos básicos: name, description.
- Aplique outros campos conforme a descrição (ex.: atributos, requisitos, bônus).
- Se houver informações que encaixem em metadados dinâmicos, use um objeto "metadata".
- Nunca produza "description" com mais de 100 caracteres.
Regras adicionais:
- Servidores ("servers"): utilize SOMENTE nomes presentes na lista fornecida no contexto. Prefira o formato array de strings (ex.: ["Belobra", "Antica"]). Se não aplicável, use null.
- Metadata: inclua apenas atributos descritivos (ex.: "attributes", "requirements", "protections", "flags"). É PROIBIDO incluir qualquer campo de valor/preço/mercado. Não utilize chaves: "price", "value", "buy", "sell", "currency", "npc_price", "marketPrice", "minPrice", "maxPrice", "avgPrice".
- Tipos: números devem ser números (sem aspas). Se o valor for desconhecido, retorne null. Não invente dados.
${extraInstructions ?? ""}
`.trim();
@@ -127,13 +151,13 @@ export interface InlineImageData {
}
export async function buildGeminiParts(prompt: string, images: InlineImageData[] = []) {
const parts: Array<{ text?: string; inlineData?: { mimeType: string; data: string } }> = [
const parts: Array<{ text?: string; inline_data?: { mimeType: string; data: string } }> = [
{ text: prompt },
];
images.slice(0, 2).forEach((image) => {
parts.push({
inlineData: {
inline_data: {
mimeType: image.mimeType,
data: image.base64Data,
},
+61
View File
@@ -0,0 +1,61 @@
/**
* Utilitário de preços de tokens do Gemini e cálculo de custo por requisição
* Baseado em preços públicos aproximados para o modelo `gemini-2.5-flash`.
* Valores podem variar; mantenha sob revisão se houver mudança na tabela oficial.
*/
export interface UsageMetadata {
promptTokenCount?: number;
candidatesTokenCount?: number;
totalTokenCount?: number;
}
export interface TokenCostBreakdown {
inputTokens: number;
outputTokens: number;
totalTokens: number;
inputUsd: number;
outputUsd: number;
totalUsd: number;
}
type PricingTable = {
[model: string]: {
inputPerMillionTextImageVideo: number; // USD por 1M tokens de entrada (text/image/video)
outputPerMillion: number; // USD por 1M tokens de saída
};
};
// Tabela simplificada; ajuste se usar outros modelos no futuro
export const TOKEN_PRICING: PricingTable = {
"gemini-2.5-flash": {
inputPerMillionTextImageVideo: 0.15, // USD
outputPerMillion: 1.25, // USD
},
};
/**
* Calcula custo em USD baseado em usageMetadata retornado pela API Gemini.
*/
export function calculateTokenCost(
model: keyof typeof TOKEN_PRICING,
usage: UsageMetadata | null | undefined,
): TokenCostBreakdown {
const pricing = TOKEN_PRICING[model] ?? TOKEN_PRICING["gemini-2.5-flash"];
const inputTokens = Math.max(0, Number(usage?.promptTokenCount || 0));
const outputTokens = Math.max(0, Number(usage?.candidatesTokenCount || 0));
const totalTokens = Math.max(0, Number(usage?.totalTokenCount || inputTokens + outputTokens));
const inputUsd = (inputTokens / 1_000_000) * pricing.inputPerMillionTextImageVideo;
const outputUsd = (outputTokens / 1_000_000) * pricing.outputPerMillion;
const totalUsd = inputUsd + outputUsd;
return {
inputTokens,
outputTokens,
totalTokens,
inputUsd,
outputUsd,
totalUsd,
};
}
+34
View File
@@ -0,0 +1,34 @@
import { getRedisClient } from "../redis";
export interface TokenUsageRecord {
id: string; // uuid/id da requisição (pode ser requestId)
timestamp: number;
model: string;
inputTokens: number;
outputTokens: number;
totalTokens: number;
costUsd: number;
context?: Record<string, unknown>; // dados opcionais (ex.: rota, itemId)
}
const KEY_PREFIX = "ai:usage";
export async function recordTokenUsage(record: TokenUsageRecord): Promise<void> {
const redis = await getRedisClient();
const dayKey = `${KEY_PREFIX}:day:${new Date(record.timestamp).toISOString().slice(0, 10)}`; // YYYY-MM-DD
const listKey = `${KEY_PREFIX}:records`;
// Armazenar agregado diário
const pipeline = redis.multi();
pipeline.hIncrBy(dayKey, "inputTokens", record.inputTokens);
pipeline.hIncrBy(dayKey, "outputTokens", record.outputTokens);
pipeline.hIncrBy(dayKey, "totalTokens", record.totalTokens);
pipeline.hIncrByFloat(dayKey, "costUsd", record.costUsd);
// Armazenar registro detalhado (para relatórios)
pipeline.lPush(listKey, JSON.stringify(record));
// Manter os últimos 10k registros para não crescer indefinidamente (ajuste conforme necessidade)
pipeline.lTrim(listKey, 0, 9999);
await pipeline.exec();
}
+27
View File
@@ -211,11 +211,38 @@ export interface UpsertItemInput {
data: Record<string, unknown>;
}
function ensureServerColumn(category: string) {
const db = getDatabase();
const normalized = category.trim().toLowerCase();
validateCategoryName(normalized);
const pragma = db.prepare(`PRAGMA table_info(${normalized})`).all() as Array<{
name: string;
type: string;
notnull: number;
pk: number;
}>;
const hasServer = pragma.some((col) => col.name === "server");
if (!hasServer) {
// Adiciona coluna 'server' como TEXT opcional
addColumnToCategory({
category: normalized,
column: { name: "server", type: "TEXT", notNull: false, editable: true },
});
}
}
export function insertCategoryItem(input: UpsertItemInput): number {
const db = getDatabase();
const category = input.category.trim().toLowerCase();
validateCategoryName(category);
// Garante que a coluna 'server' exista na tabela de destino.
try {
ensureServerColumn(category);
} catch {}
const data = { ...input.data };
delete data.id;
+52
View File
@@ -6,6 +6,7 @@
import { Prisma } from "@prisma/client";
import { prisma } from "../app";
import { ensureItemsSeeded } from "../../items/import-from-sqlite";
import { secureLogger } from "../../secure-logger";
export type ItemSort =
| "name"
@@ -193,6 +194,53 @@ function buildMetadata(
return merged;
}
// Campos relacionados a valores/preços que NÃO pertencem ao modelo Item.
// Qualquer presença deles em metadata deve ser rejeitada.
const FORBIDDEN_VALUE_KEYS = new Set<string>([
// Geral
"price",
"prices",
"valor",
"value",
"sell_value",
"sellPrice",
"buy",
"buy_price",
// Chaves usadas em Listings
"price_pix",
"price_tibia_coins",
"price_gold_coins",
"initial_price_pix",
"initial_price_tibia_coins",
"initial_price_gold_coins",
"current_bid_amount",
]);
/**
* Verifica recursivamente se há campos proibidos relacionados a valores/preços.
* Lança erro se encontrar.
*/
export function assertNoValueFieldsInMetadata(meta: Record<string, any> | undefined | null, context?: string): void {
if (!meta || typeof meta !== "object") return;
const stack: Array<{ obj: Record<string, any>; path: string }> = [{ obj: meta as Record<string, any>, path: "metadata" }];
while (stack.length) {
const { obj, path } = stack.pop()!;
for (const key of Object.keys(obj)) {
const nextPath = `${path}.${key}`;
if (FORBIDDEN_VALUE_KEYS.has(key)) {
const msg = `Itens não possuem campos de valor/preço no cadastro. Campo proibido: ${nextPath}. Use listings para preços.`;
secureLogger.warn(msg, { context });
throw new Error(msg);
}
const val = obj[key];
if (val && typeof val === "object" && !Array.isArray(val)) {
stack.push({ obj: val as Record<string, any>, path: nextPath });
}
}
}
}
export async function getItemById(itemId: number): Promise<Item | null> {
const item = await prisma.item.findUnique({ where: { item_id: itemId } });
if (!item) {
@@ -322,6 +370,8 @@ export async function searchItems(params: SearchItemsParams = {}): Promise<{ ite
export async function createAdminItem(input: ItemUpsertInput): Promise<ItemWithMeta> {
const now = Date.now();
// Validação: impedir qualquer campo de valor em metadata (preço etc.)
assertNoValueFieldsInMetadata(input.metadata, "createAdminItem");
const data: Prisma.ItemCreateInput = {
source_table_id: input.source_table_id ?? now,
source_table_name: input.source_table_name ?? "custom",
@@ -353,6 +403,8 @@ export async function updateAdminItem(itemId: number, input: ItemUpsertInput): P
}
const existingMeta = parseMetadata(existing.metadata);
// Validação: impedir qualquer campo de valor em metadata (preço etc.)
assertNoValueFieldsInMetadata(input.metadata, "updateAdminItem");
const metadata = buildMetadata(existingMeta, {
servers: input.servers ?? extractServers(existingMeta),
tags: input.tags ?? extractTags(existingMeta),
+28
View File
@@ -446,3 +446,31 @@ export function validatePasswordRequirements(password: string): {
};
}
/**
* Mapeia o locale da aplicação (ex: "pt", "en") para um BCP 47 válido
* para APIs de formatação (ex: "pt-BR", "en-US").
*/
export function resolveBCP47Locale(locale?: string): string {
switch (locale) {
case "pt":
return "pt-BR";
case "en":
return "en-US";
default:
// Fallback consistente para evitar divergência SSR/cliente
return "pt-BR";
}
}
/**
* Formata números com locale consistente, evitando mismatch de hidratação
* entre SSR e cliente.
*/
export function formatNumber(value: number, locale?: string): string {
try {
return value.toLocaleString(resolveBCP47Locale(locale));
} catch {
return String(value);
}
}
+2 -1
View File
@@ -15,7 +15,8 @@
"db:test": "tsx lib/db/test-connection.ts",
"prisma:generate": "prisma generate --schema=prisma/schema.prisma",
"prisma:studio": "prisma studio --schema=prisma/schema.prisma",
"postinstall": "prisma generate --schema=prisma/schema.prisma"
"postinstall": "prisma generate --schema=prisma/schema.prisma",
"test": "tsx tests/run-tests.ts"
},
"keywords": [
"tibia marketplace",
+62
View File
@@ -0,0 +1,62 @@
import assert from "node:assert";
import fs from "node:fs";
import path from "node:path";
import { assertNoValueFieldsInMetadata } from "../lib/prisma/queries/items";
type TestCase = { name: string; fn: () => Promise<void> | void };
const tests: TestCase[] = [];
function test(name: string, fn: () => Promise<void> | void) {
tests.push({ name, fn });
}
// Teste 1: criação sem valores (validação não lança)
test("Validação permite metadata sem campos de valor", () => {
const cleanMeta = {
attributes: { protection_physical: 0, speed: 500 },
servers: ["Antica"],
tags: ["relic"],
};
assert.doesNotThrow(() => assertNoValueFieldsInMetadata(cleanMeta, "test1"));
});
// Teste 2: rejeita tentativas de incluir valores
test("Validação rejeita metadata com campos de preço/valor", () => {
const badMeta = {
attributes: { speed: 500 },
price: 100000,
};
assert.throws(() => assertNoValueFieldsInMetadata(badMeta, "test2"));
});
// Teste 3: Consistência do schema do Item (sem campos de valor)
test("Schema Prisma do Item não possui campos de preço/valor", () => {
const schemaPath = path.resolve(process.cwd(), "prisma", "schema.prisma");
const content = fs.readFileSync(schemaPath, "utf8");
const itemBlockMatch = content.match(/model\s+Item\s+\{[\s\S]*?\}/);
assert.ok(itemBlockMatch, "Bloco model Item não encontrado no schema.prisma");
const itemBlock = itemBlockMatch![0];
const forbidden = [/price/i, /value/i, /sell_value/i, /buy_price/i];
for (const re of forbidden) {
assert.ok(!re.test(itemBlock), `Campo proibido detectado no Item: ${re}`);
}
});
async function run() {
let passed = 0;
for (const t of tests) {
try {
await t.fn();
passed++;
console.log(`✓ ${t.name}`);
} catch (e) {
console.error(`✗ ${t.name}`);
console.error(e);
process.exitCode = 1;
}
}
console.log(`\n${passed}/${tests.length} testes passaram.`);
}
run();