Compare commits

..

4 Commits

Author SHA1 Message Date
399e7be3a9 test: refresh visual baselines from CI Ubuntu screenshots
Media now loads on same-origin /storage paths, so baselines must
capture the rendered fixtures. Use full-page snapshots from the CI
runner to keep Pest's exact snapshot match stable across environments.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 09:01:56 -03:00
0adaadd9ed fix: serve public media on same-origin storage paths
Pest Browser hosts on 127.0.0.1:port while Storage::url used
http://localhost, so screenshots captured broken images. Use relative
/storage URLs for media and absolutize only OG tags via url().

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 08:55:24 -03:00
2405160046 fix: make browser visual CI deterministic for media
Mount host public storage into FrankenPHP so seeded fixtures are served,
and replace PNG-as-JPG fixtures with real JPEGs so Chromium can render them.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 08:48:31 -03:00
27f3cee855 feat: ship public site with SEO and visuals
Publish CMS content on public routes with responsive media,
accessibility checks, and deterministic visual baselines.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 08:36:59 -03:00
18 changed files with 5 additions and 881 deletions

View File

@@ -53,7 +53,6 @@ REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null REDIS_PASSWORD=null
REDIS_PORT=6379 REDIS_PORT=6379
# Local/CI: log or array. Production transactional email: MAIL_MAILER=resend
MAIL_MAILER=log MAIL_MAILER=log
MAIL_SCHEME=null MAIL_SCHEME=null
MAIL_HOST=127.0.0.1 MAIL_HOST=127.0.0.1
@@ -63,22 +62,10 @@ MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}" MAIL_FROM_NAME="${APP_NAME}"
# Required when MAIL_MAILER=resend
RESEND_API_KEY=
AWS_ACCESS_KEY_ID= AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY= AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1 AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET= AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false AWS_USE_PATH_STYLE_ENDPOINT=false
# Cloudflare R2 (production object storage). Set FILESYSTEM_DISK=r2 in production.
# R2_URL must be the custom domain mapped to the bucket (e.g. https://media.example.com).
# R2_ENDPOINT example: https://<account_id>.r2.cloudflarestorage.com
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET=
R2_ENDPOINT=
R2_URL=
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"

View File

@@ -1,21 +0,0 @@
## Design Context
### Users
Visitantes procurando assessoria para casamentos, eventos corporativos e celebrações íntimas, com peso equivalente entre essas categorias. Público inicial está concentrado em Fortaleza e precisa entender serviços, confiar na assessoria, ver eventos reais e solicitar contato com segurança. Interface pública deve converter sem pressão; painel interno deve continuar funcional e visualmente separado.
### Brand Personality
Serena, elegante e confiável. Voz profissional, acolhedora e segura, com linguagem clara em português do Brasil. Experiência deve transmitir calma durante uma decisão emocional e complexa, demonstrando organização e cuidado sem ostentação.
### Aesthetic Direction
Direção editorial orgânica, em modo claro: fotografia ampla, composição arejada, hierarquia tipográfica clara e texturas discretas. Priorizar imagens documentais espontâneas, luz quente, pessoas e emoção. Paleta atual é identidade aprovada: fundo creme `#fffdf8`, superfícies suaves `#f5f0e8`, texto escuro `#1a1410` e dourado `#8a6500`. Logo e favicon definitivos serão fornecidos depois; até lá, usar wordmark textual. Manter Instrument Sans como tipografia provisória sem inventar nova combinação. Não há referência externa obrigatória.
### Design Principles
1. **Serenidade que gera confiança.** Reduzir ruído, evitar urgência artificial e tornar cada próximo passo evidente.
2. **Fotografia conduz narrativa.** Usar momentos humanos e espontâneos como prova principal; interface serve de moldura, não compete com imagens.
3. **Elegância por edição.** Poucos elementos simultâneos, espaçamento generoso, tipografia hierárquica e detalhes sutis; evitar clichês de luxo e aparência de template.
4. **Conversão inclusiva.** CTAs claros e acessíveis, com conformidade WCAG 2.2 AA, navegação por teclado, foco visível, zoom de 200%, suporte a leitor de tela e movimento reduzido.
5. **Consistência antes de novidade.** Reutilizar tokens e padrões existentes, preservar abordagem mobile-first e manter sistema público separado do Filament.

View File

@@ -30,25 +30,3 @@ History follows Conventional Commit-style subjects, for example `feat: Fase 0
## Security & Configuration ## Security & Configuration
Copy `.env.example`; never commit secrets or production credentials. Development seed credentials are local-only. Validate uploads and authorization through Laravel policies, and run `composer security-audit` after dependency changes. Copy `.env.example`; never commit secrets or production credentials. Development seed credentials are local-only. Validate uploads and authorization through Laravel policies, and run `composer security-audit` after dependency changes.
## Design Context
### Users
Visitantes procurando assessoria para casamentos, eventos corporativos e celebrações íntimas, com peso equivalente entre essas categorias. Público inicial está concentrado em Fortaleza e precisa entender serviços, confiar na assessoria, ver eventos reais e solicitar contato com segurança. Interface pública deve converter sem pressão; painel interno deve continuar funcional e visualmente separado.
### Brand Personality
Serena, elegante e confiável. Voz profissional, acolhedora e segura, com linguagem clara em português do Brasil. Experiência deve transmitir calma durante uma decisão emocional e complexa, demonstrando organização e cuidado sem ostentação.
### Aesthetic Direction
Direção editorial orgânica, em modo claro: fotografia ampla, composição arejada, hierarquia tipográfica clara e texturas discretas. Priorizar imagens documentais espontâneas, luz quente, pessoas e emoção. Paleta atual é identidade aprovada: fundo creme `#fffdf8`, superfícies suaves `#f5f0e8`, texto escuro `#1a1410` e dourado `#8a6500`. Logo e favicon definitivos serão fornecidos depois; até lá, usar wordmark textual. Manter Instrument Sans como tipografia provisória sem inventar nova combinação. Não há referência externa obrigatória.
### Design Principles
1. **Serenidade que gera confiança.** Reduzir ruído, evitar urgência artificial e tornar cada próximo passo evidente.
2. **Fotografia conduz narrativa.** Usar momentos humanos e espontâneos como prova principal; interface serve de moldura, não compete com imagens.
3. **Elegância por edição.** Poucos elementos simultâneos, espaçamento generoso, tipografia hierárquica e detalhes sutis; evitar clichês de luxo e aparência de template.
4. **Conversão inclusiva.** CTAs claros e acessíveis, com conformidade WCAG 2.2 AA, navegação por teclado, foco visível, zoom de 200%, suporte a leitor de tela e movimento reduzido.
5. **Consistência antes de novidade.** Reutilizar tokens e padrões existentes, preservar abordagem mobile-first e manter sistema público separado do Filament.

View File

@@ -82,33 +82,13 @@ docker exec amare-postgres psql -U amare -d amare -c "CREATE DATABASE amare_test
## Armazenamento de mídia ## Armazenamento de mídia
Uploads de conteúdo usam o disco `public` em desenvolvimento. Crie o symlink antes de servir arquivos localmente: Uploads de conteúdo usam o disco `public`. Crie o symlink antes de servir arquivos localmente:
```bash ```bash
php artisan storage:link php artisan storage:link
``` ```
Em produção, configure `FILESYSTEM_DISK=r2` (Cloudflare R2). O disco legado `s3` continua suportado se necessário. Em produção, configure `FILESYSTEM_DISK=s3` no `.env`.
## Provedores de produção
Produção usa **Resend** (e-mail transacional) e **Cloudflare R2** (mídia pública via domínio customizado). Local e CI permanecem com defaults seguros (`MAIL_MAILER=log`/`array`, disco `public`).
### Checklist de variáveis (produção)
| Variável | Valor |
|---|---|
| `MAIL_MAILER` | `resend` |
| `RESEND_API_KEY` | API key Resend |
| `MAIL_FROM_ADDRESS` / `MAIL_FROM_NAME` | Remetente verificado no Resend |
| `FILESYSTEM_DISK` | `r2` |
| `R2_ACCESS_KEY_ID` | Access key do token R2 |
| `R2_SECRET_ACCESS_KEY` | Secret do token R2 |
| `R2_BUCKET` | Nome do bucket |
| `R2_ENDPOINT` | `https://<account_id>.r2.cloudflarestorage.com` |
| `R2_URL` | Domínio customizado público (ex.: `https://media.example.com`) |
Infra manual (fora do app): criar bucket R2, token API, mapear domínio customizado ao bucket, criar API key Resend e verificar domínio de envio.
## Credenciais de desenvolvimento ## Credenciais de desenvolvimento

View File

@@ -86,10 +86,6 @@ final class PublicImageUploadRules
public static function disk(): string public static function disk(): string
{ {
return match (config('filesystems.default')) { return config('filesystems.default') === 's3' ? 's3' : 'public';
'r2' => 'r2',
's3' => 's3',
default => 'public',
};
} }
} }

View File

@@ -10,9 +10,7 @@
"filament/filament": "^5.0", "filament/filament": "^5.0",
"intervention/image": "^3.0", "intervention/image": "^3.0",
"laravel/framework": "^13.8", "laravel/framework": "^13.8",
"laravel/tinker": "^3.0", "laravel/tinker": "^3.0"
"league/flysystem-aws-s3-v3": "^3.35",
"resend/resend-php": "^1.7"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",

404
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "cf50a933bd18a3ac949ec7aa0b4a1d57", "content-hash": "b7cd97df13085158d9ecb6ad877a600f",
"packages": [ "packages": [
{ {
"name": "anourvalar/eloquent-serialize", "name": "anourvalar/eloquent-serialize",
@@ -71,157 +71,6 @@
}, },
"time": "2026-06-20T14:30:25+00:00" "time": "2026-06-20T14:30:25+00:00"
}, },
{
"name": "aws/aws-crt-php",
"version": "v1.2.7",
"source": {
"type": "git",
"url": "https://github.com/awslabs/aws-crt-php.git",
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e",
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e",
"shasum": ""
},
"require": {
"php": ">=5.5"
},
"require-dev": {
"phpunit/phpunit": "^4.8.35||^5.6.3||^9.5",
"yoast/phpunit-polyfills": "^1.0"
},
"suggest": {
"ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality."
},
"type": "library",
"autoload": {
"classmap": [
"src/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "AWS SDK Common Runtime Team",
"email": "aws-sdk-common-runtime@amazon.com"
}
],
"description": "AWS Common Runtime for PHP",
"homepage": "https://github.com/awslabs/aws-crt-php",
"keywords": [
"amazon",
"aws",
"crt",
"sdk"
],
"support": {
"issues": "https://github.com/awslabs/aws-crt-php/issues",
"source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7"
},
"time": "2024-10-18T22:15:13+00:00"
},
{
"name": "aws/aws-sdk-php",
"version": "3.389.3",
"source": {
"type": "git",
"url": "https://github.com/aws/aws-sdk-php.git",
"reference": "09bbb4023a14316ff2c5e568301a382f7e3465f5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/09bbb4023a14316ff2c5e568301a382f7e3465f5",
"reference": "09bbb4023a14316ff2c5e568301a382f7e3465f5",
"shasum": ""
},
"require": {
"aws/aws-crt-php": "^1.2.3",
"ext-json": "*",
"ext-pcre": "*",
"ext-simplexml": "*",
"guzzlehttp/guzzle": "^7.8.2 || ^8.0",
"guzzlehttp/promises": "^2.0.3 || ^3.0",
"guzzlehttp/psr7": "^2.6.3 || ^3.0",
"mtdowling/jmespath.php": "^2.9.1",
"php": ">=8.1",
"psr/http-message": "^1.0 || ^2.0",
"symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0"
},
"require-dev": {
"andrewsville/php-token-reflection": "^1.4",
"aws/aws-php-sns-message-validator": "~1.0",
"behat/behat": "~3.0",
"composer/composer": "^2.7.8",
"dms/phpunit-arraysubset-asserts": "^v0.5.0",
"doctrine/cache": "~1.4",
"ext-dom": "*",
"ext-openssl": "*",
"ext-sockets": "*",
"phpunit/phpunit": "^10.0",
"psr/cache": "^2.0 || ^3.0",
"psr/simple-cache": "^2.0 || ^3.0",
"sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0",
"yoast/phpunit-polyfills": "^2.0"
},
"suggest": {
"aws/aws-php-sns-message-validator": "To validate incoming SNS notifications",
"doctrine/cache": "To use the DoctrineCacheAdapter",
"ext-curl": "To send requests using cURL",
"ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages",
"ext-pcntl": "To use client-side monitoring",
"ext-sockets": "To use client-side monitoring"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.0-dev"
}
},
"autoload": {
"files": [
"src/functions.php"
],
"psr-4": {
"Aws\\": "src/"
},
"exclude-from-classmap": [
"src/data/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Amazon Web Services",
"homepage": "https://aws.amazon.com"
}
],
"description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project",
"homepage": "https://aws.amazon.com/sdk-for-php",
"keywords": [
"amazon",
"aws",
"cloud",
"dynamodb",
"ec2",
"glacier",
"s3",
"sdk"
],
"support": {
"forum": "https://github.com/aws/aws-sdk-php/discussions",
"issues": "https://github.com/aws/aws-sdk-php/issues",
"source": "https://github.com/aws/aws-sdk-php/tree/3.389.3"
},
"time": "2026-07-29T18:09:38+00:00"
},
{ {
"name": "blade-ui-kit/blade-heroicons", "name": "blade-ui-kit/blade-heroicons",
"version": "2.7.0", "version": "2.7.0",
@@ -3163,61 +3012,6 @@
}, },
"time": "2026-07-06T14:42:07+00:00" "time": "2026-07-06T14:42:07+00:00"
}, },
{
"name": "league/flysystem-aws-s3-v3",
"version": "3.35.2",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git",
"reference": "8475ef9adfc6498b85469e2abec6fe3118cd08c4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/8475ef9adfc6498b85469e2abec6fe3118cd08c4",
"reference": "8475ef9adfc6498b85469e2abec6fe3118cd08c4",
"shasum": ""
},
"require": {
"aws/aws-sdk-php": "^3.371.5",
"league/flysystem": "^3.10.0",
"league/mime-type-detection": "^1.0.0",
"php": "^8.0.2"
},
"conflict": {
"guzzlehttp/guzzle": "<7.0",
"guzzlehttp/ringphp": "<1.1.1"
},
"type": "library",
"autoload": {
"psr-4": {
"League\\Flysystem\\AwsS3V3\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Frank de Jonge",
"email": "info@frankdejonge.nl"
}
],
"description": "AWS S3 filesystem adapter for Flysystem.",
"keywords": [
"Flysystem",
"aws",
"file",
"files",
"filesystem",
"s3",
"storage"
],
"support": {
"source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.35.2"
},
"time": "2026-07-01T23:25:49+00:00"
},
{ {
"name": "league/flysystem-local", "name": "league/flysystem-local",
"version": "3.31.0", "version": "3.31.0",
@@ -3768,72 +3562,6 @@
], ],
"time": "2026-01-02T08:56:05+00:00" "time": "2026-01-02T08:56:05+00:00"
}, },
{
"name": "mtdowling/jmespath.php",
"version": "2.9.2",
"source": {
"type": "git",
"url": "https://github.com/jmespath/jmespath.php.git",
"reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/2157c5e50e813ec6a96c1eed3be7f64a20fb32a8",
"reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8",
"shasum": ""
},
"require": {
"php": "^7.2.5 || ^8.0",
"symfony/polyfill-mbstring": "^1.17"
},
"require-dev": {
"composer/xdebug-handler": "^3.0.3",
"phpunit/phpunit": "^8.5.52"
},
"bin": [
"bin/jp.php"
],
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.9-dev"
}
},
"autoload": {
"files": [
"src/JmesPath.php"
],
"psr-4": {
"JmesPath\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
},
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
}
],
"description": "Declaratively specify how to extract elements from a JSON document",
"keywords": [
"json",
"jsonpath"
],
"support": {
"issues": "https://github.com/jmespath/jmespath.php/issues",
"source": "https://github.com/jmespath/jmespath.php/tree/2.9.2"
},
"time": "2026-07-06T18:56:19+00:00"
},
{ {
"name": "nesbot/carbon", "name": "nesbot/carbon",
"version": "3.13.1", "version": "3.13.1",
@@ -5361,65 +5089,6 @@
}, },
"time": "2026-06-18T03:57:49+00:00" "time": "2026-06-18T03:57:49+00:00"
}, },
{
"name": "resend/resend-php",
"version": "v1.7.0",
"source": {
"type": "git",
"url": "https://github.com/resend/resend-php.git",
"reference": "9516207c4f6ff210c64b193e31074e925dd6193a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/resend/resend-php/zipball/9516207c4f6ff210c64b193e31074e925dd6193a",
"reference": "9516207c4f6ff210c64b193e31074e925dd6193a",
"shasum": ""
},
"require": {
"guzzlehttp/guzzle": "^7.8.2 || ^8.0",
"guzzlehttp/psr7": "^2.6.3 || ^3.0",
"php": "^8.1.0",
"psr/http-client": "^1.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.13",
"mockery/mockery": "^1.6",
"pestphp/pest": "^1.0|^2.0|^3.0|^4.0"
},
"type": "library",
"autoload": {
"files": [
"src/Resend.php"
],
"psr-4": {
"Resend\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Resend and contributors",
"homepage": "https://github.com/resend/resend-php/contributors"
}
],
"description": "Resend PHP library.",
"homepage": "https://resend.com/",
"keywords": [
"api",
"client",
"php",
"resend",
"sdk"
],
"support": {
"issues": "https://github.com/resend/resend-php/issues",
"source": "https://github.com/resend/resend-php/tree/v1.7.0"
},
"time": "2026-07-27T21:14:02+00:00"
},
{ {
"name": "ryangjchandler/blade-capture-directive", "name": "ryangjchandler/blade-capture-directive",
"version": "v1.1.1", "version": "v1.1.1",
@@ -6325,77 +5994,6 @@
], ],
"time": "2026-06-05T06:23:12+00:00" "time": "2026-06-05T06:23:12+00:00"
}, },
{
"name": "symfony/filesystem",
"version": "v8.1.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/filesystem.git",
"reference": "17856b7a222664a26a5ea1cb06ee0721c2438217"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/filesystem/zipball/17856b7a222664a26a5ea1cb06ee0721c2438217",
"reference": "17856b7a222664a26a5ea1cb06ee0721c2438217",
"shasum": ""
},
"require": {
"php": ">=8.4.1",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-ctype": "~1.8",
"symfony/polyfill-mbstring": "~1.8"
},
"require-dev": {
"symfony/process": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Filesystem\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides basic utilities for the filesystem",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/filesystem/tree/v8.1.2"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-07-22T15:42:13+00:00"
},
{ {
"name": "symfony/finder", "name": "symfony/finder",
"version": "v8.1.1", "version": "v8.1.1",

View File

@@ -62,20 +62,6 @@ return [
'report' => false, 'report' => false,
], ],
'r2' => [
'driver' => 's3',
'key' => env('R2_ACCESS_KEY_ID'),
'secret' => env('R2_SECRET_ACCESS_KEY'),
'region' => 'auto',
'bucket' => env('R2_BUCKET'),
'url' => env('R2_URL'),
'endpoint' => env('R2_ENDPOINT'),
'use_path_style_endpoint' => true,
'visibility' => 'public',
'throw' => false,
'report' => false,
],
], ],
/* /*

View File

@@ -1,2 +0,0 @@
schema: spec-driven
created: 2026-07-29

View File

@@ -1,80 +0,0 @@
## Context
Fase 01 entregaram CMS com uploads de mídia via `PublicImageUploadRules`, que hoje seleciona disco `public` localmente e `s3` quando `FILESYSTEM_DISK=s3`. Laravel 13 já inclui mailer `resend` em [`config/mail.php`](../../config/mail.php) e chave em [`config/services.php`](../../config/services.php), mas faltam dependências (`resend/resend-php`, `league/flysystem-aws-s3-v3`) e configuração explícita para Cloudflare R2.
Produção usará **Resend** para e-mail transacional e **Cloudflare R2** para mídia pública via domínio customizado. Local e CI continuam com `MAIL_MAILER=log`/`array` e disco `public`.
## Goals / Non-Goals
**Goals:**
- Resend como transporte nativo Laravel em produção (`MAIL_MAILER=resend`, `RESEND_API_KEY`).
- Disco dedicado `r2` com variáveis explícitas (`R2_*`) e URL pública via `R2_URL` (domínio customizado).
- Seleção de disco de mídia alinhada a `FILESYSTEM_DISK=r2`.
- Documentação de env vars e testes automatizados de config/seleção (sem chamadas live).
**Non-Goals:**
- Templates de e-mail, filas de notificação de leads, ou fluxos CRM.
- Buckets privados, signed URLs, ou automação de DNS/CDN.
- Mudança de queue driver ou provisionamento Cloudflare via código.
## Decisions
### 1. Resend via transporte nativo Laravel
**Decisão:** usar mailer `resend` já presente em `config/mail.php` + pacote `resend/resend-php`. Credencial em `config/services.php``RESEND_API_KEY`.
**Alternativa rejeitada:** SMTP genérico — menos idiomático; perde integração nativa Laravel 13.
### 2. Disco dedicado `r2` (não reutilizar nome `s3`)
**Decisão:** adicionar disco `r2` em `config/filesystems.php` com driver `s3`, endpoint R2 (`https://<account_id>.r2.cloudflarestorage.com`), `use_path_style_endpoint=true`, bucket e credenciais via `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET`, `R2_ENDPOINT`, `R2_URL` (domínio público customizado).
**Alternativa rejeitada:** reutilizar disco `s3` com vars `AWS_*` — funciona tecnicamente, mas obscurece provedor real e conflita com futuro uso de AWS S3.
### 3. `FILESYSTEM_DISK=r2` em produção
**Decisão:** default disk de produção = `r2`. Local/test = `local` ou `public`. `PublicImageUploadRules::disk()` retorna `r2` quando default é `r2`, `s3` quando default é `s3`, senão `public`.
**Alternativa rejeitada:** mapear `r2` para disco `s3` internamente — confunde operadores e quebra intenção explícita do env.
### 4. URLs públicas via domínio customizado
**Decisão:** `R2_URL` aponta para domínio customizado (ex.: `https://media.example.com`) mapeado ao bucket via Cloudflare. Disco `r2` define `url => env('R2_URL')` e `visibility => public`.
**Alternativa rejeitada:** URL `r2.dev` gerenciada — menos controle de marca e SEO; usuário escolheu custom domain.
### 5. Testes sem integração live
**Decisão:** testes usam `Mail::fake`, `Storage::fake('r2')`, e asserts de config (`config('mail.default')`, `config('filesystems.disks.r2')`, `PublicImageUploadRules::disk()`). Nenhuma chamada HTTP a Resend/R2 no CI.
### 6. Defaults seguros por ambiente
**Decisão:**
| Ambiente | `MAIL_MAILER` | `FILESYSTEM_DISK` | Disco de upload CMS |
|---|---|---|---|
| Local dev | `log` | `local` | `public` |
| Testes (phpunit) | `array` | (unset → local) | `public` |
| Produção | `resend` | `r2` | `r2` |
## Risks / Trade-offs
- **[R2 credentials missing in prod]** → app boot ok, upload fails at runtime; document required vars in `.env.example` and README; config test asserts disk definition exists.
- **[Custom domain not configured]** → broken public image URLs; `R2_URL` documented as required for production media.
- **[Resend API key missing]** → mail send fails; lead creation must not depend on mail (future phases per SPEC).
- **[Package version drift]** → pin compatible versions in `composer.json`; run `composer security-audit` in CI.
## Migration Plan
1. Merge change; run `composer install` in production image build.
2. Create R2 bucket, API token, and custom domain in Cloudflare dashboard (manual).
3. Create Resend API key and verify sending domain.
4. Set production env: `MAIL_MAILER=resend`, `RESEND_API_KEY`, `FILESYSTEM_DISK=r2`, `R2_*`, `R2_URL`, `MAIL_FROM_*`.
5. Deploy; upload test image via Filament; verify public URL resolves.
6. **Rollback:** revert env to `MAIL_MAILER=log`, `FILESYSTEM_DISK=public`; existing DB paths remain valid for local disk until re-upload.
## Open Questions
- _(none — custom domain confirmed; Resend as sole production mail provider for this change)_

View File

@@ -1,43 +0,0 @@
## Why
The application already supports CMS media uploads and will soon send transactional email (lead notifications, password reset), but production still lacks concrete provider wiring. SPEC §9.1 and §15.4 require S3-compatible object storage in production and transactional email when configured. Resend and Cloudflare R2 are the chosen providers; this change adds the configuration, dependencies, and selection rules so production can send mail and persist public media outside the container filesystem.
## What Changes
- Add **Resend** as the production mail transport via Laravel's native `resend` mailer and `RESEND_API_KEY`.
- Add a dedicated **`r2` filesystem disk** for Cloudflare R2 (S3-compatible API) with explicit R2 env vars and public URLs served through a **custom domain** (`R2_URL`).
- Install required dependencies: `resend/resend-php` and `league/flysystem-aws-s3-v3`.
- Update `.env.example`, README, and deployment guidance with production vs local/test defaults.
- Fix media disk selection in `PublicImageUploadRules` so `FILESYSTEM_DISK=r2` uses the R2 disk instead of falling back to local `public`.
- Add automated config and media-disk selection tests; no live calls to Resend or R2 in CI.
## Non-Goals
Conforme [SPEC.md §4.2](../../SPEC.md), **não** fazem parte desta change:
- Lead notification emails, briefing confirmation flows, or CRM email templates (future phases).
- Private/signed URL access to media; production media is public via custom domain.
- Cloudflare Workers, CDN provisioning, or DNS automation (manual infra setup).
- Redis, queue driver changes, or FrankenPHP worker mode.
- Replacing local `public` disk behavior for development and tests.
## Capabilities
### New Capabilities
- `transactional-email`: Resend-backed transactional mail transport with safe local/test defaults and production configuration via environment variables (SPEC §9.1, §15.4, §12.112.2).
- `object-storage`: Cloudflare R2 object storage via dedicated `r2` disk, custom-domain public URLs, and environment-driven disk selection (SPEC §6.4, §9.1, §15.4).
### Modified Capabilities
- `content-media`: extend disk selection so production `FILESYSTEM_DISK=r2` stores and serves public content images from R2 instead of local `public`.
## Impact
- **Dependencies**: `resend/resend-php`, `league/flysystem-aws-s3-v3` in `composer.json`.
- **Config**: `config/mail.php`, `config/services.php`, `config/filesystems.php`, `.env.example`.
- **Application**: `app/Support/PublicImageUploadRules.php` disk selection logic.
- **Tests**: new Pest feature/unit tests for mail and filesystem config; media disk selection regression test.
- **Docs**: `README.md` production provider section.
- **Infra (manual)**: Resend API key, R2 bucket, R2 API token, and custom domain mapped to the bucket.
- **No breaking change** for local development: defaults remain `MAIL_MAILER=log` and `FILESYSTEM_DISK=local`/`public`.

View File

@@ -1,20 +0,0 @@
## ADDED Requirements
### Requirement: CMS public image uploads select production object storage disk
Public content image uploads (Filament FileUpload via `PublicImageUploadRules`) MUST store files on the disk matching the configured default filesystem (SPEC §6.4). When `FILESYSTEM_DISK` is `r2`, uploads MUST use the `r2` disk. When `FILESYSTEM_DISK` is `s3`, uploads MUST use the `s3` disk. Otherwise uploads MUST use the local `public` disk.
#### Scenario: Production R2 disk is used for CMS uploads
- **WHEN** `FILESYSTEM_DISK=r2`
- **THEN** `PublicImageUploadRules::disk()` MUST return `r2`
#### Scenario: Legacy S3 default still supported
- **WHEN** `FILESYSTEM_DISK=s3`
- **THEN** `PublicImageUploadRules::disk()` MUST return `s3`
#### Scenario: Local development uses public disk
- **WHEN** `FILESYSTEM_DISK` is `local`, unset, or any value other than `r2`/`s3`
- **THEN** `PublicImageUploadRules::disk()` MUST return `public`

View File

@@ -1,44 +0,0 @@
## ADDED Requirements
### Requirement: Cloudflare R2 is available as dedicated filesystem disk
The system SHALL provide an `r2` filesystem disk using Laravel's S3-compatible driver pointed at Cloudflare R2 (SPEC §6.4, §9.1, §15.4). Configuration MUST use explicit `R2_*` environment variables: `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET`, `R2_ENDPOINT`, and `R2_URL`.
#### Scenario: R2 disk is defined in filesystem config
- **WHEN** the application boots
- **THEN** `config('filesystems.disks.r2')` MUST exist
- **AND** the disk driver MUST be `s3`
- **AND** credentials MUST resolve from `R2_ACCESS_KEY_ID` and `R2_SECRET_ACCESS_KEY`
#### Scenario: R2 endpoint uses path-style addressing
- **WHEN** the `r2` disk is configured
- **THEN** `use_path_style_endpoint` MUST be `true`
- **AND** `endpoint` MUST resolve from `R2_ENDPOINT`
#### Scenario: Public URLs use custom domain
- **WHEN** a file is stored on the `r2` disk with public visibility
- **THEN** generated URLs MUST use `R2_URL` as base
- **AND** MUST NOT require signed URLs for browser-facing media
### Requirement: Production default filesystem disk is R2
Production deployments MUST set `FILESYSTEM_DISK=r2` so application code using the default disk stores files on R2.
#### Scenario: Default disk resolves to R2 in production
- **WHEN** `FILESYSTEM_DISK=r2` is set
- **THEN** `config('filesystems.default')` MUST be `r2`
#### Scenario: Local development keeps local disk
- **WHEN** `FILESYSTEM_DISK` is unset or set to `local`/`public` in development
- **THEN** the default disk MUST NOT require R2 credentials to boot
#### Scenario: Tests do not call R2
- **WHEN** the test suite runs
- **THEN** tests MUST use `Storage::fake('r2')` or local fakes
- **AND** no live HTTP request to Cloudflare R2 MUST occur during CI

View File

@@ -1,32 +0,0 @@
## ADDED Requirements
### Requirement: Production transactional email uses Resend
The system SHALL support Resend as the production mail transport via Laravel's native `resend` mailer (SPEC §9.1, §15.4). The Resend API key MUST be read from `RESEND_API_KEY` through `config/services.php`. Production deployments MUST set `MAIL_MAILER=resend` when transactional email is enabled.
#### Scenario: Resend mailer is configured
- **WHEN** the application boots with valid configuration
- **THEN** a `resend` mailer MUST exist in `config/mail.php`
- **AND** `config('services.resend.key')` MUST resolve from `RESEND_API_KEY`
#### Scenario: Local development uses safe mail default
- **WHEN** `APP_ENV` is `local` and `MAIL_MAILER` is unset
- **THEN** the default mailer MUST be `log`
- **AND** no outbound email MUST be sent to external providers
#### Scenario: Tests do not call Resend
- **WHEN** the test suite runs
- **THEN** `MAIL_MAILER` MUST be `array` (or tests MUST use `Mail::fake`)
- **AND** no live HTTP request to Resend MUST occur during CI
### Requirement: Global from address is configurable
The system SHALL read the global sender address and name from `MAIL_FROM_ADDRESS` and `MAIL_FROM_NAME` (SPEC §15.4).
#### Scenario: From address applied to outbound mail
- **WHEN** the application sends mail through the configured mailer
- **THEN** the message MUST use the configured from address and name

View File

@@ -1,33 +0,0 @@
## 1. Dependencies
- [x] 1.1 Add `resend/resend-php` to `composer.json` require
- [x] 1.2 Add `league/flysystem-aws-s3-v3` to `composer.json` require
- [x] 1.3 Run `composer update resend/resend-php league/flysystem-aws-s3-v3 --with-all-dependencies` and commit lockfile
## 2. Transactional email (transactional-email)
- [x] 2.1 Confirm `config/mail.php` resend mailer and `config/services.php` `RESEND_API_KEY` binding
- [x] 2.2 Add `RESEND_API_KEY` and production mail vars to `.env.example` with safe local defaults (`MAIL_MAILER=log`)
- [x] 2.3 Write feature test asserting resend mailer config and services key resolution
## 3. Object storage (object-storage)
- [x] 3.1 Add dedicated `r2` disk to `config/filesystems.php` with `R2_*` env vars, path-style endpoint, and `R2_URL`
- [x] 3.2 Add R2 env vars to `.env.example` with comments for custom domain setup
- [x] 3.3 Write feature test asserting `r2` disk config (driver, endpoint, path-style, url from `R2_URL`)
## 4. CMS media disk selection (content-media)
- [x] 4.1 Update `PublicImageUploadRules::disk()` to return `r2` when `FILESYSTEM_DISK=r2`, keep `s3` and `public` fallbacks
- [x] 4.2 Write unit/feature test for disk selection matrix: `r2`, `s3`, local/unset → `public`
## 5. Documentation
- [x] 5.1 Update `README.md` with production provider section (Resend + R2 + custom domain)
- [x] 5.2 Document production env checklist: `MAIL_MAILER=resend`, `FILESYSTEM_DISK=r2`, required secrets
## 6. Verification
- [x] 6.1 Run `composer pint:check` and `composer phpstan`
- [x] 6.2 Run `composer test:unit` and `composer test:feature` for new provider tests
- [x] 6.3 Run `composer quality` and fix any failures

View File

@@ -1,59 +0,0 @@
<?php
declare(strict_types=1);
afterEach(function (): void {
foreach ([
'R2_ACCESS_KEY_ID',
'R2_SECRET_ACCESS_KEY',
'R2_BUCKET',
'R2_ENDPOINT',
'R2_URL',
] as $key) {
putenv($key);
unset($_ENV[$key], $_SERVER[$key]);
}
});
it('defines an r2 disk with the s3 driver and path-style endpoint', function (): void {
$disk = config('filesystems.disks.r2');
expect($disk)->toBeArray()
->and($disk['driver'])->toBe('s3')
->and($disk['use_path_style_endpoint'])->toBeTrue();
});
it('resolves r2 credentials endpoint bucket and url from R2 env vars', function (): void {
putenv('R2_ACCESS_KEY_ID=r2-key');
putenv('R2_SECRET_ACCESS_KEY=r2-secret');
putenv('R2_BUCKET=amare-media');
putenv('R2_ENDPOINT=https://accountid.r2.cloudflarestorage.com');
putenv('R2_URL=https://media.example.com');
$_ENV['R2_ACCESS_KEY_ID'] = 'r2-key';
$_ENV['R2_SECRET_ACCESS_KEY'] = 'r2-secret';
$_ENV['R2_BUCKET'] = 'amare-media';
$_ENV['R2_ENDPOINT'] = 'https://accountid.r2.cloudflarestorage.com';
$_ENV['R2_URL'] = 'https://media.example.com';
$_SERVER['R2_ACCESS_KEY_ID'] = 'r2-key';
$_SERVER['R2_SECRET_ACCESS_KEY'] = 'r2-secret';
$_SERVER['R2_BUCKET'] = 'amare-media';
$_SERVER['R2_ENDPOINT'] = 'https://accountid.r2.cloudflarestorage.com';
$_SERVER['R2_URL'] = 'https://media.example.com';
$filesystems = require config_path('filesystems.php');
$disk = $filesystems['disks']['r2'];
expect($disk['key'])->toBe('r2-key')
->and($disk['secret'])->toBe('r2-secret')
->and($disk['bucket'])->toBe('amare-media')
->and($disk['endpoint'])->toBe('https://accountid.r2.cloudflarestorage.com')
->and($disk['url'])->toBe('https://media.example.com')
->and($disk['use_path_style_endpoint'])->toBeTrue()
->and($disk['visibility'])->toBe('public');
});
it('uses r2 as the default disk when FILESYSTEM_DISK is r2', function (): void {
config(['filesystems.default' => 'r2']);
expect(config('filesystems.default'))->toBe('r2');
});

View File

@@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
afterEach(function (): void {
putenv('RESEND_API_KEY');
unset($_ENV['RESEND_API_KEY'], $_SERVER['RESEND_API_KEY']);
});
it('defines the resend mailer transport', function (): void {
expect(config('mail.mailers.resend.transport'))->toBe('resend');
});
it('resolves the resend api key from RESEND_API_KEY', function (): void {
putenv('RESEND_API_KEY=test-resend-key');
$_ENV['RESEND_API_KEY'] = 'test-resend-key';
$_SERVER['RESEND_API_KEY'] = 'test-resend-key';
$services = require config_path('services.php');
expect($services['resend']['key'])->toBe('test-resend-key');
});
it('defaults the mailer to log in config when MAIL_MAILER is unset', function (): void {
$contents = file_get_contents(config_path('mail.php'));
expect($contents)->toContain("env('MAIL_MAILER', 'log')");
});

View File

@@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use App\Support\PublicImageUploadRules;
use Tests\TestCase;
class PublicImageUploadRulesDiskTest extends TestCase
{
public function test_uses_r2_disk_when_default_filesystem_is_r2(): void
{
config(['filesystems.default' => 'r2']);
$this->assertSame('r2', PublicImageUploadRules::disk());
}
public function test_uses_s3_disk_when_default_filesystem_is_s3(): void
{
config(['filesystems.default' => 's3']);
$this->assertSame('s3', PublicImageUploadRules::disk());
}
public function test_uses_public_disk_for_local_or_unset_default(): void
{
config(['filesystems.default' => 'local']);
$this->assertSame('public', PublicImageUploadRules::disk());
config(['filesystems.default' => 'public']);
$this->assertSame('public', PublicImageUploadRules::disk());
config(['filesystems.default' => null]);
$this->assertSame('public', PublicImageUploadRules::disk());
}
}