From 679ea752bbb8dee62dd1432789fca6f64db5bad0 Mon Sep 17 00:00:00 2001 From: Felipe Coutinho Date: Tue, 14 Apr 2026 00:26:48 +0000 Subject: [PATCH] =?UTF-8?q?feat(logo):=20integra=C3=A7=C3=A3o=20Logo.dev?= =?UTF-8?q?=20para=20logos=20autom=C3=A1ticos=20de=20estabelecimentos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nova tabela `establishment_logos` no schema (userId + nameKey → domain) - Utilitários: `buildLogoDevUrl`, `toNameKey`, `logoQueryKeys`, `LOGO_DEV_TOKEN` - `EstablishmentLogo`: exibe logo via Logo.dev com fallback para iniciais; hover mostra ícone de edição - `EstablishmentLogoPicker`: popover para buscar e fixar domínio Logo.dev por estabelecimento - API routes: `GET /api/logo/mapping` e `GET /api/logo/search` - Server actions/queries para persistência do mapeamento por usuário - CSP: libera `https://img.logo.dev` em `img-src` - `.env.example`: variáveis `NEXT_PUBLIC_LOGO_DEV_TOKEN` e `LOGO_DEV_SECRET_KEY` Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 15 +- src/app/api/logo/mapping/route.ts | 26 +++ src/app/api/logo/search/route.ts | 80 ++++++++ src/db/schema.ts | 30 +++ .../components/table/transactions-columns.tsx | 2 +- src/proxy.ts | 6 +- .../establishment-logo-picker.tsx | 187 ++++++++++++++++++ .../entity-avatar/establishment-logo.tsx | 110 ++++++++++- src/shared/components/entity-avatar/index.ts | 1 + .../lib/logo/establishment-logo-actions.ts | 64 ++++++ .../lib/logo/establishment-logo-queries.ts | 46 +++++ src/shared/lib/logo/index.ts | 23 +++ 12 files changed, 579 insertions(+), 11 deletions(-) create mode 100644 src/app/api/logo/mapping/route.ts create mode 100644 src/app/api/logo/search/route.ts create mode 100644 src/shared/components/entity-avatar/establishment-logo-picker.tsx create mode 100644 src/shared/lib/logo/establishment-logo-actions.ts create mode 100644 src/shared/lib/logo/establishment-logo-queries.ts diff --git a/.env.example b/.env.example index e94bf6f..2f91bc9 100644 --- a/.env.example +++ b/.env.example @@ -3,10 +3,10 @@ # ============================================ # === Database === -# PostgreSQL local (Docker): use host "db" -# PostgreSQL local (sem Docker): use host "localhost" +# Desenvolvimento local (pnpm dev): use host "localhost" (padrão abaixo) +# Docker Compose completo: o compose.yml define DATABASE_URL automaticamente com host "db" # PostgreSQL remoto: use URL completa do provider -DATABASE_URL=postgresql://openmonetis:openmonetis_dev_password@db:5432/openmonetis_db +DATABASE_URL=postgresql://openmonetis:openmonetis_dev_password@localhost:5432/openmonetis_db # Credenciais do PostgreSQL (apenas para Docker local) - Alterar POSTGRES_USER=openmonetis @@ -54,4 +54,11 @@ UMAMI_DOMAINS= ANTHROPIC_API_KEY= OPENAI_API_KEY= GOOGLE_GENERATIVE_AI_API_KEY= -OPENROUTER_API_KEY= \ No newline at end of file +OPENROUTER_API_KEY= + +# === Logo.dev (Opcional) === +# Logos automáticos de estabelecimentos. Cadastre em https://www.logo.dev +# NEXT_PUBLIC_LOGO_DEV_TOKEN — token público (aparece no frontend, ok por design) +# LOGO_DEV_SECRET_KEY — chave secreta (apenas server-side, nunca expor) +NEXT_PUBLIC_LOGO_DEV_TOKEN= +LOGO_DEV_SECRET_KEY= \ No newline at end of file diff --git a/src/app/api/logo/mapping/route.ts b/src/app/api/logo/mapping/route.ts new file mode 100644 index 0000000..e866aff --- /dev/null +++ b/src/app/api/logo/mapping/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; +import { getOptionalUserSession } from "@/shared/lib/auth/server"; +import { fetchEstablishmentLogoDomain } from "@/shared/lib/logo/establishment-logo-queries"; + +/** + * GET /api/logo/mapping?name={name} + * + * Retorna o domínio Logo.dev salvo pelo usuário para um estabelecimento. + * Usado pelo EstablishmentLogo para hidratar o domain salvo no banco. + */ +export async function GET(request: Request) { + const session = await getOptionalUserSession(); + if (!session) { + return NextResponse.json({ domain: null }, { status: 200 }); + } + + const { searchParams } = new URL(request.url); + const name = searchParams.get("name")?.trim(); + + if (!name) { + return NextResponse.json({ domain: null }, { status: 200 }); + } + + const domain = await fetchEstablishmentLogoDomain(session.user.id, name); + return NextResponse.json({ domain }); +} diff --git a/src/app/api/logo/search/route.ts b/src/app/api/logo/search/route.ts new file mode 100644 index 0000000..6b75757 --- /dev/null +++ b/src/app/api/logo/search/route.ts @@ -0,0 +1,80 @@ +import { NextResponse } from "next/server"; +import { getOptionalUserSession } from "@/shared/lib/auth/server"; + +const LOGO_DEV_SEARCH_URL = "https://api.logo.dev/search"; + +interface LogoResult { + name: string; + domain: string; +} + +async function searchByStrategy( + q: string, + strategy: "match" | "typeahead", + secretKey: string, +): Promise { + try { + const url = `${LOGO_DEV_SEARCH_URL}?q=${encodeURIComponent(q)}&strategy=${strategy}`; + const res = await fetch(url, { + headers: { Authorization: `Bearer ${secretKey}` }, + next: { revalidate: 3600 }, + }); + if (!res.ok) return []; + const data = await res.json(); + return Array.isArray(data) ? data : []; + } catch { + return []; + } +} + +/** + * GET /api/logo/search?q={name} + * + * Proxy seguro para a Logo.dev Brand Search API. + * Faz duas buscas paralelas (match + typeahead) e retorna até 20 resultados únicos. + * Usa LOGO_DEV_SECRET_KEY server-side — nunca exposta ao cliente. + */ +export async function GET(request: Request) { + const session = await getOptionalUserSession(); + if (!session) { + return NextResponse.json({ error: "Não autorizado." }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const q = searchParams.get("q")?.trim(); + + if (!q) { + return NextResponse.json( + { error: "Parâmetro q obrigatório." }, + { status: 400 }, + ); + } + + const secretKey = process.env.LOGO_DEV_SECRET_KEY; + if (!secretKey) { + return NextResponse.json( + { error: "Logo.dev não configurado." }, + { status: 503 }, + ); + } + + // Duas buscas paralelas para maximizar resultados (cada uma retorna até 10) + const [matchResults, typeaheadResults] = await Promise.all([ + searchByStrategy(q, "match", secretKey), + searchByStrategy(q, "typeahead", secretKey), + ]); + + // Mescla e deduplica por domain, mantendo ordem (match tem prioridade) + const seen = new Set(); + const merged: LogoResult[] = []; + + for (const result of [...matchResults, ...typeaheadResults]) { + if (!seen.has(result.domain)) { + seen.add(result.domain); + merged.push(result); + if (merged.length >= 20) break; + } + } + + return NextResponse.json(merged); +} diff --git a/src/db/schema.ts b/src/db/schema.ts index 7fc9e5c..118da17 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -721,6 +721,7 @@ export const userRelations = relations(user, ({ many, one }) => ({ installmentAnticipations: many(installmentAnticipations), apiTokens: many(apiTokens), inboxItems: many(inboxItems), + establishmentLogos: many(establishmentLogos), })); export const accountRelations = relations(account, ({ one }) => ({ @@ -955,6 +956,25 @@ export const importCategoryMappings = pgTable( }), ); +export const establishmentLogos = pgTable( + "establishment_logos", + { + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + nameKey: text("name_key").notNull(), + domain: text("domain").notNull(), + updatedAt: timestamp("updated_at", { mode: "date", withTimezone: true }) + .notNull() + .defaultNow(), + }, + (table) => ({ + pk: primaryKey({ columns: [table.userId, table.nameKey] }), + }), +); + +export type EstablishmentLogo = typeof establishmentLogos.$inferSelect; + export type User = typeof user.$inferSelect; export type NewUser = typeof user.$inferInsert; export type Account = typeof account.$inferSelect; @@ -1004,3 +1024,13 @@ export const transactionAttachmentsRelations = relations( export type Attachment = typeof attachments.$inferSelect; export type TransactionAttachment = typeof transactionAttachments.$inferSelect; + +export const establishmentLogosRelations = relations( + establishmentLogos, + ({ one }) => ({ + user: one(user, { + fields: [establishmentLogos.userId], + references: [user.id], + }), + }), +); diff --git a/src/features/transactions/components/table/transactions-columns.tsx b/src/features/transactions/components/table/transactions-columns.tsx index 1626040..59327f1 100644 --- a/src/features/transactions/components/table/transactions-columns.tsx +++ b/src/features/transactions/components/table/transactions-columns.tsx @@ -199,7 +199,7 @@ function buildColumns({ return ( - + {formatDate(purchaseDate)} diff --git a/src/proxy.ts b/src/proxy.ts index 9ca7891..633821d 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -38,7 +38,11 @@ function buildCsp(): string { const connectExtras = [umamiOrigin, s3Origin].filter(Boolean).join(" "); - const imgExtras = ["https://lh3.googleusercontent.com", s3Origin] + const imgExtras = [ + "https://lh3.googleusercontent.com", + "https://img.logo.dev", + s3Origin, + ] .filter(Boolean) .join(" "); diff --git a/src/shared/components/entity-avatar/establishment-logo-picker.tsx b/src/shared/components/entity-avatar/establishment-logo-picker.tsx new file mode 100644 index 0000000..032566c --- /dev/null +++ b/src/shared/components/entity-avatar/establishment-logo-picker.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useState, useTransition } from "react"; +import { Input } from "@/shared/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/shared/components/ui/popover"; +import { Spinner } from "@/shared/components/ui/spinner"; +import { + buildLogoDevUrl, + logoQueryKeys, + toNameKey, +} from "@/shared/lib/logo"; +import { + removeEstablishmentLogoAction, + saveEstablishmentLogoAction, +} from "@/shared/lib/logo/establishment-logo-actions"; +import { + buildInitials, + getCategoryBgColorFromName, + getCategoryColorFromName, +} from "@/shared/utils/category-colors"; +import { cn } from "@/shared/utils/ui"; + +interface LogoResult { + name: string; + domain: string; +} + +async function fetchLogoResults(query: string): Promise { + if (!query.trim()) return []; + const res = await fetch( + `/api/logo/search?q=${encodeURIComponent(query.trim())}`, + ); + if (!res.ok) return []; + const data = await res.json(); + return Array.isArray(data) ? data : []; +} + +interface EstablishmentLogoPickerProps { + name: string; + resolvedDomain: string | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onSelect: (domain: string | null) => void; + children: React.ReactNode; +} + +export function EstablishmentLogoPicker({ + name, + resolvedDomain, + open, + onOpenChange, + onSelect, + children, +}: EstablishmentLogoPickerProps) { + const [isPending, startTransition] = useTransition(); + const [searchInput, setSearchInput] = useState(name); + const [debouncedSearch, setDebouncedSearch] = useState(name); + const queryClient = useQueryClient(); + + useEffect(() => { + if (open) { + setSearchInput(name); + setDebouncedSearch(name); + } + }, [open, name]); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedSearch(searchInput), 400); + return () => clearTimeout(timer); + }, [searchInput]); + + const { data: results = [], isLoading } = useQuery({ + queryKey: logoQueryKeys.search(debouncedSearch), + queryFn: () => fetchLogoResults(debouncedSearch), + enabled: open && debouncedSearch.trim().length > 0, + staleTime: 1000 * 60 * 60, + }); + + function handleSelect(domain: string) { + startTransition(async () => { + await saveEstablishmentLogoAction(name, domain); + queryClient.setQueryData(logoQueryKeys.mapping(toNameKey(name)), { domain }); + onSelect(domain); + }); + } + + function handleReset() { + startTransition(async () => { + await removeEstablishmentLogoAction(name); + queryClient.setQueryData(logoQueryKeys.mapping(toNameKey(name)), { + domain: null, + }); + onSelect(null); + }); + } + + return ( + + {children} + +

+ Escolha um logo para {name} +

+ + setSearchInput(e.target.value)} + placeholder="Buscar marca..." + className="mb-3 h-8 text-sm" + autoFocus + /> + + {isLoading ? ( +
+ +
+ ) : ( +
+
+ + + {results.map((r) => ( + + ))} +
+
+ )} +
+
+ ); +} diff --git a/src/shared/components/entity-avatar/establishment-logo.tsx b/src/shared/components/entity-avatar/establishment-logo.tsx index d821350..caec0e2 100644 --- a/src/shared/components/entity-avatar/establishment-logo.tsx +++ b/src/shared/components/entity-avatar/establishment-logo.tsx @@ -1,31 +1,75 @@ +"use client"; + +import { RiPencilLine } from "@remixicon/react"; +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { + LOGO_DEV_TOKEN, + buildLogoDevUrl, + logoQueryKeys, + toNameKey, +} from "@/shared/lib/logo"; import { buildInitials, getCategoryBgColorFromName, getCategoryColorFromName, } from "@/shared/utils/category-colors"; import { cn } from "@/shared/utils/ui"; +import { EstablishmentLogoPicker } from "./establishment-logo-picker"; + +async function fetchLogoMapping( + name: string, +): Promise<{ domain: string | null }> { + const res = await fetch(`/api/logo/mapping?name=${encodeURIComponent(name)}`); + if (!res.ok) return { domain: null }; + return res.json(); +} interface EstablishmentLogoProps { name: string; + /** Domínio Logo.dev pré-carregado pelo servidor (otimização opcional). */ + domain?: string | null; size?: number; className?: string; } export function EstablishmentLogo({ name, + domain: initialDomain, size = 32, className, }: EstablishmentLogoProps) { + const [pickerOpen, setPickerOpen] = useState(false); + const [imgError, setImgError] = useState(false); + + const { data: mappingData } = useQuery({ + queryKey: logoQueryKeys.mapping(toNameKey(name)), + queryFn: () => fetchLogoMapping(name), + placeholderData: + initialDomain !== undefined + ? { domain: initialDomain ?? null } + : undefined, + staleTime: 1000 * 60 * 5, + enabled: LOGO_DEV_TOKEN !== undefined && LOGO_DEV_TOKEN !== "", + }); + + const resolvedDomain = mappingData?.domain ?? null; + + // biome-ignore lint/correctness/useExhaustiveDependencies: resetar imgError é o efeito de domain mudar + useEffect(() => { + setImgError(false); + }, [resolvedDomain]); + + const logoUrl = buildLogoDevUrl(resolvedDomain); + const showLogo = Boolean(logoUrl) && !imgError; + const initials = buildInitials(name); const color = getCategoryColorFromName(name); const bgColor = getCategoryBgColorFromName(name); - return ( + const initialsAvatar = (
); + + const logoImage = + showLogo && logoUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {name} setImgError(true)} + className="shrink-0 rounded-full object-cover" + style={{ width: size, height: size }} + /> + ) : ( + initialsAvatar + ); + + if (!LOGO_DEV_TOKEN) { + return ( +
+ {initialsAvatar} +
+ ); + } + + return ( + setPickerOpen(false)} + > + + + ); } diff --git a/src/shared/components/entity-avatar/index.ts b/src/shared/components/entity-avatar/index.ts index b0832cd..d320004 100644 --- a/src/shared/components/entity-avatar/index.ts +++ b/src/shared/components/entity-avatar/index.ts @@ -4,3 +4,4 @@ export type { } from "./category-icon-badge"; export { CategoryIconBadge } from "./category-icon-badge"; export { EstablishmentLogo } from "./establishment-logo"; +export { EstablishmentLogoPicker } from "./establishment-logo-picker"; diff --git a/src/shared/lib/logo/establishment-logo-actions.ts b/src/shared/lib/logo/establishment-logo-actions.ts new file mode 100644 index 0000000..b4ae1a8 --- /dev/null +++ b/src/shared/lib/logo/establishment-logo-actions.ts @@ -0,0 +1,64 @@ +"use server"; + +import { and, eq } from "drizzle-orm"; +import { establishmentLogos } from "@/db/schema"; +import type { ActionResult } from "@/shared/lib/actions/helpers"; +import { + handleActionError, + revalidateForEntity, +} from "@/shared/lib/actions/helpers"; +import { getUserId } from "@/shared/lib/auth/server"; +import { db } from "@/shared/lib/db"; +import { toNameKey } from "@/shared/lib/logo"; + +/** + * Salva ou atualiza o domínio Logo.dev preferido para um estabelecimento. + */ +export async function saveEstablishmentLogoAction( + name: string, + domain: string, +): Promise { + try { + const userId = await getUserId(); + const nameKey = toNameKey(name); + + await db + .insert(establishmentLogos) + .values({ userId, nameKey, domain }) + .onConflictDoUpdate({ + target: [establishmentLogos.userId, establishmentLogos.nameKey], + set: { domain, updatedAt: new Date() }, + }); + + revalidateForEntity("establishments", userId); + return { success: true, message: "Logo salvo." }; + } catch (error) { + return handleActionError(error); + } +} + +/** + * Remove o mapeamento salvo, voltando ao comportamento automático do Logo.dev. + */ +export async function removeEstablishmentLogoAction( + name: string, +): Promise { + try { + const userId = await getUserId(); + const nameKey = toNameKey(name); + + await db + .delete(establishmentLogos) + .where( + and( + eq(establishmentLogos.userId, userId), + eq(establishmentLogos.nameKey, nameKey), + ), + ); + + revalidateForEntity("establishments", userId); + return { success: true, message: "Logo restaurado." }; + } catch (error) { + return handleActionError(error); + } +} diff --git a/src/shared/lib/logo/establishment-logo-queries.ts b/src/shared/lib/logo/establishment-logo-queries.ts new file mode 100644 index 0000000..c70d7d1 --- /dev/null +++ b/src/shared/lib/logo/establishment-logo-queries.ts @@ -0,0 +1,46 @@ +import { and, eq, inArray } from "drizzle-orm"; +import { establishmentLogos } from "@/db/schema"; +import { db } from "@/shared/lib/db"; +import { toNameKey } from "@/shared/lib/logo"; + +export { toNameKey }; + +/** + * Busca o domínio salvo para um único estabelecimento. + */ +export async function fetchEstablishmentLogoDomain( + userId: string, + name: string, +): Promise { + const nameKey = toNameKey(name); + const row = await db.query.establishmentLogos.findFirst({ + where: and( + eq(establishmentLogos.userId, userId), + eq(establishmentLogos.nameKey, nameKey), + ), + columns: { domain: true }, + }); + return row?.domain ?? null; +} + +/** + * Busca domínios salvos para múltiplos nomes de uma vez (evita N+1). + * Retorna um Map de nameKey → domain. + */ +export async function fetchEstablishmentLogoMap( + userId: string, + names: string[], +): Promise> { + const nameKeys = [...new Set(names.map(toNameKey))]; + if (nameKeys.length === 0) return new Map(); + + const rows = await db.query.establishmentLogos.findMany({ + where: and( + eq(establishmentLogos.userId, userId), + inArray(establishmentLogos.nameKey, nameKeys), + ), + columns: { nameKey: true, domain: true }, + }); + + return new Map(rows.map((r) => [r.nameKey, r.domain])); +} diff --git a/src/shared/lib/logo/index.ts b/src/shared/lib/logo/index.ts index 30698bf..8940b57 100644 --- a/src/shared/lib/logo/index.ts +++ b/src/shared/lib/logo/index.ts @@ -39,6 +39,29 @@ export const deriveNameFromLogo = (logo?: string | null) => { .join(" "); }; +/** + * Normaliza o nome do estabelecimento para usar como chave de lookup no banco. + */ +export const toNameKey = (name: string): string => name.trim().toLowerCase(); + +// === Logo.dev === + +export const LOGO_DEV_TOKEN = process.env.NEXT_PUBLIC_LOGO_DEV_TOKEN; + +export function buildLogoDevUrl( + domain?: string | null, +): string | null { + if (!LOGO_DEV_TOKEN || !domain) return null; + return `https://img.logo.dev/${domain}?token=${LOGO_DEV_TOKEN}&size=64&format=png`; +} + +export const logoQueryKeys = { + mapping: (nameKey: string) => ["logo-mapping", nameKey] as const, + search: (query: string) => ["logo-search", query] as const, +}; + +// === Local logo resolution === + const LOGO_SRC_PATTERN = /^(https?:\/\/|data:)/; type ResolveLogoSrcOptions = {