13 Commits

Author SHA1 Message Date
Felipe Coutinho
9456aa98bc fix(ci): passar NEXT_PUBLIC_LOGO_DEV_TOKEN como build arg no Docker
NEXT_PUBLIC_* é inlined pelo Next.js em build time — a variável precisa
ser injetada via ARG no Dockerfile e build-args no workflow do CI.
Sem isso, o token fica undefined e os logos nunca são exibidos.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 01:06:23 +00:00
Felipe Coutinho
21c6a8d9d0 fix(lint): corrigir schema biome.json e formatação de imports
- biome.json: bump schema 2.4.10 → 2.4.11
- establishment-logo-picker.tsx, establishment-logo.tsx, navigation-menu.tsx, logo/index.ts: organizar imports e ajustar formatação conforme Biome 2.4.11

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 00:31:46 +00:00
Felipe Coutinho
c29ffa9a12 docs: registrar v2.4.0 — integração Logo.dev; atualizar screenshots
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 00:27:03 +00:00
Felipe Coutinho
8875de843b chore(deps): separar radix-ui em pacotes individuais e atualizar dependências; bump 2.4.0
- Remove pacote `radix-ui` (bundle monolítico); importa direto `@radix-ui/react-navigation-menu` e `@radix-ui/react-slider`
- Bump: @ai-sdk/* , @aws-sdk/* , @tanstack/react-query, ai, resend, dotenv, knip, @biomejs/biome, @types/node

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 00:26:56 +00:00
Felipe Coutinho
679ea752bb feat(logo): integração Logo.dev para logos automáticos de estabelecimentos
- 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 <noreply@anthropic.com>
2026-04-14 00:26:48 +00:00
Felipe Coutinho
1161e97d9e docs: reestruturar README em dois perfis; bump 2.3.8
- README: perfil Usar (só Docker) e Desenvolver (hot-reload)
- README: seção Docker simplificada; scripts atualizados
- CHANGELOG: entrada 2.3.8 com mudanças de infraestrutura Docker

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:45:19 +00:00
Felipe Coutinho
55d7dedd9a chore(scripts): reduzir scripts docker de 10 para 5
docker:up, docker:db, docker:down, docker:logs, docker:update

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:45:15 +00:00
Felipe Coutinho
ad2752b7b0 chore(docker): simplificar compose e entrypoint
- compose: removidos profiles, build e dependência de arquivo externo;
  agora standalone com curl + docker compose up -d
- compose: variáveis opcionais movidas para .env via env_file
- entrypoint: extensão pgcrypto criada via Node.js antes das migrations
- entrypoint: loop de retry reescrito; removido hack @localhost→@db

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:45:12 +00:00
Felipe Coutinho
58db357cde docs: reescrever README com guia de instalação para leigos; atualizar changelog 2.3.7
- README: seção "Como rodar" reescrita com 4 modos explicados para leigos
- README: seção Docker atualizada (sem .env obrigatório, localhost funciona)
- package.json: corrigir env:setup apontando para setup-env.sh deletado → setup.mjs
- CHANGELOG 2.3.7: documentar fix do localhost→db e default DATABASE_URL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 01:49:40 +00:00
Felipe Coutinho
99a9ff5512 fix(docker): resolver DATABASE_URL localhost→db no container automaticamente
- docker-entrypoint.sh: substituir @localhost: por @db: via sed antes das
  migrations e do Next.js subirem — transparente para o usuário
- docker-compose.yml: adicionar valor padrão para DATABASE_URL para
  permitir subir sem .env configurado

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 01:48:20 +00:00
Felipe Coutinho
5bcf4f69d3 chore(scripts): remover órfãos dev.ts e setup-env.sh; atualizar changelog 2.3.7
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 01:17:20 +00:00
Felipe Coutinho
95099c1a94 chore(docker): passar PUBLIC_DOMAIN e variáveis Umami para o container
Adiciona PUBLIC_DOMAIN, UMAMI_URL, UMAMI_WEBSITE_ID e UMAMI_DOMAINS
ao bloco de environment do serviço app no docker-compose.yml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 01:16:44 +00:00
Felipe Coutinho
94912f7edc fix(scripts): corrigir install-deps.sh — spinner, corepack e PATH
- spinner_stop: adicionar || true em kill/wait para evitar exit com set -e
- suprimir prompt interativo do corepack com COREPACK_ENABLE_DOWNLOAD_PROMPT=0
- exportar PATH do Homebrew antes do resumo para pnpm --version funcionar
- remover mensagem "próximo passo" do final do script

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 01:16:40 +00:00
30 changed files with 904 additions and 946 deletions

View File

@@ -3,10 +3,10 @@
# ============================================ # ============================================
# === Database === # === Database ===
# PostgreSQL local (Docker): use host "db" # Desenvolvimento local (pnpm dev): use host "localhost" (padrão abaixo)
# PostgreSQL local (sem Docker): use host "localhost" # Docker Compose completo: o compose.yml define DATABASE_URL automaticamente com host "db"
# PostgreSQL remoto: use URL completa do provider # 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 # Credenciais do PostgreSQL (apenas para Docker local) - Alterar
POSTGRES_USER=openmonetis POSTGRES_USER=openmonetis
@@ -48,11 +48,17 @@ GOOGLE_CLIENT_SECRET=
# Umami: https://umami.is — self-hosted ou cloud # Umami: https://umami.is — self-hosted ou cloud
UMAMI_URL= UMAMI_URL=
UMAMI_WEBSITE_ID= UMAMI_WEBSITE_ID=
# Domínios rastreados (ex: openmonetis.com) — corresponde ao data-domains do script
UMAMI_DOMAINS= UMAMI_DOMAINS=
# === AI Providers (Opcional) === # === AI Providers (Opcional) ===
ANTHROPIC_API_KEY= ANTHROPIC_API_KEY=
OPENAI_API_KEY= OPENAI_API_KEY=
GOOGLE_GENERATIVE_AI_API_KEY= GOOGLE_GENERATIVE_AI_API_KEY=
OPENROUTER_API_KEY= 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=

View File

@@ -85,6 +85,8 @@ jobs:
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
build-args: |
NEXT_PUBLIC_LOGO_DEV_TOKEN=${{ secrets.NEXT_PUBLIC_LOGO_DEV_TOKEN }}
- name: Image digest - name: Image digest
run: echo ${{ steps.meta.outputs.digest }} run: echo ${{ steps.meta.outputs.digest }}

View File

@@ -7,6 +7,34 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
## [Unreleased] ## [Unreleased]
## [2.4.0] - 2026-04-13
### Adicionado
- Estabelecimentos: integração com Logo.dev — logos automáticos de marcas exibidos na coluna de estabelecimentos nos lançamentos
- Estabelecimentos: picker de logo por estabelecimento — clique no avatar para buscar e fixar um domínio Logo.dev específico (salvo por usuário no banco)
- API: rotas `/api/logo/search` e `/api/logo/mapping` — proxy seguro para Logo.dev Brand Search API (secret key server-side) e consulta de mapeamentos salvos
- Schema: tabela `establishment_logos` com PK composta `(user_id, name_key)` para persistir preferências de logo por usuário
### Corrigido
- Dev: `.env.example` usava host `db` no `DATABASE_URL`, causando erro `EAI_AGAIN` ao rodar `pnpm dev` localmente — corrigido para `localhost`
### Documentação
- README: tabela comparativa entre Perfil 1 (Usar) e Perfil 2 (Desenvolver) com diferenças de setup, `DATABASE_URL` e instruções de atualização
- README: seção "Variáveis de Ambiente" esclarecida — distingue contexto Docker (Perfil 1) de desenvolvimento local (Perfil 2)
- Logo.dev: crie uma conta em logo.dev para obter as chaves `NEXT_PUBLIC_LOGO_DEV_TOKEN` e `LOGO_DEV_SECRET_KEY` — plano gratuito inclui 500.000 requisições/mês
## [2.3.8] - 2026-04-12
### Alterado
- Docker: `docker-compose.yml` refatorado — removidos profiles, build e dependência de arquivo externo; compose agora é standalone (basta `curl` + `docker compose up -d`)
- Docker: `docker-entrypoint.sh` simplificado — extensão `pgcrypto` criada via Node.js antes das migrations; loop de retry reescrito; removido hack `@localhost → @db`
- Docker: scripts reduzidos de 10 para 5 — `docker:up`, `docker:db`, `docker:down`, `docker:logs`, `docker:update`
- Docs: README reestruturado em dois perfis claros — **Usar** (só Docker) e **Desenvolver** (hot-reload)
## [2.3.7] - 2026-04-11 ## [2.3.7] - 2026-04-11
### Adicionado ### Adicionado
@@ -15,6 +43,7 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
- Lançamentos: filtro por status de pagamento (somente pagos / somente não pagos) e filtro por presença de anexo - Lançamentos: filtro por status de pagamento (somente pagos / somente não pagos) e filtro por presença de anexo
- Lançamentos: indicador visual no status de liquidação para lançamentos de cartão de crédito com fatura paga — exibe ícone verde com tooltip explicativo - Lançamentos: indicador visual no status de liquidação para lançamentos de cartão de crédito com fatura paga — exibe ícone verde com tooltip explicativo
- Scripts: `scripts/install-deps.sh` — script de preparação para servidores Ubuntu 24.04 limpos (instala Docker, Node.js 22, pnpm via Homebrew) - Scripts: `scripts/install-deps.sh` — script de preparação para servidores Ubuntu 24.04 limpos (instala Docker, Node.js 22, pnpm via Homebrew)
- Docker: variáveis `PUBLIC_DOMAIN`, `UMAMI_URL`, `UMAMI_WEBSITE_ID` e `UMAMI_DOMAINS` passadas ao container da aplicação no `docker-compose.yml`
### Alterado ### Alterado
@@ -32,6 +61,15 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
- S3: corrigido `Error: Region is missing` ao usar o app sem S3 configurado — `S3_REGION` vazio (string vazia) não era tratado pelo operador `??`; substituído por `||` em todo o `s3-client.ts` - S3: corrigido `Error: Region is missing` ao usar o app sem S3 configurado — `S3_REGION` vazio (string vazia) não era tratado pelo operador `??`; substituído por `||` em todo o `s3-client.ts`
- i18n: corrigidas mensagens de erro que exibiam "Payer" em inglês em vez de "Pagador" - i18n: corrigidas mensagens de erro que exibiam "Payer" em inglês em vez de "Pagador"
- Logos: corrigido modal seletor de logos de cartões e contas para renderizar miniaturas sem avisos de proporção - Logos: corrigido modal seletor de logos de cartões e contas para renderizar miniaturas sem avisos de proporção
- Scripts: `install-deps.sh` — spinner travava o script por `wait` retornar código não-zero com `set -e` ativo; corrigido com `|| true`
- Scripts: `install-deps.sh` — prompt interativo do corepack suprimido com `COREPACK_ENABLE_DOWNLOAD_PROMPT=0`
- Scripts: `install-deps.sh` — PATH do Homebrew não estava configurado na seção de resumo
### Removido
- Scripts: removidos arquivos órfãos `scripts/dev.ts` e `scripts/setup-env.sh` (substituídos pelo `setup.mjs`)
- Docker: `docker-compose.yml` agora funciona sem arquivo `.env``DATABASE_URL` tem valor padrão com credenciais de desenvolvimento
- Docker: `docker-entrypoint.sh` converte automaticamente `@localhost:` para `@db:` na `DATABASE_URL` ao iniciar o container, eliminando a necessidade de usar hosts diferentes no `.env` para desenvolvimento local e Docker
## [2.3.6] - 2026-04-09 ## [2.3.6] - 2026-04-09

View File

@@ -40,6 +40,10 @@ COPY --from=deps /app/public/pdf.worker.min.mjs ./public/pdf.worker.min.mjs
ENV NEXT_TELEMETRY_DISABLED=1 \ ENV NEXT_TELEMETRY_DISABLED=1 \
NODE_ENV=production NODE_ENV=production
# Token público do Logo.dev — injetado em build time (NEXT_PUBLIC_* é inlined pelo Next.js)
ARG NEXT_PUBLIC_LOGO_DEV_TOKEN
ENV NEXT_PUBLIC_LOGO_DEV_TOKEN=$NEXT_PUBLIC_LOGO_DEV_TOKEN
# Build da aplicação Next.js # Build da aplicação Next.js
RUN pnpm build RUN pnpm build

230
README.md
View File

@@ -8,7 +8,7 @@
> **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor. > **⚠️ Não há versão online hospedada.** Você precisa clonar o repositório e rodar localmente ou no seu próprio servidor.
[![Version](https://img.shields.io/badge/version-2.3.7-blue?style=flat-square)](CHANGELOG.md) [![Version](https://img.shields.io/badge/version-2.4.0-blue?style=flat-square)](CHANGELOG.md)
[![Next.js](https://img.shields.io/badge/Next.js-black?style=flat-square&logo=next.js)](https://nextjs.org/) [![Next.js](https://img.shields.io/badge/Next.js-black?style=flat-square&logo=next.js)](https://nextjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/) [![TypeScript](https://img.shields.io/badge/TypeScript-blue?style=flat-square&logo=typescript)](https://www.typescriptlang.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-blue?style=flat-square&logo=postgresql)](https://www.postgresql.org/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-blue?style=flat-square&logo=postgresql)](https://www.postgresql.org/)
@@ -32,9 +32,9 @@
## 📖 Índice ## 📖 Índice
- [Sobre o Projeto](#-sobre-o-projeto) - [Sobre o Projeto](#-sobre-o-projeto)
- [Instalação via Script](#-instalação-via-script) - [Como rodar o OpenMonetis](#-como-rodar-o-openmonetis)
- [Preparar o servidor (Ubuntu 24.04)](#-preparar-o-servidor-ubuntu-2404) - [Perfil 1 — Usar](#perfil-1--usar-self-hosting)
- [Início Rápido (manual)](#-início-rápido) - [Perfil 2 — Desenvolver](#perfil-2--desenvolver)
- [Scripts Disponíveis](#-scripts-disponíveis) - [Scripts Disponíveis](#-scripts-disponíveis)
- [Docker](#-docker) - [Docker](#-docker)
- [Backup](#-backup) - [Backup](#-backup)
@@ -103,107 +103,122 @@ A ideia é simples: ter um lugar onde consigo ver todas as minhas contas, cartõ
--- ---
## ⚡ Instalação via Script ## 🚀 Como rodar o OpenMonetis
A forma mais rápida de instalar. O script verifica dependências, configura o `.env` interativamente e sobe o banco automaticamente. Escolha o perfil que corresponde ao seu objetivo:
### 🖥️ Preparar o servidor (Ubuntu 24.04) | | Perfil 1 — Usar | Perfil 2 — Desenvolver |
|---|---|---|
| **Objetivo** | Rodar o app pronto | Modificar o código |
| **Clonar repositório** | Não | Sim |
| **Node.js / pnpm** | Não | Sim (Node 22+) |
| **Docker** | Sim | Sim |
| **Como iniciar** | `docker compose up -d` | `pnpm docker:db` + `pnpm dev` |
| **App roda em** | Container Docker | Host local (hot-reload) |
| **Banco roda em** | Container Docker | Container Docker |
| **`DATABASE_URL` (host)** | `db` (automático pelo compose) | `localhost` |
| **Banco remoto (Supabase, Neon...)** | Sim (`docker compose up -d app`) | Sim (ajustar `DATABASE_URL`) |
| **Como atualizar** | `pnpm docker:update` | `git pull` + `pnpm install` + `pnpm db:push` |
| **Indicado para** | Self-hosting, VPS, servidor | Contribuidores, customizações |
Se você está num **servidor Ubuntu limpo** (VPS, Oracle Cloud, DigitalOcean...) sem Node.js, Docker ou pnpm instalados, use o script de preparação antes de continuar. ---
> ⚠️ **Testado apenas em Ubuntu Server 24.04 LTS.** Em outras distribuições ou versões é necessário testar ou ajustar o script. ### Perfil 1 — Usar (self-hosting)
Só quer rodar o OpenMonetis. **Não precisa clonar o repositório nem instalar Node.js** — apenas Docker.
```bash
# 1. Baixe o compose
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/docker-compose.yml -o docker-compose.yml
# 2. Suba tudo
docker compose up -d
```
Acesse em: `http://localhost:3000`
O banco sobe com credenciais padrão. Para personalizar (senha, Google OAuth, e-mail, IA...), crie um `.env` na mesma pasta **antes** de subir:
```bash
# .env mínimo recomendado para produção
BETTER_AUTH_SECRET=gere-um-valor-com-openssl-rand-base64-32
BETTER_AUTH_URL=https://seu-dominio.com
```
Veja todas as variáveis disponíveis em [Variáveis de Ambiente](#-variáveis-de-ambiente).
**Banco remoto (Supabase, Neon, Railway...):** defina `DATABASE_URL` no `.env` e suba só o app:
```bash
docker compose up -d app
```
**Não tem Docker instalado?** Em servidores Ubuntu 24.04 limpos, use o script de instalação:
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/scripts/install-deps.sh -o install-deps.sh curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/scripts/install-deps.sh -o install-deps.sh
sudo sh install-deps.sh sudo sh install-deps.sh
``` ```
O script instala (e pula o que já estiver presente): > Ao final, faça **logout e login** para as permissões do grupo `docker` terem efeito.
| Ferramenta | Como instala | #### Atualizando (Perfil 1)
|---|---|
| `git`, `curl`, `ca-certificates` | apt |
| Docker Engine + Docker Compose | Repositório oficial do Docker |
| Homebrew | Script oficial (como usuário não-root) |
| Node.js 22 | Via Homebrew |
| pnpm | Via corepack |
Após a conclusão, adiciona o usuário atual ao grupo `docker` — faça logout/login para ativar. Em seguida, prossiga com o `setup.mjs` abaixo.
---
**Pré-requisito:** Node.js 22+
```bash ```bash
# Mac / Linux / WSL pnpm docker:update
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/setup.mjs -o setup.mjs && node setup.mjs # ou equivalente:
docker compose pull && docker compose up -d
# Windows (PowerShell)
curl -o setup.mjs https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/setup.mjs ; node setup.mjs
``` ```
O script irá: O schema do banco é aplicado automaticamente no startup — nenhum passo extra necessário.
- Verificar Node, pnpm, Git e Docker
- Perguntar se quer banco local (Docker) ou remoto (Supabase, Neon, etc.)
- Gerar o `BETTER_AUTH_SECRET` automaticamente
- Configurar opcionais: Google OAuth, e-mail, IA, domínio público
- Clonar o repositório, instalar dependências e aplicar o schema
--- ---
## 🚀 Início Rápido (manual) ### Perfil 2 — Desenvolver
### Pré-requisitos Quer modificar o código com hot-reload. O banco roda no Docker, o app roda direto no seu servidor.
- Node.js 22+ e pnpm **Requisitos:** Docker + Node.js 22+ + pnpm
- Docker e Docker Compose
### Passo a Passo ```bash
# 1. Clone o repositório
git clone https://github.com/felipegcoutinho/openmonetis.git
cd openmonetis
1. **Clone e instale** # 2. Instale as dependências
pnpm install
```bash # 3. Configure o ambiente
git clone https://github.com/felipegcoutinho/openmonetis.git cp .env.example .env
cd openmonetis # O DATABASE_URL já vem com host "localhost" (correto para dev local).
pnpm install # Edite o .env com suas configurações (BETTER_AUTH_SECRET, etc.)
```
2. **Configure o `.env`** # 4. Suba o banco
pnpm docker:db
```bash # 5. Habilite extensões do PostgreSQL (apenas no primeiro setup)
cp .env.example .env pnpm db:extensions
```
Edite o `.env` com suas credenciais. O principal é o `DATABASE_URL` e o `BETTER_AUTH_SECRET`: # 6. Aplique o schema no banco (apenas no primeiro setup)
pnpm db:push
```env # 7. Inicie o app com hot-reload
# Banco local (Docker): use host "localhost" pnpm dev
DATABASE_URL=postgresql://openmonetis:openmonetis_dev_password@localhost:5432/openmonetis_db ```
# Banco remoto (Supabase, Neon, etc): use a URL completa do provider Acesse em: `http://localhost:3000`
# DATABASE_URL=postgresql://user:password@host:5432/database?sslmode=require
BETTER_AUTH_SECRET=seu-secret-aqui # gere com: openssl rand -base64 32 Toda vez que salvar um arquivo, o app atualiza automaticamente sem precisar reiniciar.
BETTER_AUTH_URL=http://localhost:3000
```
3. **Suba o banco de dados** (pule se estiver usando banco remoto) #### Atualizando (Perfil 2)
```bash ```bash
docker compose up db -d git pull
pnpm db:extensions pnpm install # instala dependências novas, se houver
``` pnpm db:push # aplica mudanças de schema, se houver
```
4. **Execute as migrations e inicie** O `pnpm dev` já em execução detecta as mudanças de código automaticamente — não precisa reiniciar.
```bash
pnpm db:push
pnpm dev
```
5. Acesse `http://localhost:3000`
> **Docker completo** (app + banco em containers): use `pnpm docker:up:local` ao invés dos passos 3-4.
--- ---
@@ -238,78 +253,49 @@ pnpm backup # Backup completo do banco (ver seção Backup)
### Docker ### Docker
```bash ```bash
pnpm docker:up:local # Sobe app + banco PostgreSQL juntos (imagem do Hub) pnpm docker:up # Sobe app (Docker Hub) + banco em background
pnpm docker:up # Sobe apenas o app com build local pnpm docker:db # Sobe apenas o banco em background (usar com pnpm dev)
pnpm docker:up:d # Sobe apenas o app com build local em background pnpm docker:down # Para e remove os containers
pnpm docker:up:db # Sobe apenas o banco em background pnpm docker:logs # Logs em tempo real (todos os containers)
pnpm docker:down # Para e remove os containers pnpm docker:update # Atualiza para a imagem mais recente do Hub e reinicia
pnpm docker:down:volumes # Para containers e remove volumes (⚠️ apaga dados!)
pnpm docker:logs # Logs em tempo real (todos os containers)
pnpm docker:logs:app # Logs do container da aplicação
pnpm docker:logs:db # Logs do container do banco
pnpm docker:restart # Reinicia todos os containers
pnpm docker:rebuild # Rebuild completo forçando recriação
``` ```
--- ---
## 🐳 Docker ## 🐳 Docker
O `Dockerfile` usa multi-stage build (deps → builder → runner) com imagem final ~200MB rodando como usuário não-root. O `Dockerfile` usa multi-stage build (deps → builder → runner) com imagem final ~200MB rodando como usuário não-root. Health checks configurados para ambos os serviços (PostgreSQL via `pg_isready`, app via `GET /api/health`).
Health checks configurados para ambos os serviços (PostgreSQL via `pg_isready`, app via `GET /api/health`). ### Self-hosting (recomendado)
### Modos de uso Baixe apenas o `docker-compose.yml` e suba tudo — sem clonar o repositório, sem instalar dependências:
**Modo 1 — App + banco local (recomendado para self-hosting)**
Puxa a imagem pronta do Docker Hub e sobe app + banco juntos. Não precisa de Node.js instalado.
```bash ```bash
# 1. Baixar o docker-compose.yml
curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/docker-compose.yml -o docker-compose.yml curl -fsSL https://raw.githubusercontent.com/felipegcoutinho/openmonetis/main/docker-compose.yml -o docker-compose.yml
docker compose up -d
# 2. Criar o .env (copie o .env.example como referência)
# DATABASE_URL=postgresql://openmonetis:SUA_SENHA@db:5432/openmonetis_db
# POSTGRES_PASSWORD=SUA_SENHA
# BETTER_AUTH_SECRET=string-longa-aleatoria
# BETTER_AUTH_URL=http://localhost:3000
# 3. Subir
docker compose --profile local up
# ou, se tiver o projeto clonado:
pnpm docker:up:local
``` ```
**Modo 2 — App com banco remoto** As credenciais padrão do banco já estão configuradas. Para personalizar (senhas, opcionais), crie um `.env` na mesma pasta antes de subir — veja [Variáveis de Ambiente](#-variáveis-de-ambiente).
Use quando o banco está em um provider externo (Supabase, Neon, Railway...). ### Banco remoto (Supabase, Neon, Railway...)
Suba apenas o app e aponte para o banco externo via `DATABASE_URL` no `.env`:
```bash ```bash
# DATABASE_URL deve apontar para o banco remoto no .env docker compose up -d app
docker compose up
```
**Modo 3 — Build local (desenvolvimento)**
Builda a imagem localmente a partir do código-fonte.
```bash
pnpm docker:up # app apenas (banco separado)
pnpm docker:up:db # sobe o banco em background
``` ```
### Comandos úteis ### Comandos úteis
```bash ```bash
docker compose exec app sh # Shell da aplicação docker compose exec app sh # Shell da aplicação
docker compose exec db psql -U openmonetis -d openmonetis_db # Shell do banco docker compose exec db psql -U openmonetis -d openmonetis_db # Shell do banco
docker compose ps # Status docker compose ps # Status
docker compose exec db pg_dump -U openmonetis openmonetis_db > backup.sql # Backup docker compose exec db pg_dump -U openmonetis openmonetis_db > backup.sql # Backup
docker compose exec -T db psql -U openmonetis -d openmonetis_db < backup.sql # Restore docker compose exec -T db psql -U openmonetis -d openmonetis_db < backup.sql # Restore
``` ```
### Customizando Portas ### Customizando portas
```env ```env
APP_PORT=3001 # Padrão: 3000 APP_PORT=3001 # Padrão: 3000
@@ -406,11 +392,15 @@ S3_BUCKET=
## 🔐 Variáveis de Ambiente ## 🔐 Variáveis de Ambiente
Copie `.env.example` para `.env` e configure: **Perfil 2 (dev):** copie `.env.example` para `.env` — o `DATABASE_URL` já vem com `localhost`, pronto para uso com `pnpm dev`.
**Perfil 1 (Docker):** não precisa definir `DATABASE_URL` — o compose já configura automaticamente com host `db`. Só defina se usar banco remoto (Supabase, Neon, etc.).
### Obrigatórias ### Obrigatórias
```env ```env
# Perfil 2 (dev): host "localhost" — o banco roda em container, o app no host
# Perfil 1 (Docker): não precisa definir — o compose usa "db" automaticamente
DATABASE_URL=postgresql://openmonetis:openmonetis_dev_password@localhost:5432/openmonetis_db DATABASE_URL=postgresql://openmonetis:openmonetis_dev_password@localhost:5432/openmonetis_db
BETTER_AUTH_SECRET=seu-secret-aqui # openssl rand -base64 32 BETTER_AUTH_SECRET=seu-secret-aqui # openssl rand -base64 32
BETTER_AUTH_URL=http://localhost:3000 BETTER_AUTH_URL=http://localhost:3000

View File

@@ -1,5 +1,5 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.4.10/schema.json", "$schema": "https://biomejs.dev/schemas/2.4.11/schema.json",
"vcs": { "vcs": {
"enabled": true, "enabled": true,
"clientKind": "git", "clientKind": "git",

View File

@@ -1,138 +1,51 @@
# Docker Compose para Next.js + PostgreSQL
name: openmonetis name: openmonetis
# MODOS DE USO:
# 1. Banco LOCAL (PostgreSQL em container):
# - Configure DATABASE_URL com host "db" no .env
# - Execute: docker compose --profile local up
#
# 2. Banco REMOTO (ex: Supabase, Neon, etc):
# - Configure DATABASE_URL com a URL do banco remoto no .env
# - Execute: docker compose up
#
# 3. Build local (desenvolvimento):
# - Execute: docker compose --profile local up --build
#
# 4. Para parar todos os serviços:
# - Execute: docker compose down
#
# 5. Para remover volumes (CUIDADO: apaga dados do banco local):
# - Execute: docker compose down -v
services: services:
# ============================================
# Serviço: PostgreSQL (Banco de dados local)
# Ativado apenas com: --profile local
# ============================================
db: db:
profiles: ["local"]
image: postgres:18-alpine image: postgres:18-alpine
container_name: openmonetis_postgres container_name: openmonetis_postgres
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_USER: ${POSTGRES_USER:-openmonetis} POSTGRES_USER: ${POSTGRES_USER:-openmonetis}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-openmonetis_dev_password} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-openmonetis_dev_password}
POSTGRES_DB: ${POSTGRES_DB:-openmonetis_db} POSTGRES_DB: ${POSTGRES_DB:-openmonetis_db}
PGDATA: /var/lib/postgresql/data
POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C" POSTGRES_INITDB_ARGS: "-E UTF8 --locale=C"
ports: ports:
- "${DB_PORT:-5432}:5432" - "${DB_PORT:-5432}:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
# Cria extensão pgcrypto inline (necessária para gen_random_bytes no schema)
entrypoint: ["/bin/sh", "-c"]
command:
- |
echo 'CREATE EXTENSION IF NOT EXISTS pgcrypto;' > /docker-entrypoint-initdb.d/init.sql
exec docker-entrypoint.sh postgres
healthcheck: healthcheck:
test: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-openmonetis} -d ${POSTGRES_DB:-openmonetis_db}"]
[
"CMD-SHELL",
"pg_isready -U ${POSTGRES_USER:-openmonetis} -d ${POSTGRES_DB:-openmonetis_db}",
]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
start_period: 10s start_period: 10s
# Para ativar logs de queries (debug), adicione ao command acima:
# exec docker-entrypoint.sh postgres -c log_statement=all
# ============================================
# Serviço: Aplicação Next.js
# ============================================
app: app:
build: .
image: felipegcoutinho/openmonetis:latest image: felipegcoutinho/openmonetis:latest
container_name: openmonetis_app container_name: openmonetis_app
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${APP_PORT:-3000}:3000" - "${APP_PORT:-3000}:3000"
env_file:
- path: .env
required: false
environment: environment:
NODE_ENV: production NODE_ENV: production
DATABASE_URL: ${DATABASE_URL:-postgresql://openmonetis:openmonetis_dev_password@db:5432/openmonetis_db}
# Banco local: use host "db" | Banco remoto: URL completa do provider BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-}
DATABASE_URL: ${DATABASE_URL}
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000} BETTER_AUTH_URL: ${BETTER_AUTH_URL:-http://localhost:3000}
# S3 (opcional)
S3_ENDPOINT: ${S3_ENDPOINT:-}
S3_REGION: ${S3_REGION:-}
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
S3_BUCKET: ${S3_BUCKET:-}
# Email (opcional)
RESEND_API_KEY: ${RESEND_API_KEY:-}
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-}
# OAuth (opcional)
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
# AI providers (opcional)
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
GOOGLE_GENERATIVE_AI_API_KEY: ${GOOGLE_GENERATIVE_AI_API_KEY:-}
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
# required: false permite subir sem banco local (banco remoto via DATABASE_URL)
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
required: false required: false
healthcheck: healthcheck:
test: test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/api/health"]
[
"CMD",
"wget",
"--quiet",
"--tries=1",
"--spider",
"http://localhost:3000/api/health",
]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
start_period: 40s start_period: 40s
# ============================================
# Volumes
# ============================================
volumes: volumes:
postgres_data: postgres_data:
driver: local driver: local

View File

@@ -1,15 +1,26 @@
#!/bin/sh #!/bin/sh
echo "Habilitando extensão pgcrypto..."
node -e "
const { Client } = require('/app/migrate/node_modules/pg');
const c = new Client({ connectionString: process.env.DATABASE_URL });
c.connect()
.then(() => c.query('CREATE EXTENSION IF NOT EXISTS pgcrypto'))
.then(() => c.end())
.catch((e) => { console.error('Aviso pgcrypto:', e.message); process.exit(0); });
"
echo "Rodando migrations..." echo "Rodando migrations..."
RETRIES=5 MIGRATED=0
until NODE_PATH=/app/migrate/node_modules /app/migrate/node_modules/.bin/drizzle-kit push || [ "$RETRIES" -eq 0 ]; do for i in 1 2 3 4 5; do
RETRIES=$((RETRIES - 1)) if NODE_PATH=/app/migrate/node_modules /app/migrate/node_modules/.bin/drizzle-kit push; then
echo "Migration falhou, aguardando banco... ($RETRIES tentativas restantes)" MIGRATED=1
break
fi
echo "Tentativa $i/5 falhou. Aguardando 5s..."
sleep 5 sleep 5
done done
if [ "$RETRIES" -eq 0 ]; then [ "$MIGRATED" -eq 0 ] && echo "Aviso: migrations não foram aplicadas."
echo "Aviso: migrations nao foram aplicadas"
fi
exec "$@" exec "$@"

View File

@@ -1,6 +1,6 @@
{ {
"name": "openmonetis", "name": "openmonetis",
"version": "2.3.7", "version": "2.4.0",
"private": true, "private": true,
"packageManager": "pnpm@10.33.0", "packageManager": "pnpm@10.33.0",
"scripts": { "scripts": {
@@ -11,57 +11,31 @@
"lint": "biome check .", "lint": "biome check .",
"lint:deadcode": "knip --reporter compact", "lint:deadcode": "knip --reporter compact",
"lint:fix": "biome check --write .", "lint:fix": "biome check --write .",
"env:setup": "bash scripts/setup-env.sh", "env:setup": "node setup.mjs",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:extensions": "tsx scripts/postgres/enable-extensions.ts", "db:extensions": "tsx scripts/postgres/enable-extensions.ts",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"postinstall": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.min.mjs", "postinstall": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.min.mjs",
"docker:up": "docker compose up -d",
"// --- Docker ---": "---", "//docker:up": "Sobe app (Docker Hub) + banco PostgreSQL em background",
"docker:db": "docker compose up -d db",
"docker:up:local": "docker compose --profile local up", "//docker:db": "Sobe apenas o banco em background (para usar com pnpm dev)",
"//docker:up:local": "Sobe app + banco PostgreSQL local juntos (imagem do Docker Hub)",
"docker:up": "docker compose up --build",
"//docker:up": "Sobe apenas o app com build local (banco deve estar rodando separado)",
"docker:up:d": "docker compose up --build -d",
"//docker:up:d": "Sobe apenas o app com build local em background (detached)",
"docker:up:db": "docker compose up -d db",
"//docker:up:db": "Sobe apenas o banco PostgreSQL em background",
"docker:down": "docker compose down", "docker:down": "docker compose down",
"//docker:down": "Para e remove os containers", "//docker:down": "Para e remove os containers",
"docker:down:volumes": "docker compose down -v",
"//docker:down:volumes": "Para containers e remove volumes (APAGA os dados!)",
"docker:logs": "docker compose logs -f", "docker:logs": "docker compose logs -f",
"//docker:logs": "Acompanha logs de todos os containers em tempo real", "//docker:logs": "Acompanha logs de todos os containers em tempo real",
"docker:update": "docker compose pull && docker compose up -d",
"docker:logs:app": "docker compose logs -f app", "//docker:update": "Atualiza para a imagem mais recente do Docker Hub e reinicia",
"//docker:logs:app": "Acompanha logs do container da aplicação",
"docker:logs:db": "docker compose logs -f db",
"//docker:logs:db": "Acompanha logs do container do banco",
"docker:restart": "docker compose restart",
"//docker:restart": "Reinicia todos os containers",
"docker:rebuild": "docker compose up --build --force-recreate",
"//docker:rebuild": "Rebuild completo forçando recriação dos containers",
"backup": "bash scripts/backup.sh" "backup": "bash scripts/backup.sh"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "^3.0.68", "@ai-sdk/anthropic": "^3.0.69",
"@ai-sdk/google": "^3.0.61", "@ai-sdk/google": "^3.0.63",
"@ai-sdk/openai": "^3.0.52", "@ai-sdk/openai": "^3.0.52",
"@aws-sdk/client-s3": "^3.1027.0", "@aws-sdk/client-s3": "^3.1030.0",
"@aws-sdk/s3-request-presigner": "^3.1027.0", "@aws-sdk/s3-request-presigner": "^3.1030.0",
"@better-auth/passkey": "^1.6.2", "@better-auth/passkey": "^1.6.2",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
@@ -75,11 +49,13 @@
"@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-dropdown-menu": "2.1.16",
"@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "2.1.8", "@radix-ui/react-label": "2.1.8",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "1.1.8", "@radix-ui/react-progress": "1.1.8",
"@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "2.2.6", "@radix-ui/react-select": "2.2.6",
"@radix-ui/react-separator": "1.1.8", "@radix-ui/react-separator": "1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "1.2.4", "@radix-ui/react-slot": "1.2.4",
"@radix-ui/react-switch": "1.2.6", "@radix-ui/react-switch": "1.2.6",
"@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-tabs": "1.1.13",
@@ -87,10 +63,10 @@
"@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toggle-group": "1.1.11",
"@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-tooltip": "1.2.8",
"@remixicon/react": "4.9.0", "@remixicon/react": "4.9.0",
"@tanstack/react-query": "^5.97.0", "@tanstack/react-query": "^5.99.0",
"@tanstack/react-table": "8.21.3", "@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "^3.13.23", "@tanstack/react-virtual": "^3.13.23",
"ai": "^6.0.154", "ai": "^6.0.159",
"better-auth": "1.6.2", "better-auth": "1.6.2",
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
@@ -105,12 +81,11 @@
"next-themes": "0.4.6", "next-themes": "0.4.6",
"pdfjs-dist": "^5.6.205", "pdfjs-dist": "^5.6.205",
"pg": "8.20.0", "pg": "8.20.0",
"radix-ui": "^1.4.3",
"react": "19.2.5", "react": "19.2.5",
"react-day-picker": "^9.14.0", "react-day-picker": "^9.14.0",
"react-dom": "19.2.5", "react-dom": "19.2.5",
"recharts": "3.8.1", "recharts": "3.8.1",
"resend": "^6.10.0", "resend": "^6.11.0",
"sonner": "2.0.7", "sonner": "2.0.7",
"tailwind-merge": "3.5.0", "tailwind-merge": "3.5.0",
"vaul": "1.1.2", "vaul": "1.1.2",
@@ -122,16 +97,16 @@
} }
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.4.10", "@biomejs/biome": "2.4.11",
"@tailwindcss/postcss": "4.2.2", "@tailwindcss/postcss": "4.2.2",
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
"@types/node": "25.5.2", "@types/node": "25.6.0",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"@types/react": "19.2.14", "@types/react": "19.2.14",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"dotenv": "^17.4.1", "dotenv": "^17.4.2",
"drizzle-kit": "0.31.10", "drizzle-kit": "0.31.10",
"knip": "^6.3.1", "knip": "^6.4.1",
"tailwindcss": "4.2.2", "tailwindcss": "4.2.2",
"tsx": "4.21.0", "tsx": "4.21.0",
"typescript": "6.0.2" "typescript": "6.0.2"

704
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 163 KiB

View File

@@ -1,17 +0,0 @@
#!/usr/bin/env tsx
import { execSync } from "node:child_process";
import { config } from "dotenv";
// Carregar variáveis de ambiente
config();
const port = process.env.PORT || "3000";
console.log(`Starting Next.js development server on port ${port}...`);
// Executar next dev com a porta especificada
execSync(`npx next dev --turbopack --port ${port}`, {
stdio: "inherit",
env: { ...process.env, PORT: port },
});

View File

@@ -9,6 +9,9 @@ set -e
LOG_FILE="/tmp/openmonetis-install.log" LOG_FILE="/tmp/openmonetis-install.log"
> "$LOG_FILE" > "$LOG_FILE"
# Suprimir prompt interativo do corepack ao chamar pnpm/node versioning
export COREPACK_ENABLE_DOWNLOAD_PROMPT=0
# ── Cores ────────────────────────────────────────────────────────────────────── # ── Cores ──────────────────────────────────────────────────────────────────────
GREEN='\033[0;32m' GREEN='\033[0;32m'
YELLOW='\033[1;33m' YELLOW='\033[1;33m'
@@ -51,8 +54,8 @@ spinner_start() {
spinner_stop() { spinner_stop() {
if [ -n "$_spin_pid" ]; then if [ -n "$_spin_pid" ]; then
kill "$_spin_pid" 2>/dev/null kill "$_spin_pid" 2>/dev/null || true
wait "$_spin_pid" 2>/dev/null wait "$_spin_pid" 2>/dev/null || true
_spin_pid="" _spin_pid=""
printf "\r\033[2K" printf "\r\033[2K"
fi fi
@@ -220,15 +223,19 @@ if command -v pnpm > /dev/null 2>&1; then
else else
if [ -n "$CURRENT_USER" ] && [ "$CURRENT_USER" != "root" ]; then if [ -n "$CURRENT_USER" ] && [ "$CURRENT_USER" != "root" ]; then
run_as_user "Instalando pnpm via corepack" \ run_as_user "Instalando pnpm via corepack" \
'eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" && corepack enable && corepack prepare pnpm@latest --activate' 'eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" && corepack enable && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack prepare pnpm@latest --activate'
else else
run_quiet "Instalando pnpm via corepack" \ run_quiet "Instalando pnpm via corepack" \
sh -c 'corepack enable && corepack prepare pnpm@latest --activate' sh -c 'corepack enable && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack prepare pnpm@latest --activate'
fi fi
ok "pnpm instalado" ok "pnpm instalado"
fi fi
# ── Resumo ───────────────────────────────────────────────────────────────────── # ── Resumo ─────────────────────────────────────────────────────────────────────
# Garantir que node/pnpm do brew estejam no PATH para o resumo
export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH"
eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)" 2>/dev/null || true
printf "\n${BOLD}Concluído em $(elapsed)${RESET}\n" printf "\n${BOLD}Concluído em $(elapsed)${RESET}\n"
ok "git: $(git --version | cut -d' ' -f3)" ok "git: $(git --version | cut -d' ' -f3)"
@@ -236,6 +243,3 @@ ok "docker: $(docker --version | cut -d',' -f1 | cut -d' ' -f3)"
ok "docker compose: $(docker compose version | cut -d' ' -f4)" ok "docker compose: $(docker compose version | cut -d' ' -f4)"
ok "node: $(node --version)" ok "node: $(node --version)"
ok "pnpm: $(pnpm --version)" ok "pnpm: $(pnpm --version)"
printf "\n${CYAN}Próximo passo:${RESET}\n"
printf " node setup.mjs\n\n"

View File

@@ -1,49 +0,0 @@
#!/bin/bash
# Script para configurar ambiente de forma segura
# Cria backup do .env atual antes de sobrescrever
set -e
echo "🔧 Configurando ambiente..."
# Se .env já existe, criar backup
if [ -f .env ]; then
BACKUP_FILE=".env.backup.$(date +%Y%m%d_%H%M%S)"
echo "⚠️ Arquivo .env existente detectado!"
echo "📦 Criando backup em: $BACKUP_FILE"
cp .env "$BACKUP_FILE"
echo "✅ Backup criado com sucesso!"
echo ""
read -p "Deseja sobrescrever o .env atual com .env.example? (s/N) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Ss]$ ]]; then
echo "❌ Operação cancelada. Seu .env não foi modificado."
exit 0
fi
fi
# Copiar .env.example para .env
if [ -f .env.example ]; then
cp .env.example .env
echo "✅ Arquivo .env criado a partir de .env.example"
else
echo "❌ Erro: .env.example não encontrado!"
exit 1
fi
# Gerar BETTER_AUTH_SECRET automaticamente
if command -v openssl &> /dev/null; then
SECRET=$(openssl rand -base64 32)
sed -i.bak "s|BETTER_AUTH_SECRET=.*|BETTER_AUTH_SECRET=$SECRET|" .env && rm -f .env.bak
echo "✅ BETTER_AUTH_SECRET gerado automaticamente"
else
echo "⚠️ openssl não encontrado — configure BETTER_AUTH_SECRET manualmente:"
echo " openssl rand -base64 32"
fi
echo ""
echo "⚠️ IMPORTANTE: Edite o arquivo .env e configure:"
echo " - DATABASE_URL"
echo " - BETTER_AUTH_URL"
echo " - Demais variáveis opcionais (OAuth, e-mail, IA)"

View File

@@ -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 });
}

View File

@@ -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<LogoResult[]> {
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<string>();
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);
}

View File

@@ -721,6 +721,7 @@ export const userRelations = relations(user, ({ many, one }) => ({
installmentAnticipations: many(installmentAnticipations), installmentAnticipations: many(installmentAnticipations),
apiTokens: many(apiTokens), apiTokens: many(apiTokens),
inboxItems: many(inboxItems), inboxItems: many(inboxItems),
establishmentLogos: many(establishmentLogos),
})); }));
export const accountRelations = relations(account, ({ one }) => ({ 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 User = typeof user.$inferSelect;
export type NewUser = typeof user.$inferInsert; export type NewUser = typeof user.$inferInsert;
export type Account = typeof account.$inferSelect; export type Account = typeof account.$inferSelect;
@@ -1004,3 +1024,13 @@ export const transactionAttachmentsRelations = relations(
export type Attachment = typeof attachments.$inferSelect; export type Attachment = typeof attachments.$inferSelect;
export type TransactionAttachment = typeof transactionAttachments.$inferSelect; export type TransactionAttachment = typeof transactionAttachments.$inferSelect;
export const establishmentLogosRelations = relations(
establishmentLogos,
({ one }) => ({
user: one(user, {
fields: [establishmentLogos.userId],
references: [user.id],
}),
}),
);

View File

@@ -199,7 +199,7 @@ function buildColumns({
return ( return (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<EstablishmentLogo name={name} size={28} /> <EstablishmentLogo name={name} size={32} />
<span className="flex flex-col py-0.5"> <span className="flex flex-col py-0.5">
<span className="text-xs text-muted-foreground flex items-center gap-2"> <span className="text-xs text-muted-foreground flex items-center gap-2">
{formatDate(purchaseDate)} {formatDate(purchaseDate)}

View File

@@ -38,7 +38,11 @@ function buildCsp(): string {
const connectExtras = [umamiOrigin, s3Origin].filter(Boolean).join(" "); 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) .filter(Boolean)
.join(" "); .join(" ");

View File

@@ -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<LogoResult[]> {
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 (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent className="w-80 p-3" align="start" side="bottom">
<p className="mb-2 text-muted-foreground text-xs">
Escolha um logo para <strong>{name}</strong>
</p>
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Buscar marca..."
className="mb-3 h-8 text-sm"
autoFocus
/>
{isLoading ? (
<div className="flex h-24 items-center justify-center">
<Spinner />
</div>
) : (
<div className="max-h-64 overflow-y-auto">
<div className="grid grid-cols-5 gap-1.5">
<button
type="button"
disabled={isPending}
onClick={handleReset}
className={cn(
"flex flex-col items-center gap-1 rounded-md p-1.5 text-center transition-colors hover:bg-accent disabled:opacity-50",
resolvedDomain === null &&
"ring-2 ring-primary ring-offset-1",
)}
title="Usar iniciais coloridas"
>
<div
className="flex items-center justify-center rounded-md font-medium text-xs"
style={{
width: 36,
height: 36,
backgroundColor: getCategoryBgColorFromName(name),
color: getCategoryColorFromName(name),
}}
aria-hidden
>
{buildInitials(name)}
</div>
<span className="w-full truncate text-[10px] leading-tight text-muted-foreground">
Iniciais
</span>
</button>
{results.map((r) => (
<button
key={r.domain}
type="button"
disabled={isPending}
onClick={() => handleSelect(r.domain)}
className={cn(
"flex flex-col items-center gap-1 rounded-md p-1.5 text-center transition-colors hover:bg-accent disabled:opacity-50",
resolvedDomain === r.domain &&
"ring-2 ring-primary ring-offset-1",
)}
title={r.name}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={buildLogoDevUrl(r.domain) ?? ""}
alt={r.name}
width={36}
height={36}
className="rounded-md object-contain"
style={{ width: 36, height: 36 }}
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
<span className="w-full truncate text-[10px] leading-tight">
{r.name}
</span>
</button>
))}
</div>
</div>
)}
</PopoverContent>
</Popover>
);
}

View File

@@ -1,31 +1,75 @@
"use client";
import { RiPencilLine } from "@remixicon/react";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import {
buildLogoDevUrl,
LOGO_DEV_TOKEN,
logoQueryKeys,
toNameKey,
} from "@/shared/lib/logo";
import { import {
buildInitials, buildInitials,
getCategoryBgColorFromName, getCategoryBgColorFromName,
getCategoryColorFromName, getCategoryColorFromName,
} from "@/shared/utils/category-colors"; } from "@/shared/utils/category-colors";
import { cn } from "@/shared/utils/ui"; 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 { interface EstablishmentLogoProps {
name: string; name: string;
/** Domínio Logo.dev pré-carregado pelo servidor (otimização opcional). */
domain?: string | null;
size?: number; size?: number;
className?: string; className?: string;
} }
export function EstablishmentLogo({ export function EstablishmentLogo({
name, name,
domain: initialDomain,
size = 32, size = 32,
className, className,
}: EstablishmentLogoProps) { }: 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 initials = buildInitials(name);
const color = getCategoryColorFromName(name); const color = getCategoryColorFromName(name);
const bgColor = getCategoryBgColorFromName(name); const bgColor = getCategoryBgColorFromName(name);
return ( const initialsAvatar = (
<div <div
className={cn( className="flex shrink-0 items-center justify-center rounded-full font-medium"
"flex shrink-0 items-center justify-center rounded-full font-medium",
className,
)}
style={{ style={{
width: size, width: size,
height: size, height: size,
@@ -38,4 +82,60 @@ export function EstablishmentLogo({
{initials} {initials}
</div> </div>
); );
const logoImage =
showLogo && logoUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={logoUrl}
alt={name}
width={size}
height={size}
onError={() => setImgError(true)}
className="shrink-0 rounded-full object-cover"
style={{ width: size, height: size }}
/>
) : (
initialsAvatar
);
if (!LOGO_DEV_TOKEN) {
return (
<div className={cn("shrink-0", className)} aria-hidden>
{initialsAvatar}
</div>
);
}
return (
<EstablishmentLogoPicker
name={name}
resolvedDomain={resolvedDomain}
open={pickerOpen}
onOpenChange={setPickerOpen}
onSelect={() => setPickerOpen(false)}
>
<button
type="button"
className={cn("group relative shrink-0 cursor-pointer", className)}
onClick={(e) => e.stopPropagation()}
title={`Alterar logo de ${name}`}
aria-label={`Alterar logo de ${name}`}
>
{logoImage}
<span
className="absolute inset-0 flex items-center justify-center rounded-full bg-black/50 opacity-0 transition-opacity group-hover:opacity-100"
aria-hidden
>
<RiPencilLine
style={{
width: Math.max(10, Math.round(size * 0.38)),
height: Math.max(10, Math.round(size * 0.38)),
}}
className="text-white"
/>
</span>
</button>
</EstablishmentLogoPicker>
);
} }

View File

@@ -4,3 +4,4 @@ export type {
} from "./category-icon-badge"; } from "./category-icon-badge";
export { CategoryIconBadge } from "./category-icon-badge"; export { CategoryIconBadge } from "./category-icon-badge";
export { EstablishmentLogo } from "./establishment-logo"; export { EstablishmentLogo } from "./establishment-logo";
export { EstablishmentLogoPicker } from "./establishment-logo-picker";

View File

@@ -1,6 +1,6 @@
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { RiArrowDropDownLine } from "@remixicon/react"; import { RiArrowDropDownLine } from "@remixicon/react";
import { cva } from "class-variance-authority"; import { cva } from "class-variance-authority";
import { NavigationMenu as NavigationMenuPrimitive } from "radix-ui";
import type * as React from "react"; import type * as React from "react";
import { cn } from "@/shared/utils"; import { cn } from "@/shared/utils";

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { Slider as SliderPrimitive } from "radix-ui"; import * as SliderPrimitive from "@radix-ui/react-slider";
import type * as React from "react"; import type * as React from "react";
import { cn } from "@/shared/utils/ui"; import { cn } from "@/shared/utils/ui";

View File

@@ -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<ActionResult> {
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<ActionResult> {
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);
}
}

View File

@@ -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<string | null> {
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<Map<string, string>> {
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]));
}

View File

@@ -39,6 +39,27 @@ export const deriveNameFromLogo = (logo?: string | null) => {
.join(" "); .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:)/; const LOGO_SRC_PATTERN = /^(https?:\/\/|data:)/;
type ResolveLogoSrcOptions = { type ResolveLogoSrcOptions = {