Notas de projeto pro Claude Code (e qualquer dev que entre depois). Resume todas as decisões tomadas ao longo das conversas. Linguagem do projeto: **PT-BR**.
---
## 1. Visão geral
App web pro casamento de **Stefanie & Leandro**. Convidados escaneiam um QR Code na mesa, abrem o site, mandam fotos/vídeos + uma mensagem. Os noivos administram tudo num painel `/admin`.
**Escopo**: single-tenant (um casamento). Multi-tenant (SaaS) foi avaliado — fica pra um pivot futuro se houver demanda real (3+ pedidos).
Cada stack é um repositório git separado, com seu próprio `docker-compose.yml` isolado. Os 3 compartilham uma **network Docker externa** chamada `infra-net`.
lronetto-wedding/ App FastAPI + sidecars de backup (+ deploy CI)
```
No disco os 3 ficam como **diretórios irmãos** (ex.: `C:\Users\lrone\code\lronetto-*`). O `Makefile` orquestrador (neste repo, `lronetto-main`) referencia os outros dois por caminho relativo (`../lronetto-gitea`, `../lronetto-wedding`) — sobrescrevível via `GITEA_DIR=` / `WEDDING_DIR=`.
**Por que repos separados**: cada stack tem ciclo de vida, histórico e CI próprios. Subir/derrubar ou versionar uma não afeta as outras. Gitea pode ser desativado sem tocar no app.
**`lronetto-main` é o "platform layer"** que os consumidores usam. `gitea` e `wedding` conectam ao Postgres e MinIO de lá pela `infra-net`.
> Histórico: nasceu como monorepo `wedding-app` (branch `main`) com tudo em `infra/{main,gitea,wedding_photo}/`. Foi dividido em 3 repos (fresh start, sem histórico herdado — o monorepo original fica arquivado como backup).
### `lronetto-main` é infra base compartilhada (não só do casamento)
`lronetto-main` não é infra dedicada ao casamento — é a **infra base** (Postgres + Redis + MinIO + pgAdmin + Caddy + `infra-net`) que **outros serviços independentes** também consomem, além de `gitea` e `wedding`. Todos rodam no mesmo VPS, todos hospedados no mesmo Gitea self-hosted sob o prefixo `lronetto-`:
| Repo | URL | Escopo | Orquestrado por este `Makefile`? |
|---|---|---|---|
| `lronetto-main` | `https://gitea.lronetto.com/lronetto/lronetto-main.git` | Este repo — infra base (platform layer) + orquestrador | — |
| `lronetto-gitea` | `https://gitea.lronetto.com/lronetto/lronetto-gitea.git` | Git hosting + Actions, hospeda todos os repos `lronetto-*` (dogfooding) | Sim (`up-gitea`) |
| `lronetto-wedding` | `https://gitea.lronetto.com/lronetto/lronetto-wedding.git` | App do casamento (é o que este `CLAUDE.md` documenta em detalhe) | Sim (`up-wedding`) |
| `lronetto-finance` | `https://gitea.lronetto.com/lronetto/lronetto-finance.git` | Open Finance (Pluggy) — outro projeto, não relacionado ao casamento | Sim (`up-finance`) |
| `lronetto-claudeweb` | `https://gitea.lronetto.com/lronetto/lronetto-claudeweb.git` | Interface web pro Claude Code (Agent SDK), single-user — outro projeto, não relacionado ao casamento | Sim (`up-claudeweb`) |
`lronetto-finance` e `lronetto-claudeweb`**já estão provisionados e rodando em produção**, consumindo Postgres/Redis/MinIO/Caddy deste repo pela `infra-net` — assim como wedding e gitea. Cada um segue o mesmo padrão de "app self-contained, sem Postgres/Redis/Caddy próprios" já usado pela wedding: repo com `docker-compose.yml` (ou `deploy/compose.yml`) próprio, `infra-net` declarada como `external`, containers conectados por nome (`postgres`, `redis`, `minio`), sem porta pública (exposição só via Caddy do main).
**O que existe hoje neste repo pra suportar os dois:**
-`postgres/init/01-create-databases.sh`: cria roles/databases `finance` e `claudeweb` (além de `wedding`/`gitea`), a partir de `FINANCE_DB_*`/`CLAUDEWEB_DB_*` no `.env`
-`docker-compose.yml` (serviço `postgres`): repassa `FINANCE_DB_*`/`CLAUDEWEB_DB_*` pro init script
-`minio/init.sh`: cria também o bucket `claudeweb-artifacts` (privado, sem download anônimo — usado só se `lronetto-claudeweb` tiver `S3_ENDPOINT` preenchido, pra artefatos de sessão M3/RF-14); `lronetto-finance` não usa MinIO
-`caddy/Caddyfile`: roteia `finance.{DOMAIN_BASE}` → `finance_app:8000` e **`code.{DOMAIN_BASE}`** (não `claudeweb.{DOMAIN_BASE}`!) → `claudeweb-api:8300` (rotas `/api/*`, `/healthz`, `/ws/*`) ou `claudeweb-web:80` (resto, SPA estática)
-`Makefile`: targets `up-finance`/`down-finance`/`logs-finance`/`pull-finance`/`rebuild-finance` e os equivalentes `-claudeweb`, seguindo o padrão de `WEDDING_DIR=`/`GITEA_DIR=` (agora também `FINANCE_DIR=` / `CLAUDEWEB_DIR=`, default `../lronetto-finance` / `../lronetto-claudeweb`)
**Diferença importante**: `make up`/`down`/`restart`/`status` continuam cobrindo só o **core** (main + gitea + wedding — os 3 repos que este documento detalha). `finance` e `claudeweb` têm targets avulsos (`make up-finance`, `make up-claudeweb`, etc.) porque têm ciclo de vida e deploy CI próprios, independentes deste projeto — não entram no `up`/`down` agregado de propósito. `make status` já lista os dois (com `|| true`, então não quebra se não estiverem clonados/de pé).
**Gotcha extra do `lronetto-claudeweb`**: ele cria e gerencia por fora uma segunda network Docker, `claudeweb-workers` (não é a `infra-net`), e **conecta manualmente os containers `redis`, `gitea` e (opcionalmente) `minio` deste repo a ela** — é assim que os containers efêmeros de worker (Agent SDK) do claudeweb falam com Redis/Gitea/MinIO sem entrar na `infra-net` (isolamento de egress). Isso significa que `redis`/`gitea`/`minio` ficam com **duas networks anexadas** por fora do `docker-compose.yml` deste repo — um `docker network disconnect` acidental, um recreate de container, ou uma limpeza de networks (`docker network prune`) pode quebrar essa conexão sem avisar aqui. Ver `README.md` do `lronetto-claudeweb` pra recriar (`docker network connect claudeweb-workers <container>`).
> Nota sobre backup: o `scripts/vps-backup.sh` (seção 8) já cobre `finance`/`claudeweb` de graça — o `pg_dumpall` do passo 1 dumpa **todos** os bancos da instância, e o script agora também copia o `.env` de `lronetto-finance`/`lronetto-claudeweb` (via `FINANCE_DIR=`/`CLAUDEWEB_DIR=`) se estiverem clonados como irmãos.
├── packages/shared/ # Zod schemas TS (usado pelo web)
├── infra/backup/ # entrypoint + script do media-backup
└── backups/ # destino dos sidecars (gitignored)
├── postgres/
└── media/
```
---
## 5. Network e roteamento
### Network
`infra-net` é **external**. Criada pelo `network.sh` (chamada por `make network`). Containers de stacks diferentes se enxergam por nome via DNS interno do Docker.
- **Produção**: domínio real (ex.: `lronetto.com`) → Let's Encrypt automático
- Mudar `DOMAIN_BASE` requer **down + up** das 3 stacks (envs lidos no boot)
### Hairpin / `extra_hosts`
`wedding_app` tem `extra_hosts: media.{DOMAIN_BASE}:host-gateway` e `wedding.{DOMAIN_BASE}:host-gateway` pra que, mesmo dentro do container, ele resolva esses hostnames pro Docker host gateway → Caddy. Assim assinaturas S3 fecham (signing host == host que o browser usa pra PUT).
2. Browser faz **`PUT` direto no MinIO** (não passa pelo backend) com a URL pré-assinada
3. Decisão **single vs multipart**:
-`<= 50 MB`: single PUT
-`> 50 MB`: multipart 10 MB chunks
4.`POST /api/uploads/:id/confirm` → backend faz HEAD pra verificar o objeto, completa multipart se aplicável, **se for HEIC**: transcoda pra JPEG e substitui o storage_key, marca `approved` (ou `pending` se moderation=`pre`)
5. Galeria pública lista só `status=approved`
### HEIC transcoding
- Detecção: `mime_type in {"image/heic", "image/heif"}`
- Em `/confirm` após HEAD: baixa, decoda com pillow-heif, aplica EXIF rotation, salva JPEG quality 88 progressive, escreve com `.jpg`, deleta HEIC
- **Falha não bloqueia o upload**: log + mantém HEIC original (gallery mostra placeholder)
- Razão: browsers (especialmente Android) não renderizam HEIC nativamente
### Multipart upload (cliente)
- Em `apps/web/src/lib/upload.ts`
- Sequential (não paralelo pra MVP) com `XMLHttpRequest` (precisa de progress event)
- ETag de cada chunk via header `etag` na resposta — exige CORS `ExposeHeaders: ["ETag"]` no bucket
### Admin login
-`POST /api/admin/login` body `{email, password}`
- Valida email contra `ALLOWED_ADMIN_EMAILS` (separados por vírgula no env)
- Compara senha com `ADMIN_PASSWORD` via `hmac.compare_digest` (constant-time)
- Issue cookie HttpOnly JWT HS256 com email + exp 7 dias
-`GET /api/admin/*` decora com `Depends(get_admin_email)` que verifica o cookie
### Admin: gestão de uploads
-`GET /api/admin/uploads?status=...&kind=photo|video&q=...&cursor=...` — paginação por timestamp DESC
-`PATCH /api/admin/uploads/:id` — edita `authorName` + `message`
- Script único que cobre o que os sidecars da wedding **não** cobrem: dump lógico completo do Postgres (`pg_dumpall`, todos os bancos + roles, não só o da wedding), snapshot RDB do Redis, tar do diretório de dados do Gitea (repos + config), `.env` das 3 stacks e os certs do Caddy (evita reemissão no Let's Encrypt após um restore)
- Empacota tudo num único `.tar.gz` com permissão `600` (tem segredo dentro — os `.env`) em `backups/vps/`, com rotação por `BACKUP_RETENTION_DAYS` (default 14 dias)
- Envio offsite opcional via `rclone` se `BACKUP_REMOTE` estiver setado (ex.: `r2:meu-bucket/vps-backup`)
- Roda **no host**, fora de containers (usa `docker exec`/`docker cp` pra falar com `postgres`/`redis`, e lê os diretórios dos repos irmãos via `GITEA_DIR=`/`WEDDING_DIR=`, mesma convenção do Makefile orquestrador)
- Pensado pra cron diário (fora de horário de pico):
- Restore é manual (não tem script de restore automático — cada peça volta do seu jeito): `gunzip -c postgres-all.sql.gz | docker exec -i postgres psql -U postgres`, `tar -xzf gitea-data.tar.gz -C ../lronetto-gitea/`, etc.
Vive no repo **`lronetto-wedding`** (`.gitea/workflows/deploy.yml`). A cada push
na `main` — ou disparo manual (`workflow_dispatch`) — o runner conecta por
**SSH no host** e roda `make deploy` (git reset --hard + `up` com build no repo
wedding), seguido de health check em `/api/health`.
**Por que SSH e não docker direto no runner**: o `.env` (segredos do app) fica
só no host, fora do git. O host já tem o repo clonado; o deploy só atualiza o
código e sobe.
Setup (1ª vez):
1. No host, clone `lronetto-wedding` (ex.: `/opt/lronetto-wedding`) com um usuário
SSH que rode docker e tenha acesso ao repo. (Stack `main` precisa estar de pé.)
2. Gere um par de chaves SSH e adicione a **pública** no `~/.ssh/authorized_keys`
desse usuário.
3. No repo `lronetto-wedding` no Gitea → **Settings → Actions → Secrets**, crie:
-`DEPLOY_HOST` — IP/hostname do host
-`DEPLOY_USER` — usuário SSH
-`DEPLOY_SSH_KEY` — a chave **privada** (conteúdo completo)
-`DEPLOY_PATH` — caminho do repo no host (ex.: `/opt/lronetto-wedding`)
4. (Opcional) **Variables**: `DEPLOY_PORT` se o SSH não for 22.
`make deploy` (no repo wedding) aceita `BRANCH=` pra sobrescrever a branch.
---
## 9. Gotchas conhecidos
### Postgres init script só roda na 1ª vez
`postgres/init/01-create-databases.sh` (neste repo, `lronetto-main`) é executado pelo entrypoint do postgres **apenas quando `PGDATA` está vazio**. Pra "rerodar":
```bash
make down-main
sudo rm -rf postgres/data
make up-main
```
Alternativa: criar manualmente via `docker exec -i postgres psql`.
### `docker exec -it` com heredoc
`-t` aloca TTY e conflita com stdin redirecionado. Usar **`-i` só**:
```bash
docker exec -i postgres psql -U postgres <<EOF
CREATE ROLE gitea WITH LOGIN PASSWORD 'xxx';
EOF
```
### Gitea reservou nomes
`admin`, `api`, `user`, `org`, `explore`, `install`, `repo` etc. são reservados. Use outro nome (ex.: `lronetto`, `gitadmin`). O role admin vem do flag `--admin`, não do username.
Usamos a **regular** com `INSTALL_LOCK=true` pra pular o wizard `/install` no primeiro boot.
### `docker exec` por padrão entra como root
Pra Gitea, isso quebra (`Gitea is not supposed to be run as root`). Sempre `docker exec -u git ...` quando for chamar binário do gitea.
### CORS no MinIO
Bucket precisa de `ExposeHeaders: ["ETag"]` pra multipart funcionar (cliente lê ETag de cada PUT). Aplicado pelo `minio/init.sh` (repo `lronetto-main`) via `mc cors set`.
### Postgres path-style URLs
`S3_FORCE_PATH_STYLE=true` é necessário pra MinIO. Sem isso, boto3 monta URL virtual-hosted (`bucket.endpoint/key`) que MinIO single-instance não suporta.
### Mudança em `.env` exige restart
Compose só lê env vars no boot do container. `make down-X && make up-X` após editar.
### TLS local em `*.localhost`
Caddy emite cert auto-assinado pelo root local. Browser pede pra aceitar (1x por subdomínio). Pra evitar prompts:
Pra produção, registros A pros subdomínios (`wedding`, `gitea`, `media`, `pgadmin`, `minio`) precisam estar propagados antes do Caddy tentar emitir cert. Senão ele entra em backoff e demora.
### `ACME_EMAIL` obrigatório em prod
Let's Encrypt requer email pra contato de renovação. Em dev pode ficar vazio (Caddy usa internal CA).
### Hairpin DNS no `wedding_app`
O `extra_hosts: host-gateway` pra `media.{DOMAIN_BASE}` faz o container resolver o subdomínio pro host. Sem isso, requests server-side (HEAD/DELETE/multipart complete) vão pra IP público → roteador → host → Caddy (lentidão). Com host-gateway: container → host → Caddy (rápido).
---
## 10. Histórico de iterações (sem detalhe — referência rápida)
1.**MVP Cloudflare**: Workers + D1 + R2 + Pages + Access. Funcionou mas Access não rola em `*.workers.dev`.
2.**Migração 1**: full Docker (Node/Hono + Postgres + MinIO + Caddy). Single compose, deploy num VPS.
3.**Migração 2**: backend reescrito em Python (FastAPI + SQLAlchemy + boto3 + pyjwt). Mesmo contrato de API. Frontend não mexeu.
4.**HEIC + backup**: pillow-heif no `/confirm`, 2 sidecars de backup (Postgres + MinIO mirror).
5.**Reorganização em 3 stacks**: `infra/{main,gitea,wedding_photo}` no monorepo `wedding-app`, compartilhando `infra-net`. Caddy concentrado em `main/`.
6.**Deploy CI + rename main**: workflow Gitea Actions de deploy via SSH; branch renomeada pra `main`.
7.**Split em 3 repos**: `lronetto-main` + `lronetto-gitea` + `lronetto-wedding` (fresh start). Orquestrador no main, deploy CI no wedding. Monorepo `wedding-app` arquivado.
| Nova app na rede | novo repo com `docker-compose.yml`, declarar `infra-net` como external, conectar a `postgres`/`redis`/`minio` por nome, e referenciar no Makefile orquestrador. Exemplos reais já rodando assim: `lronetto-finance` e `lronetto-claudeweb` (seção 2) |
| Nova tela no front | `apps/web/src/routes/` + rota em `App.tsx` |
| Schema compartilhado front-back | duplica: Zod em `packages/shared/src/schemas.ts` (TS) + Pydantic em `apps/api/app/schemas/api.py` (Python). **Camelo nos dois** |
| Workflow CI no Gitea | `.gitea/workflows/*.yml` no repo alvo (sintaxe GitHub Actions) — runner já registrado. Ex.: deploy do app em `lronetto-wedding` |
---
## 12. Decisões deferidas (a fazer se necessário)
- **Thumbnails server-side**: galeria carrega imagens full-size. Pra otimizar: `sharp` no upload `/confirm` (ou um job async), salvar `thumbnail_key`. ~2h.
- **Export ZIP do admin**: streamed ZIP de todos os uploads. ~1h.
- **Rate limit em `/uploads/init`**: hoje sem limite. Vale colocar Redis-based se houver suspeita de abuso. ~1h.
- **Email aos noivos quando upload chegar**: Resend ou SMTP. ~2h.
- **Slideshow pra projetar na recepção**: tela `/slideshow` com auto-advance. ~30 min.
- **Custom domain do bucket público** (em vez de `media.X`): mais "branded". DNS + CNAME pro MinIO. ~15 min.
- **Pivot multi-tenant SaaS**: ver seção 1 — 2-4 semanas se valer a pena.